From 1c216d6278b6f709d307db4e416252efd4757c3d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 10 Sep 2026 22:18:38 +0200 Subject: [PATCH] fix(deploy): a missing sandbox token must not stop the whole stage The production deploy of #819 failed at config load, before any resource was touched, so nothing deployed at all: ConfigError: SchemaError(Expected string at ["SANDBOX_INTERNAL_SERVICE_TOKEN"]) `requireSecretEntry` in the sandbox Worker's props was meant to refuse a sandbox that would answer 401 to every call, which is right. What it actually did was fail `alchemy deploy` for the entire stage on a secret nobody had provisioned yet, taking every other Worker with it. The decision moves up to the stack, where it can be made without failing: `secretIsSet` reports presence rather than reading the value, and the sandbox Worker is declared only when the stage runs one AND the token is there. Without it the deploy proceeds, the sandbox is not provisioned, and the api already logs that the repository sandbox is unavailable. The Worker's own props keep requiring the token, which is now unreachable with it absent. Setting SANDBOX_INTERNAL_SERVICE_TOKEN for prd/stg is what turns the feature on. Co-Authored-By: Claude Opus 5 --- alchemy.run.ts | 17 +++++++++++++++-- packages/infra/src/env.test.ts | 24 ++++++++++++++++++++++++ packages/infra/src/env.ts | 12 ++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) 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(