diff --git a/alchemy.run.ts b/alchemy.run.ts index 462dd19e2..15faf019c 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -35,7 +35,7 @@ import { resolveMapleDomains, } from "@maple/infra/cloudflare" import * as Acm from "@maple/infra/acm" -import { optionalPlain, plainWithDefault } from "@maple/infra/env" +import { optionalPlain, plainWithDefault, secretIsSet } from "@maple/infra/env" import * as Portless from "@maple/alchemy-portless" import { DEV_PROCESS_APPS, selectedDevApps, type DevApp } from "@maple/infra/dev-urls" import Alerting from "./apps/alerting/src/worker.ts" @@ -217,7 +217,20 @@ export default Alchemy.Stack( // Object, and the api binds it as `SANDBOX`. Yielded first so the binding // sees a Worker this deploy created rather than stored state, and only on // the stages that run it — see `stageDeploysSandbox`. - const sandbox = stageDeploysSandbox(stage) ? yield* MapleSandbox : undefined + // + // The token is checked here rather than required inside the Worker's own + // props. Both refuse to deploy a sandbox that would answer 401 to every + // call, but a `requiredSecret` in the props fails the whole `alchemy deploy` + // at config load, before any resource is touched — so a token nobody had + // provisioned yet stopped every Worker in the stage from updating. Deciding + // it here keeps the blast radius at the feature: no token, no sandbox, and + // the api logs that it is unavailable. + const sandboxTokenSet = yield* secretIsSet("SANDBOX_INTERNAL_SERVICE_TOKEN") + if (stageDeploysSandbox(stage) && !sandboxTokenSet) + yield* Effect.logWarning( + "skipping the repository sandbox: SANDBOX_INTERNAL_SERVICE_TOKEN is not set for this stage", + ) + const sandbox = stageDeploysSandbox(stage) && sandboxTokenSet ? yield* MapleSandbox : undefined const api = yield* sandbox === undefined ? MapleApi : Effect.provideService(MapleApi, SandboxWorker, sandbox) diff --git a/packages/infra/src/env.test.ts b/packages/infra/src/env.test.ts index 9ab6b72b1..cfc5a090f 100644 --- a/packages/infra/src/env.test.ts +++ b/packages/infra/src/env.test.ts @@ -12,6 +12,8 @@ import { ingestKeyCryptoEnv, optionalPlain, optionalSecret, + requireSecretEntry, + secretIsSet, planetScaleOAuthEnv, plainWithDefault, PRD_LOCKSTEP_REVISION_SERVICES, @@ -124,6 +126,28 @@ describe("primitives", () => { }) }) +describe("secretIsSet", () => { + // Gates whether a resource is declared at all, so it must agree with + // `requiredSecret` on what counts as present: a blank value is not. + it("is false when the key is missing or blank, true only for a real value", () => { + expect(run(secretIsSet("SANDBOX_INTERNAL_SERVICE_TOKEN"), {})).toBe(false) + expect( + run(secretIsSet("SANDBOX_INTERNAL_SERVICE_TOKEN"), { SANDBOX_INTERNAL_SERVICE_TOKEN: "" }), + ).toBe(false) + expect( + run(secretIsSet("SANDBOX_INTERNAL_SERVICE_TOKEN"), { SANDBOX_INTERNAL_SERVICE_TOKEN: " " }), + ).toBe(false) + expect( + run(secretIsSet("SANDBOX_INTERNAL_SERVICE_TOKEN"), { SANDBOX_INTERNAL_SERVICE_TOKEN: "tok" }), + ).toBe(true) + }) + + it("never fails, so a missing secret cannot abort the deploy before any resource is touched", () => { + expect(runExit(secretIsSet("SANDBOX_INTERNAL_SERVICE_TOKEN"), {})._tag).toBe("Success") + expect(runExit(requireSecretEntry("SANDBOX_INTERNAL_SERVICE_TOKEN"), {})._tag).toBe("Failure") + }) +}) + describe("selfObservabilityEnv", () => { const base = { MAPLE_OTEL_INGEST_KEY: "maple_ak_test" } diff --git a/packages/infra/src/env.ts b/packages/infra/src/env.ts index cfb489283..c960790f2 100644 --- a/packages/infra/src/env.ts +++ b/packages/infra/src/env.ts @@ -106,6 +106,18 @@ export const optionalPlain = (key: string, fallback?: string): Config.Config => trimmedOption(key).pipe(Config.map((value) => entry(key, Option.map(value, Redacted.make)))) +/** + * Whether a secret is set, without reading it. + * + * For deciding at the stack level whether a resource that *requires* a secret + * should be declared at all. `requiredSecret` inside a resource's props is the + * right shape once the resource exists, but it fails the whole `alchemy deploy` + * before any resource is touched — so a secret nobody provisioned takes down + * every Worker in the stage rather than the one feature that needed it. + */ +export const secretIsSet = (key: string): Config.Config => + trimmedOption(key).pipe(Config.map(Option.isSome)) + /** The first present-and-non-blank of `keys`, else `fallback`. For build vars with a `VITE_` twin. */ export const plainFrom = (keys: ReadonlyArray, fallback: string): Config.Config => Config.all(keys.map(trimmedOption)).pipe(