diff --git a/deploy/README.md b/deploy/README.md index 0383f00f..fa1a40cf 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -87,6 +87,7 @@ RECTIFICATION_PRICE_CREDITS=3 # Required to encrypt/decrypt model-provider API keys stored in the admin database. # Base64 decoding must produce exactly 32 random bytes; do not reuse other keys. +# Staging migration/deploy creates this once when absent and removes legacy model API-key settings. MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY= # OpenAI-compatible origins must be explicitly server-allowlisted. # MODEL_PROVIDER_BASE_URL_ALLOWLIST=https://api.deepseek.com diff --git a/deploy/prepare-staging-model-provider-env.sh b/deploy/prepare-staging-model-provider-env.sh new file mode 100755 index 00000000..a377811b --- /dev/null +++ b/deploy/prepare-staging-model-provider-env.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail +set +x + +ENV_FILE="${1:-.env.staging}" +if [ ! -f "$ENV_FILE" ] || [ -L "$ENV_FILE" ]; then + echo "staging environment file is missing or unsafe: $ENV_FILE" >&2 + exit 1 +fi + +legacy_pattern='^(OPENAI_API_KEY|ANTHROPIC_API_KEY|DEEPSEEK_API_KEY|LLM_API_KEY|LLM_MODELS_JSON|LLM_BASE_URL|LLM_MODEL|LLM_DEFAULT_MODEL_ID|LLM_PROVIDER_ID|MASTRA_MODEL|MODEL_PROVIDER_[A-Z0-9_]+_API_KEY)=' +key_count="$(grep -Ec '^MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY=' "$ENV_FILE" || true)" +if [ "$key_count" -gt 1 ]; then + echo "duplicate staging model provider encryption key" >&2 + exit 1 +fi + +if mode="$(stat -c '%a' "$ENV_FILE" 2>/dev/null)"; then + owner="$(stat -c '%u' "$ENV_FILE")" + group="$(stat -c '%g' "$ENV_FILE")" +else + mode="$(stat -f '%Lp' "$ENV_FILE")" + owner="$(stat -f '%u' "$ENV_FILE")" + group="$(stat -f '%g' "$ENV_FILE")" +fi +temporary="$(mktemp "${ENV_FILE}.tmp.XXXXXX")" +trap 'rm -f -- "$temporary"' EXIT + +grep -Ev "$legacy_pattern" "$ENV_FILE" > "$temporary" || true +if [ "$key_count" -eq 0 ]; then + key="$(openssl rand -base64 32 | tr -d '\n')" + [[ "$key" =~ ^[A-Za-z0-9+/]{43}=$ ]] || exit 1 + printf 'MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY=%s\n' "$key" >> "$temporary" +fi + +chmod "$mode" "$temporary" +chown "$owner:$group" "$temporary" +mv -f -- "$temporary" "$ENV_FILE" +trap - EXIT diff --git a/deploy/run-staging-deploy.sh b/deploy/run-staging-deploy.sh index 7a52c90d..4fd18af4 100755 --- a/deploy/run-staging-deploy.sh +++ b/deploy/run-staging-deploy.sh @@ -126,6 +126,7 @@ EXPECTED_STAGING_ENV_OWNER_UID="$(stat -c '%u' "$DEPLOY_PATH" 2>/dev/null || sta exit 1 } export EXPECTED_STAGING_ENV_OWNER_UID +bash deploy/prepare-staging-model-provider-env.sh .env.staging bash deploy/validate-staging-env.sh \ .env.staging staging.jyotisha.chat deploy/Caddyfile.staging bash deploy/validate-staging-database-env.sh .env.staging.database diff --git a/deploy/run-staging-migration.sh b/deploy/run-staging-migration.sh index 60dda620..743e30a8 100755 --- a/deploy/run-staging-migration.sh +++ b/deploy/run-staging-migration.sh @@ -78,6 +78,7 @@ EXPECTED_STAGING_ENV_OWNER_UID="$(stat -c '%u' "$DEPLOY_PATH" 2>/dev/null || sta exit 1 } export EXPECTED_STAGING_ENV_OWNER_UID +bash deploy/prepare-staging-model-provider-env.sh .env.staging bash deploy/validate-staging-env.sh \ .env.staging staging.jyotisha.chat deploy/Caddyfile.staging bash deploy/validate-staging-database-env.sh .env.staging.database diff --git a/frontend/tests/health-deployment.test.ts b/frontend/tests/health-deployment.test.ts index 1c6cac2c..253cd53e 100644 --- a/frontend/tests/health-deployment.test.ts +++ b/frontend/tests/health-deployment.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, readFileSync, rmSync, + statSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -222,6 +223,33 @@ test("staging deploy consumes only the isolated staging environment and tested r assert.match(syncController, /--exclude='\/backups\/'/); }); +test("staging model provider env preparation removes legacy settings and keeps one stable key", () => { + const prepare = fileURLToPath( + new URL("../../deploy/prepare-staging-model-provider-env.sh", import.meta.url), + ); + const root = mkdtempSync(join(tmpdir(), "jyotisha-staging-model-env-")); + const envFile = join(root, ".env.staging"); + try { + writeFileSync(envFile, "APP_ENV_FILE=../.env.staging\nOPENAI_API_KEY=legacy\nLLM_MODEL=legacy\n"); + chmodSync(envFile, 0o600); + const first = spawnSync("bash", [prepare, envFile], { encoding: "utf8" }); + assert.equal(first.status, 0, first.stderr); + const prepared = readFileSync(envFile, "utf8"); + assert.match(prepared, /^APP_ENV_FILE=\.\.\/\.env\.staging$/m); + assert.doesNotMatch(prepared, /^(OPENAI_API_KEY|LLM_MODEL)=/m); + const key = prepared.match(/^MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY=([A-Za-z0-9+/]{43}=)$/m)?.[1]; + assert.ok(key); + assert.equal(statSync(envFile).mode & 0o777, 0o600); + + const second = spawnSync("bash", [prepare, envFile], { encoding: "utf8" }); + assert.equal(second.status, 0, second.stderr); + const secondKey = readFileSync(envFile, "utf8").match(/^MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY=([A-Za-z0-9+/]{43}=)$/m)?.[1]; + assert.equal(secondKey, key); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("staging env validator rejects selector drift, duplicates, and unsafe permissions", () => { const validator = fileURLToPath( new URL("../../deploy/validate-staging-env.sh", import.meta.url), diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index 3019e20f..4c9756b9 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -649,6 +649,7 @@ test("first immutable deployment rolls back to validated local image IDs", () => writeFileSync(join(deploymentPath, ".state", "deployed-revision"), `${previousSha}\n`); for (const script of [ join(incomingDeploy, "sync-staging-tree.sh"), + join(liveDeploy, "prepare-staging-model-provider-env.sh"), join(liveDeploy, "validate-staging-env.sh"), join(liveDeploy, "validate-staging-database-env.sh"), ]) { @@ -726,6 +727,7 @@ test("normal deployment checks migrations but never applies them", () => { assert.match(runner, /^#!\/usr\/bin\/env bash\nset -euo pipefail\nset \+x\n/); assert.match(runner, /-f deploy\/docker-compose\.staging\.yml/); assertOrder(runner, [ + "prepare-staging-model-provider-env.sh", "validate-staging-env.sh", "validate-staging-database-env.sh", "compose=(",