@coreplane/switchboard 0.0.0 → 1.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +17 -1
- package/dist/assets/.dockerignore +27 -0
- package/dist/assets/.env.example +33 -0
- package/dist/assets/Dockerfile +111 -0
- package/dist/assets/config/config.example.yaml +359 -0
- package/dist/assets/deploy/bin/build-stamp.d.mts +15 -0
- package/dist/assets/deploy/bin/build-stamp.mjs +98 -0
- package/dist/assets/deploy/bin/cf-logs +32 -0
- package/dist/assets/deploy/cloudflare/package.json +29 -0
- package/dist/assets/deploy/cloudflare/preflight.mjs +243 -0
- package/dist/assets/deploy/cloudflare/tsconfig.json +18 -0
- package/dist/assets/deploy/cloudflare/worker.ts +382 -0
- package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +67 -0
- package/dist/assets/deploy/cloudflare/write-build.d.mts +7 -0
- package/dist/assets/deploy/cloudflare/write-build.mjs +53 -0
- package/dist/assets/deploy/cloudflare-docs/package.json +18 -0
- package/dist/assets/deploy/cloudflare-docs/wrangler.template.jsonc +30 -0
- package/dist/assets/deploy/cloudflare-memory/package.json +25 -0
- package/dist/assets/deploy/cloudflare-memory/tsconfig.json +17 -0
- package/dist/assets/deploy/cloudflare-memory/worker.ts +2635 -0
- package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +50 -0
- package/dist/assets/deploy/cloudflare-resident/Dockerfile +91 -0
- package/dist/assets/deploy/cloudflare-resident/gc.ts +287 -0
- package/dist/assets/deploy/cloudflare-resident/node-async-hooks.d.ts +11 -0
- package/dist/assets/deploy/cloudflare-resident/package.json +29 -0
- package/dist/assets/deploy/cloudflare-resident/preflight.mjs +224 -0
- package/dist/assets/deploy/cloudflare-resident/tsconfig.json +19 -0
- package/dist/assets/deploy/cloudflare-resident/worker.ts +6637 -0
- package/dist/assets/deploy/cloudflare-resident/wrangler.template.jsonc +120 -0
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +67 -0
- package/dist/assets/deploy/cloudflare-sandbox/docker-wrapper.sh +37 -0
- package/dist/assets/deploy/cloudflare-sandbox/package.json +26 -0
- package/dist/assets/deploy/cloudflare-sandbox/tsconfig.json +20 -0
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +410 -0
- package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +67 -0
- package/dist/assets/deploy/profile.example.json +13 -0
- package/dist/assets/deploy/secrets.manifest.json +108 -0
- package/dist/assets/docker-entrypoint.sh +15 -0
- package/dist/assets/package-lock.json +18407 -0
- package/dist/assets/package.json +104 -0
- package/dist/assets/project.json +219 -0
- package/dist/assets/source.json +5 -0
- package/dist/assets/src/core/authz/actor.ts +100 -0
- package/dist/assets/src/core/authz/authorize.ts +169 -0
- package/dist/assets/src/core/authz/grants.ts +347 -0
- package/dist/assets/src/core/authz/policy.ts +281 -0
- package/dist/assets/src/core/authz/resource.ts +147 -0
- package/dist/assets/src/core/authz/types.ts +164 -0
- package/dist/assets/src/core/drain.ts +54 -0
- package/dist/assets/src/core/ingressTokens.ts +64 -0
- package/dist/assets/src/core/memory/engine.ts +115 -0
- package/dist/assets/src/core/memory/scorer.ts +147 -0
- package/dist/assets/src/core/memory/types.ts +120 -0
- package/dist/assets/src/core/normalizeSpans.ts +299 -0
- package/dist/assets/src/core/prDescriptionTypes.ts +54 -0
- package/dist/assets/src/core/redact.ts +113 -0
- package/dist/assets/src/core/runEvents.ts +537 -0
- package/dist/assets/src/core/runFriction.ts +665 -0
- package/dist/assets/src/core/runLedger/decisions.ts +126 -0
- package/dist/assets/src/core/runLedger/types.ts +177 -0
- package/dist/assets/src/core/runRecord.ts +627 -0
- package/dist/assets/src/core/runShape.ts +61 -0
- package/dist/assets/src/core/schedules.ts +452 -0
- package/dist/assets/src/core/time/formatDuration.ts +61 -0
- package/dist/assets/src/core/trace/attrs.ts +203 -0
- package/dist/assets/src/core/trace/classify.ts +49 -0
- package/dist/assets/src/core/trace/clock.ts +6 -0
- package/dist/assets/src/core/trace/context.ts +9 -0
- package/dist/assets/src/core/trace/ids.ts +23 -0
- package/dist/assets/src/core/trace/partition.ts +235 -0
- package/dist/assets/src/core/trace/sinks.ts +68 -0
- package/dist/assets/src/core/trace/streamSpans.ts +163 -0
- package/dist/assets/src/core/trace/traceparent.ts +29 -0
- package/dist/assets/src/core/trace/tracer.ts +247 -0
- package/dist/assets/src/core/trace/types.ts +125 -0
- package/dist/assets/src/core/trace/workerTrace.ts +97 -0
- package/dist/assets/src/deploy/buildStamp.ts +93 -0
- package/dist/assets/src/deploy/liveGate.ts +203 -0
- package/dist/assets/src/deploy/profile.ts +162 -0
- package/dist/assets/src/deploy/restart.ts +393 -0
- package/dist/assets/src/effort.ts +17 -0
- package/dist/assets/src/execution/bashTimeout.ts +78 -0
- package/dist/assets/src/execution/bindingPurge.ts +43 -0
- package/dist/assets/src/execution/residentBackupTransfer.ts +50 -0
- package/dist/assets/src/execution/residentCleanliness.ts +95 -0
- package/dist/assets/src/execution/residentCredentials.ts +81 -0
- package/dist/assets/src/execution/residentDepCache.ts +321 -0
- package/dist/assets/src/execution/residentDepsStore.ts +326 -0
- package/dist/assets/src/execution/residentDetach.ts +48 -0
- package/dist/assets/src/execution/residentDisk.ts +107 -0
- package/dist/assets/src/execution/residentDiskBudget.ts +448 -0
- package/dist/assets/src/execution/residentExecWrap.ts +100 -0
- package/dist/assets/src/execution/residentHead.ts +85 -0
- package/dist/assets/src/execution/residentReadonly.ts +72 -0
- package/dist/assets/src/execution/residentRefresh.ts +429 -0
- package/dist/assets/src/execution/residentRestoreExtract.ts +130 -0
- package/dist/assets/src/execution/residentState.ts +47 -0
- package/dist/assets/src/execution/residentStepReport.ts +98 -0
- package/dist/assets/src/execution/residentStepTrace.ts +97 -0
- package/dist/assets/src/execution/residentSteps.ts +99 -0
- package/dist/assets/src/execution/residentText.ts +83 -0
- package/dist/assets/src/execution/residentTrace.ts +119 -0
- package/dist/assets/src/execution/sandboxEnv.ts +42 -0
- package/dist/assets/src/execution/sandboxErrors.ts +159 -0
- package/dist/assets/src/execution/sandboxKeepalive.ts +118 -0
- package/dist/assets/src/execution/shellQuote.ts +8 -0
- package/dist/assets/src/mcp/registry.ts +242 -0
- package/dist/assets/src/providers/types.ts +152 -0
- package/dist/assets/web/dist/.vite/manifest.json +176 -0
- package/dist/assets/web/dist/assets/AppShell-Bk2gbvet.js +1 -0
- package/dist/assets/web/dist/assets/CostsPage-CTZcMYYx.js +1 -0
- package/dist/assets/web/dist/assets/NotFoundPage-C-BuaSm8.js +1 -0
- package/dist/assets/web/dist/assets/ResidentDetailPage-DvQ05AGa.js +1 -0
- package/dist/assets/web/dist/assets/ResidentsIndexPage-B3uxKUne.js +1 -0
- package/dist/assets/web/dist/assets/RunRoutePage-XVFj0XDc.css +1 -0
- package/dist/assets/web/dist/assets/RunRoutePage-ty94olNM.js +126 -0
- package/dist/assets/web/dist/assets/RunsIndexPage-CM-qxyQm.js +1 -0
- package/dist/assets/web/dist/assets/RunsTabs-C4krAL9o.js +1 -0
- package/dist/assets/web/dist/assets/ScheduledPage-C1psvLD4.js +1 -0
- package/dist/assets/web/dist/assets/StatusDot-DuoQnQeU.js +1 -0
- package/dist/assets/web/dist/assets/Tooltip-BfLPyxQy.js +1 -0
- package/dist/assets/web/dist/assets/favicon-DL1rdWJt.js +1 -0
- package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
- package/dist/assets/web/dist/assets/main-Bnbk_Rsg.js +28 -0
- package/dist/assets/web/dist/assets/main-BsBGUyMH.css +2 -0
- package/dist/assets/web/dist/assets/residentDiskBudget-BMBKlYRH.js +1 -0
- package/dist/assets/web/dist/assets/seed-BglCRKLA.js +6 -0
- package/dist/assets/web/dist/assets/wallClock-Ckv3sKoR.js +1 -0
- package/dist/cli.js +34494 -0
- package/package.json +43 -10
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// What commit a Worker script is running (docs/reference/specs/execution.md item 13).
|
|
2
|
+
//
|
|
3
|
+
// The bot has always known this: `npm run deploy` writes `build.json`, the
|
|
4
|
+
// image COPYs it, and `/healthz` serves `build: {commit, builtAt}` — which is
|
|
5
|
+
// how `deploy all`'s live gate tells "deployed" from "live". The three Worker
|
|
6
|
+
// SCRIPTS (resident, memory, sandbox) had no equivalent. The resident instead
|
|
7
|
+
// carried a hand-edited `const BUILD_MARKER = "perf53"` whose comment said
|
|
8
|
+
// "bump on every deploy-worthy change"; a marker bumped by hand goes unbumped
|
|
9
|
+
// the first busy week. That costs twice over: a deploy cannot be proven from
|
|
10
|
+
// outside (its receipt has to be assembled from `wrangler versions list` plus a
|
|
11
|
+
// container digest), and the test-override guard rail that expires an override
|
|
12
|
+
// when the build changes (resident-repos item 49(c), `ignored:"stale-build …"`)
|
|
13
|
+
// silently stops expiring anything.
|
|
14
|
+
//
|
|
15
|
+
// A file cannot carry the stamp into a Worker script the way it does into the
|
|
16
|
+
// bot's image: it would have to be either committed (so a deploy dirties the
|
|
17
|
+
// tree, which `deploy all` then refuses) or gitignored (so `tsc` and a fresh
|
|
18
|
+
// clone fail on the missing import). So the deploy SUBSTITUTES it —
|
|
19
|
+
// `wrangler deploy --define` (see `deploy/bin/build-stamp.mjs`) — and this
|
|
20
|
+
// module is the one place that reads it.
|
|
21
|
+
//
|
|
22
|
+
// `--define` and not `--var`: a CLI `--var` may replace the `vars` block a
|
|
23
|
+
// Worker's wrangler.jsonc declares, and the resident's `STATE_WORKER_URL` var
|
|
24
|
+
// is load-bearing (the watchdog records firings through it). A bundle-time
|
|
25
|
+
// substitution cannot touch bindings.
|
|
26
|
+
|
|
27
|
+
/** What a Worker reports as `build` on `/healthz`. Shaped like the bot's
|
|
28
|
+
* `BuildInfo` (`src/channels/health.ts`) so both read the same to an
|
|
29
|
+
* operator and to `src/deploy/liveGate.ts`. */
|
|
30
|
+
export interface BuildStamp {
|
|
31
|
+
commit: string;
|
|
32
|
+
builtAt?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The commit of a Worker nobody stamped — a bare `wrangler deploy`, `wrangler
|
|
36
|
+
* dev`, or a test process. Never silently absent: an operator must be able to
|
|
37
|
+
* tell "built without a stamp" from "built at this commit". */
|
|
38
|
+
export const UNKNOWN_COMMIT = "unknown";
|
|
39
|
+
|
|
40
|
+
/** The environment variable a deploy with no tree to read (the published package, src/deploy/run.ts) hands
|
|
41
|
+
* the stamp scripts the commit through — the same name as the `--define` identifier below, so the two scripts
|
|
42
|
+
* (`deploy/bin/build-stamp.mjs`, `deploy/cloudflare/write-build.mjs`) and the runner agree by construction.
|
|
43
|
+
* Spelled here, not imported from the scripts: the bot image carries `src/` and not `deploy/`. */
|
|
44
|
+
export const BUILD_COMMIT_ENV = "SWITCHBOARD_BUILD_COMMIT";
|
|
45
|
+
|
|
46
|
+
// The identifiers `deploy/bin/build-stamp.mjs` substitutes. They are declared,
|
|
47
|
+
// never defined: with no `--define` they do not exist at runtime at all, which
|
|
48
|
+
// is why every read below goes through `typeof` first (see injectedBuildStamp).
|
|
49
|
+
declare const SWITCHBOARD_BUILD_COMMIT: string;
|
|
50
|
+
declare const SWITCHBOARD_BUILT_AT: string;
|
|
51
|
+
|
|
52
|
+
/** Pure: the stamp for whatever the deploy injected. A commit that is missing,
|
|
53
|
+
* blank, or not a string becomes `unknown`, and `builtAt` is dropped rather
|
|
54
|
+
* than guessed — a made-up build time is worse than none. */
|
|
55
|
+
export function resolveBuildStamp(commit: unknown, builtAt: unknown): BuildStamp {
|
|
56
|
+
const trimmed = typeof commit === "string" ? commit.trim() : "";
|
|
57
|
+
const at = typeof builtAt === "string" ? builtAt.trim() : "";
|
|
58
|
+
return { commit: trimmed === "" ? UNKNOWN_COMMIT : trimmed, ...(at === "" ? {} : { builtAt: at }) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The identity a stored artifact compares itself against — the commit, plus
|
|
62
|
+
* the build's own timestamp when there is one.
|
|
63
|
+
*
|
|
64
|
+
* Not the commit alone: two builds of the same DIRTY tree carry the same
|
|
65
|
+
* `<sha>-dirty` commit, and a redeploy of the same clean commit is still a
|
|
66
|
+
* later build. The resident's test overrides (`gc.ts` `effectiveLimits`,
|
|
67
|
+
* resident-repos item 49(c)) are expired by "a different build wrote this",
|
|
68
|
+
* so a coarser identity leaves a forgotten admin cap alive across exactly the
|
|
69
|
+
* deploys that were meant to clear it. `builtAt` is injected once per deploy,
|
|
70
|
+
* so every isolate of one deployed version agrees — an override set through
|
|
71
|
+
* one isolate is still honored by its siblings.
|
|
72
|
+
*
|
|
73
|
+
* An UN-stamped bundle (`wrangler dev`, a bare `wrangler deploy`) therefore
|
|
74
|
+
* shares one id across all of its builds, and an override does not expire
|
|
75
|
+
* between them. That is deliberate, not an oversight: with nothing injected
|
|
76
|
+
* there is no value that differs per build yet is stable per deployment —
|
|
77
|
+
* anything generated at module load would differ per ISOLATE, so overrides
|
|
78
|
+
* would vanish between siblings of one deployment, which is the worse
|
|
79
|
+
* failure. Stamped deploys are the case the guard rail exists for. */
|
|
80
|
+
export function buildId(stamp: BuildStamp): string {
|
|
81
|
+
return stamp.builtAt === undefined ? stamp.commit : `${stamp.commit}@${stamp.builtAt}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The stamp this bundle was built with. Reading an undeclared identifier is a
|
|
85
|
+
* ReferenceError, and `typeof` is the one operator that tolerates one — so it
|
|
86
|
+
* gates both reads, and an un-stamped bundle answers `unknown` instead of
|
|
87
|
+
* throwing inside `/healthz`. */
|
|
88
|
+
export function injectedBuildStamp(): BuildStamp {
|
|
89
|
+
return resolveBuildStamp(
|
|
90
|
+
typeof SWITCHBOARD_BUILD_COMMIT === "string" ? SWITCHBOARD_BUILD_COMMIT : null,
|
|
91
|
+
typeof SWITCHBOARD_BUILT_AT === "string" ? SWITCHBOARD_BUILT_AT : null,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { COLD_START_ALLOWANCE_MS, DRAIN_DEADLINE_MS } from "../core/drain.js";
|
|
2
|
+
|
|
3
|
+
// "Deployed" is not "live". `wrangler deploy` uploads a Worker version and
|
|
4
|
+
// starts a container rollout, but the OLD bot container keeps serving while it
|
|
5
|
+
// drains in-flight runs (up to DRAIN_DEADLINE_MS) — a `deploy:all` that trusted
|
|
6
|
+
// the upload printed `bot … deployed`, exited 0, and the old container was
|
|
7
|
+
// still draining (docs/decisions/0015-deploy-order-deployed-is-not-live.md).
|
|
8
|
+
// This module is the pure half of the live gate
|
|
9
|
+
// `deploy:all` runs after the bot step: read `/healthz`, decide whether the NEW
|
|
10
|
+
// container — identified by the commit baked into its image (`build.commit`,
|
|
11
|
+
// src/channels/health.ts) — is the one answering, and say why not otherwise.
|
|
12
|
+
// No node:* imports; the CLI does the fetching and the clock.
|
|
13
|
+
|
|
14
|
+
/** What the gate needs from a `/healthz` body (src/channels/health.ts `HealthPayload`). */
|
|
15
|
+
export interface HealthzBody {
|
|
16
|
+
ok?: unknown;
|
|
17
|
+
inFlight?: unknown;
|
|
18
|
+
draining?: unknown;
|
|
19
|
+
drainStartedAt?: unknown;
|
|
20
|
+
build?: { commit?: unknown; builtAt?: unknown } | unknown;
|
|
21
|
+
/** ISO process start — `deploy restart`'s identity (the image, hence `build.commit`, is unchanged). */
|
|
22
|
+
startedAt?: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Parse a `/healthz` response body; undefined when it is not a JSON object
|
|
26
|
+
* (a container mid-restart answers nothing, a pre-item-8 Worker answers `ok`). */
|
|
27
|
+
export function parseHealthz(text: string): HealthzBody | undefined {
|
|
28
|
+
try {
|
|
29
|
+
const parsed: unknown = JSON.parse(text);
|
|
30
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
31
|
+
? (parsed as HealthzBody)
|
|
32
|
+
: undefined;
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** How long the gate waits for the new container: the drain deadline the old
|
|
39
|
+
* one may use in full, plus the same cold-start allowance the reconnect
|
|
40
|
+
* catch-up budgets (`COLD_START_ALLOWANCE_MS`, item 7) — one number for "how
|
|
41
|
+
* long until the replacement is up", so the gate cannot time out a container
|
|
42
|
+
* the catch-up still expects to arrive. */
|
|
43
|
+
export const LIVE_GATE_DEADLINE_MS = DRAIN_DEADLINE_MS + COLD_START_ALLOWANCE_MS;
|
|
44
|
+
/** `/healthz` poll interval while waiting to go live. */
|
|
45
|
+
export const LIVE_GATE_POLL_MS = 15_000;
|
|
46
|
+
|
|
47
|
+
/** One poll's verdict: `live` carrying the identity that proved it, or the
|
|
48
|
+
* reason an operator would want to read — `waiting` until the deadline,
|
|
49
|
+
* `timeout` after it (the CLI exits non-zero: never report success when not live). */
|
|
50
|
+
export type ReadyDecision<Identity> =
|
|
51
|
+
({ kind: "live" } & Identity) | { kind: "waiting"; reason: string } | { kind: "timeout"; reason: string };
|
|
52
|
+
|
|
53
|
+
export type LiveDecision = ReadyDecision<{ commit: string }>;
|
|
54
|
+
export type RestartDecision = ReadyDecision<{ startedAt: string }>;
|
|
55
|
+
|
|
56
|
+
function servedCommit(body: HealthzBody): string | undefined {
|
|
57
|
+
const b = body.build;
|
|
58
|
+
if (typeof b !== "object" || b === null) return undefined;
|
|
59
|
+
const c = (b as { commit?: unknown }).commit;
|
|
60
|
+
return typeof c === "string" && c !== "" ? c : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The part of a live decision every gate shares: a non-JSON body is never
|
|
64
|
+
* live; a JSON body is judged by `identify` — the identity that proves the NEW
|
|
65
|
+
* container is answering, or the reason it is not. A draining body is judged
|
|
66
|
+
* the same way (run-history item 39: a container that already serves the
|
|
67
|
+
* deployed identity IS live, whatever is rolling it next); when the identity
|
|
68
|
+
* is the old one, the reason names the drain — the more useful fact — unless
|
|
69
|
+
* `identify` already judged the same identity draining (`sameIdentity`: a
|
|
70
|
+
* same-commit rollout, decided by `startedAt`) and said why. Past
|
|
71
|
+
* `deadlineMs` the reason becomes a timeout. */
|
|
72
|
+
function decideReady<Identity>(
|
|
73
|
+
body: HealthzBody | undefined,
|
|
74
|
+
elapsedMs: number,
|
|
75
|
+
deadlineMs: number,
|
|
76
|
+
identify: (
|
|
77
|
+
body: HealthzBody,
|
|
78
|
+
) => { live: true; identity: Identity } | { live: false; reason: string; sameIdentity?: true },
|
|
79
|
+
): ReadyDecision<Identity> {
|
|
80
|
+
let reason: string;
|
|
81
|
+
if (!body) {
|
|
82
|
+
reason = "/healthz not answering with JSON (container restarting, or unreachable)";
|
|
83
|
+
} else {
|
|
84
|
+
const verdict = identify(body);
|
|
85
|
+
if (verdict.live) return { kind: "live", ...verdict.identity };
|
|
86
|
+
if (body.draining === true && !verdict.sameIdentity) {
|
|
87
|
+
const n = typeof body.inFlight === "number" ? body.inFlight : "?";
|
|
88
|
+
const since = typeof body.drainStartedAt === "string" ? ` since ${body.drainStartedAt}` : "";
|
|
89
|
+
reason = `old container still draining — ${n} run(s) in flight${since}`;
|
|
90
|
+
} else reason = verdict.reason;
|
|
91
|
+
}
|
|
92
|
+
return elapsedMs >= deadlineMs ? { kind: "timeout", reason } : { kind: "waiting", reason };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Two commit identities name the same commit when one is a prefix of the other
|
|
96
|
+
* (≥7 chars) — `git rev-parse HEAD` vs. a short form; a `-dirty` suffix never matches. */
|
|
97
|
+
export function sameCommit(a: string, b: string): boolean {
|
|
98
|
+
const x = a.trim();
|
|
99
|
+
const y = b.trim();
|
|
100
|
+
if (x.length < 7 || y.length < 7) return false;
|
|
101
|
+
if (x.endsWith("-dirty") || y.endsWith("-dirty")) return false;
|
|
102
|
+
return x.startsWith(y) || y.startsWith(x);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The decision for one poll. `live` only when the body is JSON and carries the
|
|
107
|
+
* expected commit — and, when that container is DRAINING, only if it is provably
|
|
108
|
+
* the new one: a rollout of the SAME commit (a Worker recovered with
|
|
109
|
+
* `--only bot`, a forced `all` with no code change) drains an old container
|
|
110
|
+
* that serves the deployed commit too, so the commit alone would call it live the
|
|
111
|
+
* moment SIGTERM landed. The runner reads `startedAt` before the upload
|
|
112
|
+
* (`opts.previousStartedAt`) and a draining same-commit container counts only
|
|
113
|
+
* with a later one; without a pre-upload reading, a draining same-commit
|
|
114
|
+
* container waits. Everything else is `waiting` with the reason an operator
|
|
115
|
+
* would want to read — until `elapsedMs` reaches `deadlineMs`, when the same
|
|
116
|
+
* reason becomes a `timeout` (the CLI exits non-zero: never report success when
|
|
117
|
+
* not live).
|
|
118
|
+
*/
|
|
119
|
+
export function decideLive(
|
|
120
|
+
body: HealthzBody | undefined,
|
|
121
|
+
expectedCommit: string,
|
|
122
|
+
elapsedMs: number,
|
|
123
|
+
deadlineMs: number = LIVE_GATE_DEADLINE_MS,
|
|
124
|
+
opts: { previousStartedAt?: string } = {},
|
|
125
|
+
): LiveDecision {
|
|
126
|
+
return decideReady(body, elapsedMs, deadlineMs, (b) => {
|
|
127
|
+
const commit = servedCommit(b);
|
|
128
|
+
if (!commit)
|
|
129
|
+
return {
|
|
130
|
+
live: false,
|
|
131
|
+
reason: "/healthz carries no build identity — a container that predates the live gate is answering",
|
|
132
|
+
};
|
|
133
|
+
if (commit === "unknown")
|
|
134
|
+
return { live: false, reason: 'serving a build with commit "unknown" (image built without build.json)' };
|
|
135
|
+
if (!sameCommit(commit, expectedCommit))
|
|
136
|
+
return {
|
|
137
|
+
live: false,
|
|
138
|
+
reason: `serving commit ${commit.slice(0, 7)}, expected ${expectedCommit.slice(0, 7)} (old container still up)`,
|
|
139
|
+
};
|
|
140
|
+
if (b.draining === true) {
|
|
141
|
+
const started = servedStartedAt(b);
|
|
142
|
+
const newer =
|
|
143
|
+
started !== undefined &&
|
|
144
|
+
opts.previousStartedAt !== undefined &&
|
|
145
|
+
Date.parse(started) > Date.parse(opts.previousStartedAt);
|
|
146
|
+
if (!newer)
|
|
147
|
+
return {
|
|
148
|
+
live: false,
|
|
149
|
+
sameIdentity: true,
|
|
150
|
+
reason: `the deployed commit answers but is draining${started ? ` (started ${started})` : ""} — a same-commit rollout replaces it; waiting for the new container`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return { live: true, identity: { commit } };
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A parseable ISO `startedAt` from a body, else undefined. */
|
|
158
|
+
export function servedStartedAt(body: HealthzBody): string | undefined {
|
|
159
|
+
const s = body.startedAt;
|
|
160
|
+
return typeof s === "string" && Number.isFinite(Date.parse(s)) ? s : undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The live decision after `deploy restart`: the image is unchanged, so the
|
|
165
|
+
* restarted container is recognised by a `startedAt` LATER than
|
|
166
|
+
* `previousStartedAt` (what `/healthz` said before the restart was requested).
|
|
167
|
+
* With no previous value (the old container predated `startedAt`), any
|
|
168
|
+
* non-draining container that reports one counts. Same waiting/timeout
|
|
169
|
+
* vocabulary as `decideLive`.
|
|
170
|
+
*/
|
|
171
|
+
export function decideRestarted(
|
|
172
|
+
body: HealthzBody | undefined,
|
|
173
|
+
previousStartedAt: string | undefined,
|
|
174
|
+
elapsedMs: number,
|
|
175
|
+
deadlineMs: number = LIVE_GATE_DEADLINE_MS,
|
|
176
|
+
): RestartDecision {
|
|
177
|
+
return decideReady(body, elapsedMs, deadlineMs, (b) => {
|
|
178
|
+
const startedAt = servedStartedAt(b);
|
|
179
|
+
if (!startedAt)
|
|
180
|
+
return {
|
|
181
|
+
live: false,
|
|
182
|
+
reason: "/healthz carries no startedAt — a container that predates `deploy restart` is answering",
|
|
183
|
+
};
|
|
184
|
+
if (previousStartedAt !== undefined && Date.parse(startedAt) <= Date.parse(previousStartedAt))
|
|
185
|
+
return { live: false, reason: `old container still answering (started ${startedAt})` };
|
|
186
|
+
return { live: true, identity: { startedAt } };
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The line printed on every preflight retry, so a long wait is never silent.
|
|
191
|
+
* `tag` names the command waiting (`deploy:all`, `deploy:restart`). */
|
|
192
|
+
export function heartbeatLine(
|
|
193
|
+
step: string,
|
|
194
|
+
body: HealthzBody | undefined,
|
|
195
|
+
elapsedMs: number,
|
|
196
|
+
waitMaxMs: number,
|
|
197
|
+
tag = "deploy:all",
|
|
198
|
+
): string {
|
|
199
|
+
const waited = `waited ${Math.floor(elapsedMs / 60_000)}m of ${Math.round(waitMaxMs / 60_000)}m`;
|
|
200
|
+
if (!body) return `[${tag}] ${step}: still waiting — /healthz not answering, ${waited}`;
|
|
201
|
+
const n = typeof body.inFlight === "number" ? body.inFlight : "?";
|
|
202
|
+
return `[${tag}] ${step}: still waiting — ${n} run(s) in flight (draining: ${body.draining === true ? "yes" : "no"}), ${waited}`;
|
|
203
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// The deployment profile: everything about WHERE an installation runs that the
|
|
2
|
+
// code must not know. Cloudflare account, the zone the Workers sit under, each
|
|
3
|
+
// Worker's script name and hostname, where the runtime config comes from, and
|
|
4
|
+
// where secrets come from. The product is installed, not forked: this file is
|
|
5
|
+
// the operator's, the code reads it, and nothing under src/ or deploy/ names
|
|
6
|
+
// an account or a hostname of its own.
|
|
7
|
+
//
|
|
8
|
+
// Two files: `deploy/profile.json` (an installation's own; today the
|
|
9
|
+
// repository's own production values, gitignored once they move to the
|
|
10
|
+
// infrastructure repo) and `deploy/profile.example.json` (checked in, the
|
|
11
|
+
// shape with placeholders). `deploy plan` falls back to the example so the
|
|
12
|
+
// plan can be read anywhere — CI on a pull request, a fresh clone — but the
|
|
13
|
+
// plan says so, and `deploy all` refuses a plan computed from the example: a
|
|
14
|
+
// placeholder account is not a place to deploy to.
|
|
15
|
+
//
|
|
16
|
+
// Pure: parsing, validation, and the URLs derived from the profile. Reading
|
|
17
|
+
// the file is the runner's job (src/deploy/run.ts).
|
|
18
|
+
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
|
|
21
|
+
/** The Workers an installation may run, in deploy order. The project's docs
|
|
22
|
+
* site (deploy/cloudflare-docs/) is not one of them: it is the project's
|
|
23
|
+
* website, deployed by the project's own CI from project.json's facts, never
|
|
24
|
+
* a copy an installation runs (src/deploy/wranglerTemplate.ts `siteView`). */
|
|
25
|
+
export const WORKER_KINDS = ["memory", "bot", "resident", "sandbox"] as const;
|
|
26
|
+
export type WorkerKind = (typeof WORKER_KINDS)[number];
|
|
27
|
+
|
|
28
|
+
export const PROFILE_PATH = "deploy/profile.json";
|
|
29
|
+
export const PROFILE_EXAMPLE_PATH = "deploy/profile.example.json";
|
|
30
|
+
/** Overrides the profile's location — a second installation's profile, a test fixture. */
|
|
31
|
+
export const PROFILE_ENV = "SWITCHBOARD_DEPLOY_PROFILE";
|
|
32
|
+
|
|
33
|
+
const hostname = z
|
|
34
|
+
.string()
|
|
35
|
+
.regex(
|
|
36
|
+
/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/,
|
|
37
|
+
"a bare DNS hostname, no scheme, no path",
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const endpoint = z.object({
|
|
41
|
+
/** The Cloudflare Worker script name (what `wrangler deployments list` shows). */
|
|
42
|
+
script: z.string().regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, "a Worker script name: lowercase, digits, hyphens"),
|
|
43
|
+
/** The hostname the Worker's custom domain route serves. */
|
|
44
|
+
hostname,
|
|
45
|
+
/** The zone that hostname lives in, when it is not the profile's `zone` — one
|
|
46
|
+
* Worker on a second domain the account also owns. Must be a zone in the
|
|
47
|
+
* same account. */
|
|
48
|
+
zone: hostname.optional(),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** `path` | `github://owner/repo/path@ref` | `op://Vault/Item/field` — parsed by src/deploy/configSource.ts. */
|
|
52
|
+
const source = z.string().min(1);
|
|
53
|
+
|
|
54
|
+
/** Where a Worker's container image comes from (src/deploy/images.ts): `build` —
|
|
55
|
+
* each Worker's `image` is its Dockerfile and wrangler builds it at deploy time
|
|
56
|
+
* (a checkout; this project's own production); `registry` — the release's
|
|
57
|
+
* published images, copied into the account registry by `deploy all` (or
|
|
58
|
+
* `deploy images` ahead of it) and referenced as
|
|
59
|
+
* `registry.cloudflare.com/<account>/<name>:<version>`. */
|
|
60
|
+
export const IMAGE_MODES = ["build", "registry"] as const;
|
|
61
|
+
export type ImageMode = (typeof IMAGE_MODES)[number];
|
|
62
|
+
|
|
63
|
+
export const profileSchema = z.object({
|
|
64
|
+
/** The Cloudflare account every Worker deploys to (32 hex characters). */
|
|
65
|
+
account: z.string().regex(/^[0-9a-f]{32}$/, "a Cloudflare account id: 32 hex characters"),
|
|
66
|
+
/** The zone the hostnames live under; every hostname must be in it unless its
|
|
67
|
+
* Worker names its own `zone`. */
|
|
68
|
+
zone: hostname,
|
|
69
|
+
/** The Workers this installation runs. The bot is the one every installation
|
|
70
|
+
* has; the state Worker (memory), the resident and the sandbox are optional —
|
|
71
|
+
* a profile without one has no step for it (`deploy plan` iterates what is
|
|
72
|
+
* here) and no URL derived for it. */
|
|
73
|
+
workers: z.object({
|
|
74
|
+
memory: endpoint.optional(),
|
|
75
|
+
bot: endpoint,
|
|
76
|
+
resident: endpoint.optional(),
|
|
77
|
+
sandbox: endpoint.optional(),
|
|
78
|
+
}),
|
|
79
|
+
/** Where the bot's runtime config comes from at deploy time; `deploy all`
|
|
80
|
+
* materializes it into the image's build context. */
|
|
81
|
+
configSource: source,
|
|
82
|
+
/** Where `secrets put` reads values from: a directory of `<NAME>` files, or
|
|
83
|
+
* `op://Vault/Item` with the secret's name as the field. Optional: the
|
|
84
|
+
* secrets tooling has its own default directory. */
|
|
85
|
+
secretsSource: source.optional(),
|
|
86
|
+
/** How the bot, resident and sandbox images reach wrangler (`IMAGE_MODES`).
|
|
87
|
+
* Absent means `build` — the checkout deploys what it builds. */
|
|
88
|
+
images: z.enum(IMAGE_MODES).default("build"),
|
|
89
|
+
/** The Cloudflare Access application in front of the bot's dashboards, when
|
|
90
|
+
* there is one: the team domain the JWT is issued by and the app's AUD. */
|
|
91
|
+
access: z.object({ teamDomain: hostname, aud: z.string().regex(/^[0-9a-f]{64}$/) }).optional(),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export type DeploymentProfile = z.infer<typeof profileSchema>;
|
|
95
|
+
|
|
96
|
+
/** Pure: a parsed, validated profile, or the problems that make it unusable —
|
|
97
|
+
* each naming the field, never the value. A hostname outside the zone is a
|
|
98
|
+
* problem: the custom-domain route would be for a zone the account does not own. */
|
|
99
|
+
export function parseProfile(
|
|
100
|
+
raw: unknown,
|
|
101
|
+
): { ok: true; profile: DeploymentProfile } | { ok: false; problems: string[] } {
|
|
102
|
+
const parsed = profileSchema.safeParse(raw);
|
|
103
|
+
if (!parsed.success) {
|
|
104
|
+
return { ok: false, problems: parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`) };
|
|
105
|
+
}
|
|
106
|
+
const p = parsed.data;
|
|
107
|
+
const problems: string[] = [];
|
|
108
|
+
for (const [kind, ep] of Object.entries(p.workers)) {
|
|
109
|
+
if (!ep) continue;
|
|
110
|
+
const zone = ep.zone ?? p.zone;
|
|
111
|
+
if (ep.hostname !== zone && !ep.hostname.endsWith(`.${zone}`))
|
|
112
|
+
problems.push(`workers.${kind}.hostname: not under zone ${zone}`);
|
|
113
|
+
}
|
|
114
|
+
const scripts = Object.values(p.workers)
|
|
115
|
+
.filter((e): e is z.infer<typeof endpoint> => !!e)
|
|
116
|
+
.map((e) => e.script);
|
|
117
|
+
if (new Set(scripts).size !== scripts.length) problems.push("workers: two Workers share a script name");
|
|
118
|
+
return problems.length > 0 ? { ok: false, problems } : { ok: true, profile: p };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Where the profile came from — `deploy all` refuses the example. */
|
|
122
|
+
export type ProfileOrigin = "profile" | "example";
|
|
123
|
+
|
|
124
|
+
export interface LoadedProfile {
|
|
125
|
+
profile: DeploymentProfile;
|
|
126
|
+
origin: ProfileOrigin;
|
|
127
|
+
/** The path that was read, repo-relative or as the env var gave it. */
|
|
128
|
+
path: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Pure: the URLs the tooling derives — never stored twice, never typed by hand.
|
|
132
|
+
* A Worker the profile does not have has no URL (`undefined`); the bot's are
|
|
133
|
+
* always there, the bot being the one required Worker. */
|
|
134
|
+
export function profileUrls(p: DeploymentProfile) {
|
|
135
|
+
const origin = (kind: WorkerKind): string | undefined => {
|
|
136
|
+
const worker = p.workers[kind];
|
|
137
|
+
return worker ? `https://${worker.hostname}` : undefined;
|
|
138
|
+
};
|
|
139
|
+
const bot = `https://${p.workers.bot.hostname}`;
|
|
140
|
+
return {
|
|
141
|
+
/** A Worker's origin — what its deploy preflight is pointed at; undefined when the profile lacks it. */
|
|
142
|
+
baseUrl: origin,
|
|
143
|
+
/** `GET /healthz` of a Worker; undefined when the profile lacks it. */
|
|
144
|
+
healthUrl: (kind: WorkerKind): string | undefined => {
|
|
145
|
+
const o = origin(kind);
|
|
146
|
+
return o === undefined ? undefined : `${o}/healthz`;
|
|
147
|
+
},
|
|
148
|
+
/** The bot's public origin — live-view links, the dashboards. */
|
|
149
|
+
publicBaseUrl: bot,
|
|
150
|
+
/** The state Worker other Workers record firings on and the bot reads its config from; undefined without one. */
|
|
151
|
+
stateWorkerUrl: origin("memory"),
|
|
152
|
+
/** The bot Worker's restart route (`deploy restart`). */
|
|
153
|
+
botAdminRestartUrl: `${bot}/admin/restart`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Pure: is this the example profile? The example's account is the one
|
|
158
|
+
* placeholder that can never be a real Cloudflare account. */
|
|
159
|
+
export const EXAMPLE_ACCOUNT = "00000000000000000000000000000000";
|
|
160
|
+
export function isExampleProfile(p: DeploymentProfile): boolean {
|
|
161
|
+
return p.account === EXAMPLE_ACCOUNT;
|
|
162
|
+
}
|