@wtfalch/foundry 0.17.1 → 0.19.0
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/dist/bootstrap/declare.d.ts +11 -2
- package/dist/bootstrap/declare.js +20 -11
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/migrations/0045_names_to_edge.sql +107 -0
- package/dist/org.d.ts +17 -17
- package/dist/org.js +48 -58
- package/dist/plan/agora.d.ts +25 -0
- package/dist/plan/agora.js +25 -0
- package/dist/plan/boule.d.ts +8 -0
- package/dist/plan/boule.js +8 -0
- package/dist/plan/diff.d.ts +3 -1
- package/dist/plan/diff.js +115 -52
- package/dist/plan/entry-edge-key.d.ts +79 -0
- package/dist/plan/{entry-names-key.js → entry-edge-key.js} +90 -66
- package/dist/plan/executor.d.ts +87 -42
- package/dist/plan/executor.js +277 -120
- package/dist/plan/index.d.ts +2 -2
- package/dist/plan/index.js +1 -1
- package/dist/plan/live.d.ts +3 -3
- package/dist/plan/login-host.d.ts +15 -11
- package/dist/plan/login-host.js +16 -16
- package/dist/plan/plan-apply.d.ts +6 -5
- package/dist/plan/plan-apply.js +40 -38
- package/dist/plan/platform-diff.d.ts +22 -22
- package/dist/plan/platform-diff.js +45 -45
- package/dist/plan/platform-org-ports.d.ts +1 -0
- package/dist/plan/platform-org-ports.js +3 -0
- package/dist/plan/platform-org.d.ts +8 -8
- package/dist/plan/platform-org.js +7 -7
- package/dist/plan/platform-providers.d.ts +17 -16
- package/dist/plan/platform-providers.js +136 -118
- package/dist/plan/providers-readers.d.ts +34 -11
- package/dist/plan/providers-readers.js +72 -15
- package/dist/plan/step-system.js +1 -1
- package/dist/plan/{edge-worker.d.ts → worker.d.ts} +53 -33
- package/dist/plan/{edge-worker.js → worker.js} +92 -54
- package/dist/platform.d.ts +3 -3
- package/dist/platform.js +1 -1
- package/dist/store/postgres.js +7 -7
- package/dist/store/schema.d.ts +6 -6
- package/dist/store/schema.js +4 -4
- package/package.json +1 -1
- package/dist/plan/entry-names-key.d.ts +0 -73
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ZitadelError } from '@wtfalch/providers/zitadel';
|
|
1
2
|
import { BOULE_MANAGED_ENV_KEYS } from './boule.js';
|
|
2
3
|
/**
|
|
3
4
|
* A {@link ScopeResolver} backed by `AppliedState.serviceIds` (`../state.ts`)
|
|
@@ -8,15 +9,17 @@ import { BOULE_MANAGED_ENV_KEYS } from './boule.js';
|
|
|
8
9
|
* resource it creates, keyed `system:kind:key` ({@link storeRecordedIds}
|
|
9
10
|
* below reads those back), and #20's `FoundryStepExecutor` (`executor.ts`)
|
|
10
11
|
* now also calls `StepExecutionContext.recordScopeId` for `coolify_project`,
|
|
11
|
-
* `zitadel_org` and `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
12
|
+
* `zitadel_org`, `zitadel_project` and (#510) `cloudflare_zone`, so a
|
|
13
|
+
* resolver built with one of those `serviceKey`s returns the real id once
|
|
14
|
+
* that step has run. Before #510, `cloudflareZone` recorded its result only
|
|
15
|
+
* under `cloudflare:zone:<domain>` (a resource id, read back by
|
|
16
|
+
* {@link storeRecordedIds}), never under the plain `cloudflare_zone` scope
|
|
17
|
+
* key — so a resolver built with that `serviceKey` always returned `null`,
|
|
18
|
+
* `ProvidersCloudflareReader.listResources` always returned `[]`, and
|
|
19
|
+
* `diff.ts`'s `planZone` re-proposed provisioning the zone on every apply
|
|
20
|
+
* even once it was live. A scope key that genuinely has nothing recorded yet
|
|
21
|
+
* still correctly resolves to `null`: a reader passed `null` returns `[]`,
|
|
22
|
+
* which `computePlanDetail` (`diff.ts`) already reads as "no live resource".
|
|
20
23
|
*/
|
|
21
24
|
export function storeScopeResolver(store, serviceKey) {
|
|
22
25
|
return async (orgId) => {
|
|
@@ -179,18 +182,34 @@ async function withEnvNamesConfig(client, resource, recorded) {
|
|
|
179
182
|
* the `agora` kind, like {@link withLogVolumeConfig}. Unlike that read, a
|
|
180
183
|
* failure here is not swallowed: a plan that cannot see the live config must
|
|
181
184
|
* fail, not report it as missing.
|
|
185
|
+
*
|
|
186
|
+
* #506: also the live app's own `ports_exposes`, as `config.ports_exposes`,
|
|
187
|
+
* so `planAgora` can plan the fix for an app Coolify never got that setting
|
|
188
|
+
* on even when its env already matches -- the exact shape of the Company
|
|
189
|
+
* Included incident this issue names, where re-planning without this read
|
|
190
|
+
* would see no drift at all and never run `coolifyAgora` again.
|
|
182
191
|
*/
|
|
183
192
|
async function withAgoraEnvConfig(client, resource, recorded) {
|
|
184
193
|
if (resource.kind !== 'application' || !recorded[resource.key]?.startsWith('agora:')) {
|
|
185
194
|
return resource;
|
|
186
195
|
}
|
|
187
|
-
const envs = await
|
|
196
|
+
const [envs, app] = await Promise.all([
|
|
197
|
+
client.listEnvs(resource.key),
|
|
198
|
+
client.getApplication(resource.key),
|
|
199
|
+
]);
|
|
188
200
|
const env = {};
|
|
189
201
|
for (const e of envs) {
|
|
190
202
|
if (!e.is_preview && e.key.startsWith('AGORA_'))
|
|
191
203
|
env[e.key] = e.value;
|
|
192
204
|
}
|
|
193
|
-
|
|
205
|
+
// `@wtfalch/providers`' `CoolifyApplication` type does not declare
|
|
206
|
+
// `ports_exposes` (as of 0.7.3), but `getApplication` returns the raw
|
|
207
|
+
// Coolify API response (`coolify.ts`'s own `getApplication`, a plain
|
|
208
|
+
// `request` passthrough), which does carry it -- the same gap that
|
|
209
|
+
// repo's own CLAUDE.md names for other fields ("verified against a live
|
|
210
|
+
// instance, not the bundled openapi.yaml").
|
|
211
|
+
const portsExposes = app.ports_exposes;
|
|
212
|
+
return { ...resource, config: { ...resource.config, env, ports_exposes: portsExposes } };
|
|
194
213
|
}
|
|
195
214
|
/**
|
|
196
215
|
* #161: the org's own recorded Boule app's live sign-in env vars, as
|
|
@@ -273,13 +292,17 @@ export class ProvidersCloudflareReader {
|
|
|
273
292
|
// A zone needs no mapping: its key is already the zone name, which is
|
|
274
293
|
// foundry's own zone key (`diff.ts`'s `planZone`), so it never matches a
|
|
275
294
|
// recorded provider id and always passes through unchanged.
|
|
276
|
-
return applyRecordedIds(toFoundryResources('cloudflare', resources)
|
|
295
|
+
return applyRecordedIds(toFoundryResources('cloudflare', resources)
|
|
296
|
+
// Keyed by the zone id, so a recorded zone id would map it onto the
|
|
297
|
+
// zone's own key and replace the zone's status; nothing plans from it.
|
|
298
|
+
.filter((resource) => resource.kind !== 'email_routing'), recorded, CLOUDFLARE_DROP_UNRECORDED);
|
|
277
299
|
}
|
|
278
300
|
}
|
|
279
301
|
/**
|
|
280
302
|
* Backed by `@wtfalch/providers/zitadel`'s `ZitadelClient.listResources`: one
|
|
281
303
|
* resource per project and per project grant in a ZITADEL org (v1 scope —
|
|
282
|
-
* see that method's own doc comment)
|
|
304
|
+
* see that method's own doc comment), plus (#510) a synthesized `issuer`
|
|
305
|
+
* resource once the org's own ZITADEL org is confirmed live.
|
|
283
306
|
*/
|
|
284
307
|
export class ProvidersZitadelReader {
|
|
285
308
|
client;
|
|
@@ -294,11 +317,45 @@ export class ProvidersZitadelReader {
|
|
|
294
317
|
const zitadelOrgId = await this.resolveScope(orgId);
|
|
295
318
|
if (!zitadelOrgId)
|
|
296
319
|
return [];
|
|
297
|
-
const [resources, recorded] = await Promise.all([
|
|
320
|
+
const [resources, recorded, issuer] = await Promise.all([
|
|
298
321
|
this.client.listResources(zitadelOrgId),
|
|
299
322
|
this.resolveRecordedIds(orgId),
|
|
323
|
+
this.liveIssuer(orgId, zitadelOrgId),
|
|
300
324
|
]);
|
|
301
|
-
|
|
325
|
+
const mapped = applyRecordedIds(toFoundryResources('zitadel', resources), recorded);
|
|
326
|
+
return issuer ? [...mapped, issuer] : mapped;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* #510: `client.listResources` reports only what is *inside* a ZITADEL org
|
|
330
|
+
* (its projects and grants), never the org record itself, so nothing ever
|
|
331
|
+
* matched `diff.ts`'s `planIssuer`'s own `zitadel:issuer:<orgId>` lookup —
|
|
332
|
+
* every plan re-proposed "provision the org's own ZITADEL org" after a
|
|
333
|
+
* clean apply, even once `zitadel_org` was recorded and live. `orgId` here
|
|
334
|
+
* is foundry's own id ({@link resourceKey}'s `key` for an issuer, `diff.ts`'s
|
|
335
|
+
* `planIssuer`), not `zitadelOrgId` — the resource foundry tracks is "this
|
|
336
|
+
* org's issuer", and ZITADEL's own org id is an implementation detail of
|
|
337
|
+
* where it lives, the same distinction {@link applyRecordedIds} draws for
|
|
338
|
+
* every other recorded resource.
|
|
339
|
+
*
|
|
340
|
+
* `resolveScope` only reads what was last recorded; it never confirms the
|
|
341
|
+
* org still exists in ZITADEL. A 404 here means the recorded id no longer
|
|
342
|
+
* resolves (the org was removed out of band) and reads as "not live" — the
|
|
343
|
+
* same `[]` answer a never-recorded scope already gives. Any other failure
|
|
344
|
+
* propagates, same as every other call in this reader. An org ZITADEL still
|
|
345
|
+
* returns but marks `ORG_STATE_REMOVED` is not live either.
|
|
346
|
+
*/
|
|
347
|
+
async liveIssuer(orgId, zitadelOrgId) {
|
|
348
|
+
try {
|
|
349
|
+
const found = await this.client.getOrg(zitadelOrgId);
|
|
350
|
+
if (found.state === 'ORG_STATE_REMOVED')
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
if (err instanceof ZitadelError && err.status === 404)
|
|
355
|
+
return null;
|
|
356
|
+
throw err;
|
|
357
|
+
}
|
|
358
|
+
return { system: 'zitadel', kind: 'issuer', key: orgId, config: {} };
|
|
302
359
|
}
|
|
303
360
|
}
|
|
304
361
|
/**
|
package/dist/plan/step-system.js
CHANGED
|
@@ -4,7 +4,7 @@ const PLATFORM_APP_SYSTEMS = {
|
|
|
4
4
|
'platform:mail': 'stalwart',
|
|
5
5
|
'platform:files': 'files',
|
|
6
6
|
'platform:ai': 'ai',
|
|
7
|
-
'platform:
|
|
7
|
+
'platform:edge': 'edge',
|
|
8
8
|
};
|
|
9
9
|
/** The system `action` touches. The one place that decides it, so no planner sets it by hand. */
|
|
10
10
|
export function stepSystemOf(action) {
|
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
import type { DeclaredOrg,
|
|
1
|
+
import type { DeclaredOrg, HostingEntry, WorkerConfig } from '../org.js';
|
|
2
2
|
import type { DraftStep } from './diff.js';
|
|
3
3
|
/**
|
|
4
4
|
* F8 (#318): one plain Cloudflare Worker per hosting entry that declares
|
|
5
|
-
* `
|
|
5
|
+
* `worker`, deployed from a built artifact that entry's own repo publishes
|
|
6
6
|
* (estate DECISIONS.md, 2026-10-04: foundry fetches and verifies it, never
|
|
7
7
|
* checks it out or bundles it). Unlike `planEntryAiKeys`/`planFilesKeys`,
|
|
8
8
|
* there is no renewal window -- a Worker script has no expiry -- so a step
|
|
9
9
|
* is planned again only while the entry's own declared config (`route`,
|
|
10
10
|
* `bindings` or, crucially, `artifactSha256`) differs from what was last
|
|
11
|
-
* applied (recorded as that config's JSON, `
|
|
11
|
+
* applied (recorded as that config's JSON, `workerAppliedScopeKey`):
|
|
12
12
|
* `artifactSha256` is itself the declared signal that the built bytes
|
|
13
13
|
* changed, so a rebuild under an unchanged URL still redeploys as soon as
|
|
14
14
|
* the entry declares the new digest. "Blocked, not failing" (CLAUDE.md): a
|
|
15
15
|
* host whose executor carries no `WorkersPort` reports every entry's step
|
|
16
16
|
* (apply or retire) as blocked, not planned to a step that would throw.
|
|
17
17
|
*/
|
|
18
|
-
export declare const
|
|
19
|
-
export declare const
|
|
18
|
+
export declare const entryWorkerAppliedScopeKey: (entryKey: string) => string;
|
|
19
|
+
export declare const entryWorkerRouteIdScopeKey: (entryKey: string) => string;
|
|
20
20
|
/** The route pattern the recorded route id above was created for -- read to find and delete the *old* route when the declared route pattern changes. */
|
|
21
|
-
export declare const
|
|
21
|
+
export declare const entryWorkerRoutePatternScopeKey: (entryKey: string) => string;
|
|
22
22
|
/**
|
|
23
23
|
* The script name retired, once a retire step completes. `runSteps` only
|
|
24
24
|
* ever writes a step's main recorded id from a *truthy* `resourceId`
|
|
@@ -27,25 +27,35 @@ export declare const entryEdgeRoutePatternScopeKey: (entryKey: string) => string
|
|
|
27
27
|
* and without this marker the "gone" scan below would plan the same retire
|
|
28
28
|
* forever. Compared against that still-live id, the same convergence
|
|
29
29
|
* `entryAiRetiredScopeKey`/`entryFilesRetiredScopeKey`/
|
|
30
|
-
* `
|
|
30
|
+
* `entryEdgeRetiredScopeKey` already give their own entry keys.
|
|
31
31
|
*/
|
|
32
|
-
export declare const
|
|
32
|
+
export declare const entryWorkerRetiredScopeKey: (entryKey: string) => string;
|
|
33
33
|
/** The script name being uploaded, recorded (and persisted) *before* the upload call -- so a crash mid-upload or a failed route create still leaves a trail to the script rather than orphaning it, and a retry reads the script as foundry's own instead of tripping the `scriptExists` overwrite guard. Cleared once the step records success. */
|
|
34
|
-
export declare const
|
|
35
|
-
/** The zone the recorded route id lives in, as {@link
|
|
36
|
-
export declare const
|
|
37
|
-
/** A route about to be created, as {@link
|
|
38
|
-
export declare const
|
|
39
|
-
|
|
34
|
+
export declare const entryWorkerUploadedScopeKey: (entryKey: string) => string;
|
|
35
|
+
/** The zone the recorded route id lives in, as {@link WorkerRouteZone} JSON -- so retire and destroy delete it on *that* zone even after `org.domain` changes or goes null. */
|
|
36
|
+
export declare const entryWorkerRouteZoneScopeKey: (entryKey: string) => string;
|
|
37
|
+
/** A route about to be created, as {@link WorkerRoutePending} JSON, recorded and persisted before `createRoute` -- so a crash or failed persist right after Cloudflare creates it still leaves retire and destroy something to find it by (`findRoute`). Cleared once the route id is recorded. */
|
|
38
|
+
export declare const entryWorkerRoutePendingScopeKey: (entryKey: string) => string;
|
|
39
|
+
/**
|
|
40
|
+
* The no-Worker routes kept beside an entry's wildcard Worker route, as
|
|
41
|
+
* {@link WorkerExclusions} JSON. Written after each create or delete, so a
|
|
42
|
+
* crash leaves every route that exists recorded.
|
|
43
|
+
*/
|
|
44
|
+
export declare const entryWorkerExclusionsScopeKey: (entryKey: string) => string;
|
|
45
|
+
export interface WorkerExclusions extends WorkerRouteZone {
|
|
46
|
+
/** Route pattern to Cloudflare route id. */
|
|
47
|
+
routes: Record<string, string>;
|
|
48
|
+
}
|
|
49
|
+
export interface WorkerRouteZone {
|
|
40
50
|
id: string;
|
|
41
51
|
apex: string;
|
|
42
52
|
}
|
|
43
|
-
export interface
|
|
53
|
+
export interface WorkerRoutePending extends WorkerRouteZone {
|
|
44
54
|
pattern: string;
|
|
45
55
|
script: string;
|
|
46
56
|
}
|
|
47
57
|
/** The per-entry scope keys that mark something deployed or half-deployed, or that a retire already handled it -- cleared together once org destroy has torn the Worker down (#318 re-review: the retired marker was left out, so it stayed recorded as a stale leftover after the org itself was gone). */
|
|
48
|
-
export declare const
|
|
58
|
+
export declare const workerTraceScopeKeys: (entryKey: string) => string[];
|
|
49
59
|
/**
|
|
50
60
|
* Every entry key of `orgId` with something foundry deployed still live,
|
|
51
61
|
* mapped to the script name recorded for it (`''` if only a route trace is
|
|
@@ -56,11 +66,11 @@ export declare const edgeWorkerTraceScopeKeys: (entryKey: string) => string[];
|
|
|
56
66
|
* the retired marker, since an apply clears that marker before it leaves any
|
|
57
67
|
* of these (#318 re-review).
|
|
58
68
|
*/
|
|
59
|
-
export declare function
|
|
60
|
-
/** The first 8 hex characters of org.id's own sha256 -- see {@link
|
|
61
|
-
export declare const
|
|
69
|
+
export declare function liveWorkers(orgId: string, recordedIds: Readonly<Record<string, string>>): Map<string, string>;
|
|
70
|
+
/** The first 8 hex characters of org.id's own sha256 -- see {@link workerName}'s doc comment. */
|
|
71
|
+
export declare const WORKER_ORG_HASH_LENGTH = 8;
|
|
62
72
|
/** Cloudflare's own limit on a Worker script name. */
|
|
63
|
-
export declare const
|
|
73
|
+
export declare const WORKER_NAME_MAX_LENGTH = 63;
|
|
64
74
|
/**
|
|
65
75
|
* `${8-hex-char hash of org.id}-${entry.key}-edge`, not `${org.slug}-...`
|
|
66
76
|
* (#318 re-review): a slug and an entry key both allow hyphens, so org
|
|
@@ -69,37 +79,47 @@ export declare const EDGE_WORKER_NAME_MAX_LENGTH = 63;
|
|
|
69
79
|
* caught the second of the two to apply, and two concurrent applies could
|
|
70
80
|
* both pass it. `org.id` is a UUID with no ambiguity of its own, but using
|
|
71
81
|
* it in full would make the fixed part of the name longer than most of the
|
|
72
|
-
* 63-character budget this port's `
|
|
73
|
-
* so only a short hash of it is used -- `
|
|
82
|
+
* 63-character budget this port's `WORKER_NAME_MAX_LENGTH` gives it,
|
|
83
|
+
* so only a short hash of it is used -- `validateEntryWorker` (`org.ts`)
|
|
74
84
|
* checks the resulting name never exceeds that limit.
|
|
75
85
|
*/
|
|
76
|
-
export declare const
|
|
86
|
+
export declare const workerName: (org: DeclaredOrg, entry: HostingEntry) => string;
|
|
77
87
|
/**
|
|
78
88
|
* Foundry's own fixed compatibility date for every Worker it deploys -- not
|
|
79
|
-
* a per-entry declared value, so it is not part of {@link
|
|
89
|
+
* a per-entry declared value, so it is not part of {@link WorkerWant}
|
|
80
90
|
* and never triggers a re-plan on its own; bumping it is a foundry release,
|
|
81
91
|
* like {@link RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS} is a fixed Cloudflare
|
|
82
92
|
* Free plan value rather than something an org declares.
|
|
83
93
|
*/
|
|
84
|
-
export declare const
|
|
85
|
-
export type
|
|
86
|
-
export interface
|
|
94
|
+
export declare const WORKER_COMPATIBILITY_DATE = "2026-09-12";
|
|
95
|
+
export type WorkerMode = 'apply' | 'retire';
|
|
96
|
+
export interface WorkerWant {
|
|
87
97
|
name: string;
|
|
88
98
|
artifactUrl: string;
|
|
89
99
|
artifactSha256: string;
|
|
90
100
|
route: string;
|
|
91
|
-
bindings:
|
|
101
|
+
bindings: WorkerConfig['bindings'];
|
|
102
|
+
/** No-Worker route patterns, sorted; set only for a wildcard route with hostnames to keep out. */
|
|
103
|
+
exclusions?: string[];
|
|
92
104
|
}
|
|
93
|
-
|
|
105
|
+
/**
|
|
106
|
+
* With a wildcard route (`*.<domain>/*`) the Worker would also capture every
|
|
107
|
+
* hostname foundry provisions on the domain, so each gets a no-Worker route
|
|
108
|
+
* `<hostname>/*` (a more specific pattern wins): every entry hostname plus the
|
|
109
|
+
* names `lockedHostnames` lists (Agora, Boule, the login host, platform apps).
|
|
110
|
+
* Never the wildcard itself.
|
|
111
|
+
*/
|
|
112
|
+
export declare function workerExclusions(org: DeclaredOrg, entry: HostingEntry, platformHostnames: readonly string[]): string[];
|
|
113
|
+
export declare function wantedWorker(org: DeclaredOrg, entry: HostingEntry, platformHostnames?: readonly string[]): WorkerWant;
|
|
94
114
|
type Blocked = Array<{
|
|
95
115
|
description: string;
|
|
96
116
|
key: string;
|
|
97
117
|
}>;
|
|
98
118
|
/**
|
|
99
|
-
* One step per entry whose `
|
|
119
|
+
* One step per entry whose `worker` config is new or changed, plus one retire
|
|
100
120
|
* step per entry whose Worker is recorded but no longer declared -- either
|
|
101
|
-
* because the entry dropped `
|
|
102
|
-
* same "gone" scan `planEntryAiKeys`/`planFilesKeys`/`
|
|
121
|
+
* because the entry dropped `worker` or because the entry itself is gone, the
|
|
122
|
+
* same "gone" scan `planEntryAiKeys`/`planFilesKeys`/`planEntryEdgeKeys`
|
|
103
123
|
* run over their own recorded keys, so removing a whole entry still cleans
|
|
104
124
|
* up its Worker. `workersAvailable` is the host's own
|
|
105
125
|
* `FoundryPlanApplyOptions.edgeWorkers` (mirrors `jobsAvailable`): without
|
|
@@ -107,7 +127,7 @@ type Blocked = Array<{
|
|
|
107
127
|
* than planned to fail at apply. The caller (`diff.ts`) gates the *apply*
|
|
108
128
|
* steps further, on an active zone, the same way it gates `planRateLimit`.
|
|
109
129
|
*/
|
|
110
|
-
export declare function
|
|
130
|
+
export declare function planWorkers(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, workersAvailable: boolean, platformHostnames?: readonly string[]): {
|
|
111
131
|
steps: DraftStep[];
|
|
112
132
|
blocked: Blocked;
|
|
113
133
|
};
|
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { entryHostnames } from './hostnames.js';
|
|
3
|
+
import { lockedHostnames } from './locked-hostnames.js';
|
|
2
4
|
import { appStepKey } from './platform-diff.js';
|
|
3
5
|
/**
|
|
4
6
|
* F8 (#318): one plain Cloudflare Worker per hosting entry that declares
|
|
5
|
-
* `
|
|
7
|
+
* `worker`, deployed from a built artifact that entry's own repo publishes
|
|
6
8
|
* (estate DECISIONS.md, 2026-10-04: foundry fetches and verifies it, never
|
|
7
9
|
* checks it out or bundles it). Unlike `planEntryAiKeys`/`planFilesKeys`,
|
|
8
10
|
* there is no renewal window -- a Worker script has no expiry -- so a step
|
|
9
11
|
* is planned again only while the entry's own declared config (`route`,
|
|
10
12
|
* `bindings` or, crucially, `artifactSha256`) differs from what was last
|
|
11
|
-
* applied (recorded as that config's JSON, `
|
|
13
|
+
* applied (recorded as that config's JSON, `workerAppliedScopeKey`):
|
|
12
14
|
* `artifactSha256` is itself the declared signal that the built bytes
|
|
13
15
|
* changed, so a rebuild under an unchanged URL still redeploys as soon as
|
|
14
16
|
* the entry declares the new digest. "Blocked, not failing" (CLAUDE.md): a
|
|
15
17
|
* host whose executor carries no `WorkersPort` reports every entry's step
|
|
16
18
|
* (apply or retire) as blocked, not planned to a step that would throw.
|
|
17
19
|
*/
|
|
18
|
-
|
|
19
|
-
export const
|
|
20
|
+
// Recorded scope keys keep their `edge_worker_` prefix: renaming them would orphan live Workers.
|
|
21
|
+
export const entryWorkerAppliedScopeKey = (entryKey) => `edge_worker_applied_${entryKey}`;
|
|
22
|
+
export const entryWorkerRouteIdScopeKey = (entryKey) => `edge_worker_route_id_${entryKey}`;
|
|
20
23
|
/** The route pattern the recorded route id above was created for -- read to find and delete the *old* route when the declared route pattern changes. */
|
|
21
|
-
export const
|
|
24
|
+
export const entryWorkerRoutePatternScopeKey = (entryKey) => `edge_worker_route_pattern_${entryKey}`;
|
|
22
25
|
/**
|
|
23
26
|
* The script name retired, once a retire step completes. `runSteps` only
|
|
24
27
|
* ever writes a step's main recorded id from a *truthy* `resourceId`
|
|
@@ -27,23 +30,30 @@ export const entryEdgeRoutePatternScopeKey = (entryKey) => `edge_worker_route_pa
|
|
|
27
30
|
* and without this marker the "gone" scan below would plan the same retire
|
|
28
31
|
* forever. Compared against that still-live id, the same convergence
|
|
29
32
|
* `entryAiRetiredScopeKey`/`entryFilesRetiredScopeKey`/
|
|
30
|
-
* `
|
|
33
|
+
* `entryEdgeRetiredScopeKey` already give their own entry keys.
|
|
31
34
|
*/
|
|
32
|
-
export const
|
|
35
|
+
export const entryWorkerRetiredScopeKey = (entryKey) => `edge_worker_retired_${entryKey}`;
|
|
33
36
|
/** The script name being uploaded, recorded (and persisted) *before* the upload call -- so a crash mid-upload or a failed route create still leaves a trail to the script rather than orphaning it, and a retry reads the script as foundry's own instead of tripping the `scriptExists` overwrite guard. Cleared once the step records success. */
|
|
34
|
-
export const
|
|
35
|
-
/** The zone the recorded route id lives in, as {@link
|
|
36
|
-
export const
|
|
37
|
-
/** A route about to be created, as {@link
|
|
38
|
-
export const
|
|
37
|
+
export const entryWorkerUploadedScopeKey = (entryKey) => `edge_worker_uploaded_${entryKey}`;
|
|
38
|
+
/** The zone the recorded route id lives in, as {@link WorkerRouteZone} JSON -- so retire and destroy delete it on *that* zone even after `org.domain` changes or goes null. */
|
|
39
|
+
export const entryWorkerRouteZoneScopeKey = (entryKey) => `edge_worker_route_zone_${entryKey}`;
|
|
40
|
+
/** A route about to be created, as {@link WorkerRoutePending} JSON, recorded and persisted before `createRoute` -- so a crash or failed persist right after Cloudflare creates it still leaves retire and destroy something to find it by (`findRoute`). Cleared once the route id is recorded. */
|
|
41
|
+
export const entryWorkerRoutePendingScopeKey = (entryKey) => `edge_worker_route_pending_${entryKey}`;
|
|
42
|
+
/**
|
|
43
|
+
* The no-Worker routes kept beside an entry's wildcard Worker route, as
|
|
44
|
+
* {@link WorkerExclusions} JSON. Written after each create or delete, so a
|
|
45
|
+
* crash leaves every route that exists recorded.
|
|
46
|
+
*/
|
|
47
|
+
export const entryWorkerExclusionsScopeKey = (entryKey) => `edge_worker_exclusions_${entryKey}`;
|
|
39
48
|
/** The per-entry scope keys that mark something deployed or half-deployed, or that a retire already handled it -- cleared together once org destroy has torn the Worker down (#318 re-review: the retired marker was left out, so it stayed recorded as a stale leftover after the org itself was gone). */
|
|
40
|
-
export const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
49
|
+
export const workerTraceScopeKeys = (entryKey) => [
|
|
50
|
+
entryWorkerUploadedScopeKey(entryKey),
|
|
51
|
+
entryWorkerRouteIdScopeKey(entryKey),
|
|
52
|
+
entryWorkerRouteZoneScopeKey(entryKey),
|
|
53
|
+
entryWorkerRoutePatternScopeKey(entryKey),
|
|
54
|
+
entryWorkerRoutePendingScopeKey(entryKey),
|
|
55
|
+
entryWorkerRetiredScopeKey(entryKey),
|
|
56
|
+
entryWorkerExclusionsScopeKey(entryKey),
|
|
47
57
|
];
|
|
48
58
|
/**
|
|
49
59
|
* Every entry key of `orgId` with something foundry deployed still live,
|
|
@@ -55,13 +65,14 @@ export const edgeWorkerTraceScopeKeys = (entryKey) => [
|
|
|
55
65
|
* the retired marker, since an apply clears that marker before it leaves any
|
|
56
66
|
* of these (#318 re-review).
|
|
57
67
|
*/
|
|
58
|
-
export function
|
|
68
|
+
export function liveWorkers(orgId, recordedIds) {
|
|
59
69
|
const mainPrefix = `cloudflare:worker:${orgId}:`;
|
|
60
70
|
const tracePrefixes = [
|
|
61
71
|
mainPrefix,
|
|
62
72
|
'edge_worker_uploaded_',
|
|
63
73
|
'edge_worker_route_id_',
|
|
64
74
|
'edge_worker_route_pending_',
|
|
75
|
+
'edge_worker_exclusions_',
|
|
65
76
|
];
|
|
66
77
|
const entryKeys = new Set();
|
|
67
78
|
for (const [name, id] of Object.entries(recordedIds)) {
|
|
@@ -74,21 +85,22 @@ export function liveEdgeWorkers(orgId, recordedIds) {
|
|
|
74
85
|
const live = new Map();
|
|
75
86
|
for (const entryKey of entryKeys) {
|
|
76
87
|
const mainId = recordedIds[`${mainPrefix}${entryKey}`] ?? '';
|
|
77
|
-
const uploaded = recordedIds[
|
|
88
|
+
const uploaded = recordedIds[entryWorkerUploadedScopeKey(entryKey)] ?? '';
|
|
78
89
|
const partial = uploaded ||
|
|
79
|
-
recordedIds[
|
|
80
|
-
recordedIds[
|
|
81
|
-
|
|
90
|
+
recordedIds[entryWorkerRouteIdScopeKey(entryKey)] ||
|
|
91
|
+
recordedIds[entryWorkerRoutePendingScopeKey(entryKey)] ||
|
|
92
|
+
recordedIds[entryWorkerExclusionsScopeKey(entryKey)];
|
|
93
|
+
const retired = mainId !== '' && recordedIds[entryWorkerRetiredScopeKey(entryKey)] === mainId;
|
|
82
94
|
if (!partial && (!mainId || retired))
|
|
83
95
|
continue;
|
|
84
96
|
live.set(entryKey, mainId || uploaded);
|
|
85
97
|
}
|
|
86
98
|
return live;
|
|
87
99
|
}
|
|
88
|
-
/** The first 8 hex characters of org.id's own sha256 -- see {@link
|
|
89
|
-
export const
|
|
100
|
+
/** The first 8 hex characters of org.id's own sha256 -- see {@link workerName}'s doc comment. */
|
|
101
|
+
export const WORKER_ORG_HASH_LENGTH = 8;
|
|
90
102
|
/** Cloudflare's own limit on a Worker script name. */
|
|
91
|
-
export const
|
|
103
|
+
export const WORKER_NAME_MAX_LENGTH = 63;
|
|
92
104
|
/**
|
|
93
105
|
* `${8-hex-char hash of org.id}-${entry.key}-edge`, not `${org.slug}-...`
|
|
94
106
|
* (#318 re-review): a slug and an entry key both allow hyphens, so org
|
|
@@ -97,34 +109,60 @@ export const EDGE_WORKER_NAME_MAX_LENGTH = 63;
|
|
|
97
109
|
* caught the second of the two to apply, and two concurrent applies could
|
|
98
110
|
* both pass it. `org.id` is a UUID with no ambiguity of its own, but using
|
|
99
111
|
* it in full would make the fixed part of the name longer than most of the
|
|
100
|
-
* 63-character budget this port's `
|
|
101
|
-
* so only a short hash of it is used -- `
|
|
112
|
+
* 63-character budget this port's `WORKER_NAME_MAX_LENGTH` gives it,
|
|
113
|
+
* so only a short hash of it is used -- `validateEntryWorker` (`org.ts`)
|
|
102
114
|
* checks the resulting name never exceeds that limit.
|
|
103
115
|
*/
|
|
104
|
-
export const
|
|
116
|
+
export const workerName = (org, entry) =>
|
|
117
|
+
// The `-edge` suffix stays after the entry field rename to `worker`, so live Workers keep their names and are not orphaned.
|
|
118
|
+
`${createHash('sha256').update(org.id).digest('hex').slice(0, WORKER_ORG_HASH_LENGTH)}-${entry.key}-edge`;
|
|
105
119
|
/**
|
|
106
120
|
* Foundry's own fixed compatibility date for every Worker it deploys -- not
|
|
107
|
-
* a per-entry declared value, so it is not part of {@link
|
|
121
|
+
* a per-entry declared value, so it is not part of {@link WorkerWant}
|
|
108
122
|
* and never triggers a re-plan on its own; bumping it is a foundry release,
|
|
109
123
|
* like {@link RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS} is a fixed Cloudflare
|
|
110
124
|
* Free plan value rather than something an org declares.
|
|
111
125
|
*/
|
|
112
|
-
export const
|
|
113
|
-
|
|
114
|
-
|
|
126
|
+
export const WORKER_COMPATIBILITY_DATE = '2026-09-12';
|
|
127
|
+
/**
|
|
128
|
+
* With a wildcard route (`*.<domain>/*`) the Worker would also capture every
|
|
129
|
+
* hostname foundry provisions on the domain, so each gets a no-Worker route
|
|
130
|
+
* `<hostname>/*` (a more specific pattern wins): every entry hostname plus the
|
|
131
|
+
* names `lockedHostnames` lists (Agora, Boule, the login host, platform apps).
|
|
132
|
+
* Never the wildcard itself.
|
|
133
|
+
*/
|
|
134
|
+
export function workerExclusions(org, entry, platformHostnames) {
|
|
135
|
+
const route = entry.worker?.route;
|
|
136
|
+
const { domain } = org;
|
|
137
|
+
if (!route || !domain || !route.startsWith('*.'))
|
|
138
|
+
return [];
|
|
139
|
+
const names = new Set(lockedHostnames(org, platformHostnames));
|
|
140
|
+
for (const e of org.hosting.entries)
|
|
141
|
+
for (const h of entryHostnames(org, e))
|
|
142
|
+
names.add(h);
|
|
143
|
+
return [...names]
|
|
144
|
+
.filter((h) => h === domain || h.endsWith(`.${domain}`))
|
|
145
|
+
.map((h) => `${h}/*`)
|
|
146
|
+
.filter((pattern) => pattern !== route)
|
|
147
|
+
.sort();
|
|
148
|
+
}
|
|
149
|
+
export function wantedWorker(org, entry, platformHostnames = []) {
|
|
150
|
+
const worker = entry.worker;
|
|
151
|
+
const exclusions = workerExclusions(org, entry, platformHostnames);
|
|
115
152
|
return {
|
|
116
|
-
name:
|
|
117
|
-
artifactUrl:
|
|
118
|
-
artifactSha256:
|
|
119
|
-
route:
|
|
120
|
-
bindings:
|
|
153
|
+
name: workerName(org, entry),
|
|
154
|
+
artifactUrl: worker.artifactUrl,
|
|
155
|
+
artifactSha256: worker.artifactSha256,
|
|
156
|
+
route: worker.route,
|
|
157
|
+
bindings: worker.bindings,
|
|
158
|
+
exclusions: exclusions.length > 0 ? exclusions : undefined,
|
|
121
159
|
};
|
|
122
160
|
}
|
|
123
161
|
/**
|
|
124
|
-
* One step per entry whose `
|
|
162
|
+
* One step per entry whose `worker` config is new or changed, plus one retire
|
|
125
163
|
* step per entry whose Worker is recorded but no longer declared -- either
|
|
126
|
-
* because the entry dropped `
|
|
127
|
-
* same "gone" scan `planEntryAiKeys`/`planFilesKeys`/`
|
|
164
|
+
* because the entry dropped `worker` or because the entry itself is gone, the
|
|
165
|
+
* same "gone" scan `planEntryAiKeys`/`planFilesKeys`/`planEntryEdgeKeys`
|
|
128
166
|
* run over their own recorded keys, so removing a whole entry still cleans
|
|
129
167
|
* up its Worker. `workersAvailable` is the host's own
|
|
130
168
|
* `FoundryPlanApplyOptions.edgeWorkers` (mirrors `jobsAvailable`): without
|
|
@@ -132,24 +170,24 @@ export function wantedEdgeWorker(org, entry) {
|
|
|
132
170
|
* than planned to fail at apply. The caller (`diff.ts`) gates the *apply*
|
|
133
171
|
* steps further, on an active zone, the same way it gates `planRateLimit`.
|
|
134
172
|
*/
|
|
135
|
-
export function
|
|
173
|
+
export function planWorkers(org, recordedIds, workersAvailable, platformHostnames = []) {
|
|
136
174
|
const steps = [];
|
|
137
175
|
const blocked = [];
|
|
138
176
|
// Hosting off declares no Worker at all: the gone-scan below still runs,
|
|
139
177
|
// so a Worker deployed while hosting was on is retired rather than left
|
|
140
178
|
// live forever (#318 re-review). Only planning a *new* deploy is skipped.
|
|
141
|
-
const declared = new Set(org.hosting.enabled ? org.hosting.entries.filter((e) => e.
|
|
179
|
+
const declared = new Set(org.hosting.enabled ? org.hosting.entries.filter((e) => e.worker).map((e) => e.key) : []);
|
|
142
180
|
const mainPrefix = `cloudflare:worker:${org.id}:`;
|
|
143
|
-
// Every entryKey with a live, undeclared trace (`
|
|
181
|
+
// Every entryKey with a live, undeclared trace (`liveWorkers`): the
|
|
144
182
|
// main id or any partial-deploy marker, so a deploy that failed after
|
|
145
183
|
// upload or mid-route-create is still found to retire (#318 review).
|
|
146
|
-
for (const entryKey of
|
|
184
|
+
for (const entryKey of liveWorkers(org.id, recordedIds).keys()) {
|
|
147
185
|
if (declared.has(entryKey))
|
|
148
186
|
continue;
|
|
149
187
|
const stepKey = `${org.id}:${entryKey}`;
|
|
150
188
|
if (!workersAvailable) {
|
|
151
189
|
blocked.push({
|
|
152
|
-
description: `${org.slug}'s ${entryKey}
|
|
190
|
+
description: `${org.slug}'s ${entryKey} Worker is blocked, not planned: the host supplies no WorkersPort`,
|
|
153
191
|
key: `${mainPrefix}${entryKey}`,
|
|
154
192
|
});
|
|
155
193
|
continue;
|
|
@@ -157,7 +195,7 @@ export function planEdgeWorkers(org, recordedIds, workersAvailable) {
|
|
|
157
195
|
steps.push({
|
|
158
196
|
action: { system: 'cloudflare', kind: 'worker', key: stepKey, desired: { mode: 'retire' } },
|
|
159
197
|
step: {
|
|
160
|
-
description: `delete ${org.slug}'s ${entryKey}
|
|
198
|
+
description: `delete ${org.slug}'s ${entryKey} Worker and its route: worker is no longer declared`,
|
|
161
199
|
destroys: true,
|
|
162
200
|
migrates: false,
|
|
163
201
|
},
|
|
@@ -166,11 +204,11 @@ export function planEdgeWorkers(org, recordedIds, workersAvailable) {
|
|
|
166
204
|
if (!org.hosting.enabled)
|
|
167
205
|
return { steps, blocked };
|
|
168
206
|
for (const entry of org.hosting.entries) {
|
|
169
|
-
if (!entry.
|
|
207
|
+
if (!entry.worker)
|
|
170
208
|
continue;
|
|
171
209
|
const stepKey = appStepKey(org, entry.key);
|
|
172
210
|
const key = `cloudflare:worker:${stepKey}`;
|
|
173
|
-
const at = `${org.slug}'s ${entry.key}
|
|
211
|
+
const at = `${org.slug}'s ${entry.key} Worker`;
|
|
174
212
|
if (!workersAvailable) {
|
|
175
213
|
blocked.push({
|
|
176
214
|
description: `${at} is blocked, not planned: the host supplies no WorkersPort`,
|
|
@@ -178,9 +216,9 @@ export function planEdgeWorkers(org, recordedIds, workersAvailable) {
|
|
|
178
216
|
});
|
|
179
217
|
continue;
|
|
180
218
|
}
|
|
181
|
-
const want =
|
|
219
|
+
const want = wantedWorker(org, entry, platformHostnames);
|
|
182
220
|
const wantJson = JSON.stringify(want);
|
|
183
|
-
const applied = recordedIds[
|
|
221
|
+
const applied = recordedIds[entryWorkerAppliedScopeKey(entry.key)];
|
|
184
222
|
if (applied === wantJson)
|
|
185
223
|
continue; // Rerun: nothing changed, down to the built artifact's own digest.
|
|
186
224
|
steps.push({
|
|
@@ -188,10 +226,10 @@ export function planEdgeWorkers(org, recordedIds, workersAvailable) {
|
|
|
188
226
|
system: 'cloudflare',
|
|
189
227
|
kind: 'worker',
|
|
190
228
|
key: stepKey,
|
|
191
|
-
desired: { mode: 'apply', compatibilityDate:
|
|
229
|
+
desired: { mode: 'apply', compatibilityDate: WORKER_COMPATIBILITY_DATE, ...want },
|
|
192
230
|
},
|
|
193
231
|
step: {
|
|
194
|
-
description: `${applied ? 'update' : 'deploy'} ${at} on ${entry.
|
|
232
|
+
description: `${applied ? 'update' : 'deploy'} ${at} on ${entry.worker.route}`,
|
|
195
233
|
destroys: false,
|
|
196
234
|
migrates: false,
|
|
197
235
|
},
|
package/dist/platform.d.ts
CHANGED
|
@@ -26,11 +26,11 @@ export interface PlatformMailService extends PlatformService {
|
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
28
28
|
* The shared services a declared platform names, decided in #165, plus
|
|
29
|
-
* `
|
|
29
|
+
* `edge` (#317, F7): the service that mints an entry's own edge key and
|
|
30
30
|
* holds the Cloudflare token `<username>.<org domain>` claims use, so no
|
|
31
31
|
* hosting entry ever holds that credential itself.
|
|
32
32
|
*/
|
|
33
|
-
export type PlatformServiceKey = 'auth' | 'mail' | 'sfu' | 'files' | 'ai' | 'jobs' | 'integrations' | 'archon' | '
|
|
33
|
+
export type PlatformServiceKey = 'auth' | 'mail' | 'sfu' | 'files' | 'ai' | 'jobs' | 'integrations' | 'archon' | 'edge';
|
|
34
34
|
export declare const PLATFORM_SERVICE_KEYS: readonly PlatformServiceKey[];
|
|
35
35
|
/**
|
|
36
36
|
* The one shared platform every org's services run on -- decided in #165 as
|
|
@@ -67,7 +67,7 @@ export interface DeclaredPlatform {
|
|
|
67
67
|
integrations: PlatformService;
|
|
68
68
|
archon: PlatformService;
|
|
69
69
|
/** #317 (F7). No deploy step reads this service's own `gitRef` yet -- `N1` is deployed by hand until it exists. */
|
|
70
|
-
|
|
70
|
+
edge: PlatformService;
|
|
71
71
|
}
|
|
72
72
|
/** `<service>.<apex>` -- the default `hostname` for a platform service, before an operator overrides it. */
|
|
73
73
|
export declare function defaultPlatformHostname(service: PlatformServiceKey, apex: string): string;
|
package/dist/platform.js
CHANGED
|
@@ -9,7 +9,7 @@ export const PLATFORM_SERVICE_KEYS = [
|
|
|
9
9
|
'jobs',
|
|
10
10
|
'integrations',
|
|
11
11
|
'archon',
|
|
12
|
-
'
|
|
12
|
+
'edge',
|
|
13
13
|
];
|
|
14
14
|
/** `<service>.<apex>` -- the default `hostname` for a platform service, before an operator overrides it. */
|
|
15
15
|
export function defaultPlatformHostname(service, apex) {
|