Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/web/src/components/settings/EnvVars.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ export function EnvVars() {
}
>
<p className="text-sm text-muted-foreground">
Encrypted variables available to tasks in every environment.
Encrypted variables injected into tasks in every environment. Model
provider credentials are managed under Models.
</p>
{isPending ? (
<table className="w-full">
Expand Down
46 changes: 45 additions & 1 deletion apps/web/src/trpc/commands/environment-variables/index.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

157 changes: 148 additions & 9 deletions apps/web/src/trpc/commands/environment-variables/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import {
db,
environmentVariables,
modelProviderEnvironmentVariables,
eq,
desc,
inArray,
not,
getTableColumns,
stringifyDecryptedEnvVarValue,
getPersistedModelProviderEnvironmentVariableNames,
type DatabaseOrTransaction,
} from '@roomote/db/server';
import { decryptSecrets } from '@roomote/db/encryption';
Expand All @@ -16,6 +18,8 @@ import {
CONTROL_PLANE_ENV_VAR_NAMES,
isAutoProvisionedComputeArtifactField,
ROOMOTE_MANAGED_ENV_VAR_NAMES,
DEFAULT_MODEL_PROVIDER_ENV_KEYS,
isOpenAiCompatibleProviderEnvVarName,
SOURCE_CONTROL_SECRET_ENV_VAR_NAMES,
normalizePemEnvValue,
} from '@roomote/types';
Expand Down Expand Up @@ -79,8 +83,28 @@ export async function getPersistedEnvironmentVariableValues(
const PROVIDER_MANAGED_ENV_VAR_NAME_LIST = [
...CONTROL_PLANE_ENV_VAR_NAMES,
...ROOMOTE_MANAGED_ENV_VAR_NAMES,
...DEFAULT_MODEL_PROVIDER_ENV_KEYS,
'R_MODEL_ENV_KEYS',
];

function isStaticModelProviderEnvVarName(name: string): boolean {
return (
DEFAULT_MODEL_PROVIDER_ENV_KEYS.includes(name) ||
name === 'R_MODEL_ENV_KEYS' ||
isOpenAiCompatibleProviderEnvVarName(name)
);
}

async function isModelProviderEnvVarName(name: string): Promise<boolean> {
if (isStaticModelProviderEnvVarName(name)) {
return true;
}

const modelProviderNames =
await getPersistedModelProviderEnvironmentVariableNames();
return modelProviderNames.includes(name);
}

export async function upsertDeploymentEnvironmentVariables(
tx: DatabaseOrTransaction,
{
Expand Down Expand Up @@ -148,6 +172,72 @@ export async function upsertDeploymentEnvironmentVariables(
}
}

/**
* Model settings dual-write to the dedicated store and the legacy deployment
* table for N-1 rollback compatibility. Remove the legacy write after the
* dedicated-store release is the oldest supported rollback target.
*/
export async function upsertModelProviderEnvironmentVariables(
tx: DatabaseOrTransaction,
{
userId,
values,
}: {
userId: string | null;
values: Array<{ name: string; value: string }>;
},
) {
if (values.length === 0) {
return;
}

await upsertDeploymentEnvironmentVariables(tx, { userId, values });

const names = Array.from(new Set(values.map((value) => value.name)));
const existingRows = await tx
.select({
id: modelProviderEnvironmentVariables.id,
name: modelProviderEnvironmentVariables.name,
})
.from(modelProviderEnvironmentVariables)
.where(inArray(modelProviderEnvironmentVariables.name, names));
const existingByName = new Map(existingRows.map((row) => [row.name, row.id]));
const now = new Date();
const valuesToInsert: Array<{
name: string;
value: string;
createdByUserId: string | null;
lastUpdatedByUserId: string | null;
}> = [];

for (const value of values) {
const normalizedValue = normalizePemEnvValue(value.value);
const existingId = existingByName.get(value.name);

if (existingId) {
await tx
.update(modelProviderEnvironmentVariables)
.set({
value: normalizedValue,
lastUpdatedByUserId: userId,
updatedAt: now,
})
.where(eq(modelProviderEnvironmentVariables.id, existingId));
} else {
valuesToInsert.push({
name: value.name,
value: normalizedValue,
createdByUserId: userId,
lastUpdatedByUserId: userId,
});
}
}

if (valuesToInsert.length > 0) {
await tx.insert(modelProviderEnvironmentVariables).values(valuesToInsert);
}
}

export async function deleteDeploymentEnvironmentVariables(
tx: DatabaseOrTransaction,
names: string[],
Expand All @@ -161,20 +251,51 @@ export async function deleteDeploymentEnvironmentVariables(
.where(inArray(environmentVariables.name, [...new Set(names)]));
}

export async function deleteModelProviderEnvironmentVariables(
tx: DatabaseOrTransaction,
names: string[],
) {
if (names.length === 0) {
return;
}

const uniqueNames = [...new Set(names)];
// Keep deleting both stores during the N-1 dual-write release.
await Promise.all([
tx
.delete(modelProviderEnvironmentVariables)
.where(inArray(modelProviderEnvironmentVariables.name, uniqueNames)),
deleteDeploymentEnvironmentVariables(tx, uniqueNames),
]);
}

export async function getEnvVarsCommand(auth: UserAuthSuccess) {
assertAdmin(auth);

const { value: _value, ...columns } = getTableColumns(environmentVariables);

return db
.select(columns)
.from(environmentVariables)
.where(
not(
inArray(environmentVariables.name, PROVIDER_MANAGED_ENV_VAR_NAME_LIST),
),
)
.orderBy(desc(environmentVariables.updatedAt));
const [rows, modelProviderNames] = await Promise.all([
db
.select(columns)
.from(environmentVariables)
.where(
not(
inArray(
environmentVariables.name,
PROVIDER_MANAGED_ENV_VAR_NAME_LIST,
),
),
)
.orderBy(desc(environmentVariables.updatedAt)),
getPersistedModelProviderEnvironmentVariableNames(),
]);
const modelProviderNameSet = new Set(modelProviderNames);

return rows.filter(
(row) =>
!modelProviderNameSet.has(row.name) &&
!isStaticModelProviderEnvVarName(row.name),
);
}

export async function deleteEnvVarCommand(
Expand All @@ -193,6 +314,12 @@ export async function deleteEnvVarCommand(
return { success: false as const, error: 'Environment variable not found' };
}

if (await isModelProviderEnvVarName(envVar.name)) {
throw new Error(
`"${envVar.name}" is managed under Settings → Models and cannot be deleted here.`,
);
}

await db.transaction(async (tx) => {
await tx
.delete(environmentVariables)
Expand Down Expand Up @@ -245,6 +372,12 @@ export async function createEnvVarCommand(
throw new Error(`"${name}" is managed by Roomote and cannot be set here.`);
}

if (await isModelProviderEnvVarName(name)) {
throw new Error(
`"${name}" is reserved for model-provider configuration during the compatibility rollout. Configure it under Settings → Models.`,
);
}

const [existing] = await db
.select()
.from(environmentVariables)
Expand Down Expand Up @@ -290,6 +423,12 @@ export async function updateEnvVarCommand(
throw new Error('Environment variable not found');
}

if (await isModelProviderEnvVarName(envVar.name)) {
throw new Error(
`"${envVar.name}" is managed under Settings → Models and cannot be updated here.`,
);
}

const [updatedEnvVar] = await db
.update(environmentVariables)
.set({
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading