diff --git a/cli/package.json b/cli/package.json index cf24c1d6..b764c1b9 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@yc-software/qm", - "version": "0.1.9", + "version": "0.1.10", "license": "MIT", "description": "Control-plane CLI for portable QM deployments on Docker, Fly, and AWS.", "type": "module", diff --git a/cli/src/config.ts b/cli/src/config.ts index 55cf346e..d5beba2c 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -217,6 +217,10 @@ export function sandboxImagePinErrors(config: QmConfig): Array<{ clause: string; ]; } +export function flyImplicitSandboxBackend(config: QmConfig): string | undefined { + return config.sandbox?.backend ?? (config.target === "fly" && config.sandbox?.app ? "sprites" : undefined); +} + export function sandboxCoreEnv( config: QmConfig, lookup?: (name: string) => string | undefined, @@ -231,7 +235,7 @@ export function sandboxCoreEnv( if (violation) throw new CliError(violation.message, { clause: violation.clause }); env.FLY_SANDBOX_APP_NAME = sb.app; env.FLY_BASE_IMAGE = sb.image; - const backend = sb.backend ?? (config.target === "fly" ? "sprites" : undefined); + const backend = flyImplicitSandboxBackend(config); if (backend) env.SANDBOX_BACKEND = backend; } for (const [k, v] of Object.entries(sb.env ?? {})) env[`FLY_RESIDENT_ENV_${k}`] = v; diff --git a/cli/src/target-env-defaults.ts b/cli/src/target-env-defaults.ts index fd53379d..d8cc2d93 100644 --- a/cli/src/target-env-defaults.ts +++ b/cli/src/target-env-defaults.ts @@ -1,4 +1,4 @@ -import type { QmConfig } from "./config.ts"; +import { flyImplicitSandboxBackend, type QmConfig } from "./config.ts"; import type { Target } from "./providers.ts"; /** @@ -17,7 +17,10 @@ const AWS_RENDER_ENV_DEFAULTS: Readonly = { docker: () => undefined, - fly: (_config, service, name) => FLY_TEMPLATE_ENV_DEFAULTS[service]?.[name], + fly: (config, service, name) => { + if (service === "core" && name === "SANDBOX_BACKEND") return flyImplicitSandboxBackend(config); + return FLY_TEMPLATE_ENV_DEFAULTS[service]?.[name]; + }, aws: (config, service, name) => { const rendered = AWS_RENDER_ENV_DEFAULTS[service]?.[name]; if (rendered === undefined) return undefined; diff --git a/cli/test/e2e/fly.e2e.test.ts b/cli/test/e2e/fly.e2e.test.ts index 69adf138..32f8cf94 100644 --- a/cli/test/e2e/fly.e2e.test.ts +++ b/cli/test/e2e/fly.e2e.test.ts @@ -14,6 +14,7 @@ const ALL_SECRETS = [ "CORE_SIGNING_SECRET", "PORTAL_IDENTITY_SECRET", "SKILL_SIGNING_SECRET", + "SPRITES_TOKEN", "PUBLIC_API_URL", "FLY_API_TOKEN", "SLACK_BOT_TOKEN", diff --git a/cli/test/fly-sandbox.test.ts b/cli/test/fly-sandbox.test.ts index 104c71ec..0367f20c 100644 --- a/cli/test/fly-sandbox.test.ts +++ b/cli/test/fly-sandbox.test.ts @@ -265,6 +265,7 @@ test("fly secrets push stages a dual-role secret under BOTH names on the core ap "PORTAL_IDENTITY_SECRET=identity", `SKILL_SIGNING_SECRET=${"skill-signing".repeat(3)}`, "FLY_SANDBOX_API_TOKEN=f", + "SPRITES_TOKEN=spr", "PUBLIC_API_URL=https://core.example.test", "SLACK_BOT_TOKEN=xoxb", "SLACK_APP_TOKEN=xapp", @@ -342,6 +343,7 @@ test("fly secrets push warns that staged secrets are not live when machines are "PORTAL_IDENTITY_SECRET=portal-identity-secret-that-is-long-enough", `SKILL_SIGNING_SECRET=${"skill-signing".repeat(3)}`, "FLY_SANDBOX_API_TOKEN=fly", + "SPRITES_TOKEN=spr", ].join("\n"), ); const fake = fakeFly( @@ -398,6 +400,7 @@ test("fly secrets push stays quiet about staging when no machines are running", "PORTAL_IDENTITY_SECRET=portal-identity-secret-that-is-long-enough", `SKILL_SIGNING_SECRET=${"skill-signing".repeat(3)}`, "FLY_SANDBOX_API_TOKEN=fly", + "SPRITES_TOKEN=spr", ].join("\n"), ); const fake = fakeFly( @@ -450,6 +453,7 @@ test("fly secrets push removes the disabled Fly app publisher token", async () = "PORTAL_IDENTITY_SECRET=portal-identity-secret-that-is-long-enough", `SKILL_SIGNING_SECRET=${"skill-signing".repeat(3)}`, "FLY_SANDBOX_API_TOKEN=fly", + "SPRITES_TOKEN=spr", ].join("\n"), ); const fake = fakeFly( diff --git a/cli/test/fly-timing.test.ts b/cli/test/fly-timing.test.ts index 24888ffc..3434edc3 100644 --- a/cli/test/fly-timing.test.ts +++ b/cli/test/fly-timing.test.ts @@ -24,7 +24,7 @@ if (args[0] === "apps" && args[1] === "create") { } else if (args[0] === "secrets" && args[1] === "set") { console.log("staged"); } else if (args[0] === "secrets" && args[1] === "list") { - console.log("ADMIN_GRANTS\\nANTHROPIC_API_KEY\\nAWS_ACCESS_KEY_ID\\nAWS_ENDPOINT_URL_S3\\nAWS_SECRET_ACCESS_KEY\\nCAPABILITY_SECRET\\nCONNECTOR_SECRET_KEY\\nCORE_SIGNING_SECRET\\nPORTAL_IDENTITY_SECRET\\nSKILL_SIGNING_SECRET\\nFLY_API_TOKEN\\nPUBLIC_API_URL\\n" + (process.env.FAKE_FLY_FRESH_PG ? "" : "DATABASE_URL\\n") + "SLACK_BOT_TOKEN\\nSLACK_APP_TOKEN"); + console.log("ADMIN_GRANTS\\nANTHROPIC_API_KEY\\nAWS_ACCESS_KEY_ID\\nAWS_ENDPOINT_URL_S3\\nAWS_SECRET_ACCESS_KEY\\nCAPABILITY_SECRET\\nCONNECTOR_SECRET_KEY\\nCORE_SIGNING_SECRET\\nPORTAL_IDENTITY_SECRET\\nSKILL_SIGNING_SECRET\\nSPRITES_TOKEN\\nFLY_API_TOKEN\\nPUBLIC_API_URL\\n" + (process.env.FAKE_FLY_FRESH_PG ? "" : "DATABASE_URL\\n") + "SLACK_BOT_TOKEN\\nSLACK_APP_TOKEN"); } else if (args[0] === "mpg" && args[1] === "list") { console.log(process.env.FAKE_FLY_FRESH_PG ? "" : "pg-1 test-pg"); } else if (args[0] === "mpg" && args[1] === "create") { diff --git a/cli/test/fly-up.test.ts b/cli/test/fly-up.test.ts index 135e771e..1a5f476a 100644 --- a/cli/test/fly-up.test.ts +++ b/cli/test/fly-up.test.ts @@ -87,6 +87,7 @@ else if (a[0] === "secrets" && a[1] === "list") { "PORTAL_IDENTITY_SECRET", "SECURITY_SCREEN_PROXY_TOKEN", "SKILL_SIGNING_SECRET", + "SPRITES_TOKEN", ] .map((name) => `${name} digest`) .join("\n"), diff --git a/cli/test/secrets.test.ts b/cli/test/secrets.test.ts index c10f8990..b2776343 100644 --- a/cli/test/secrets.test.ts +++ b/cli/test/secrets.test.ts @@ -192,6 +192,42 @@ test("the sprites token is a catalog secret when the sandbox backend is sprites" ); }); +test("a Fly deployment relying on the implicit sprites default is required to configure SPRITES_TOKEN (#423)", () => { + const implicitBackend = makeConfig({ + target: "fly", + sandbox: { + app: "acme-sb", + image: "registry.fly.io/acme-sb@sha256:4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d", + }, + }); + assert.ok( + secretByName(implicitBackend, "SPRITES_TOKEN").required, + "qm check must require SPRITES_TOKEN for a Fly deployment that boots with the implicit sprites default", + ); + + const explicitBackend = makeConfig({ + target: "fly", + sandbox: { + app: "acme-sb", + backend: "sprites", + image: "registry.fly.io/acme-sb@sha256:5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e", + }, + }); + assert.ok(secretByName(explicitBackend, "SPRITES_TOKEN").required, "an explicit sandbox.backend keeps requiring it"); + + const noSandboxApp = makeConfig({ target: "fly" }); + assert.ok( + !computedSecrets(noSandboxApp).some((secret) => secret.name === "SPRITES_TOKEN"), + "a Fly target with no sandbox.app configured has no sandbox backend to require a token for", + ); + + const awsImplicitBackend = makeConfig({ target: "aws", sandbox: { app: "acme-sb" } }); + assert.ok( + !computedSecrets(awsImplicitBackend).some((secret) => secret.name === "SPRITES_TOKEN"), + 'AWS\'s own implicit SANDBOX_BACKEND default resolves to "aws", never "sprites", so it must stay unaffected', + ); +}); + test("naming a base model provider makes that provider's key a required deployment secret", () => { for (const [provider, key] of [ ["anthropic", "ANTHROPIC_API_KEY"],