@wtfalch/foundry 0.17.0 → 0.18.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.js +6 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/migrations/0043_platform_names.sql +25 -0
- package/dist/migrations/0044_platform_mail_lettermint_project.sql +11 -0
- package/dist/migrations/0045_names_to_edge.sql +107 -0
- package/dist/org.d.ts +74 -3
- package/dist/org.js +133 -0
- package/dist/plan/chat-matrix.d.ts +0 -1
- package/dist/plan/chat-matrix.js +4 -1
- package/dist/plan/diff.d.ts +18 -0
- package/dist/plan/diff.js +239 -26
- package/dist/plan/entry-edge-key.d.ts +79 -0
- package/dist/plan/entry-edge-key.js +260 -0
- package/dist/plan/executor.d.ts +246 -1
- package/dist/plan/executor.js +684 -32
- package/dist/plan/index.d.ts +2 -2
- package/dist/plan/index.js +1 -1
- package/dist/plan/live.d.ts +4 -2
- package/dist/plan/login-host.d.ts +12 -1
- package/dist/plan/login-host.js +20 -2
- package/dist/plan/plan-apply.d.ts +15 -0
- package/dist/plan/plan-apply.js +102 -9
- package/dist/plan/platform-diff.d.ts +92 -1
- package/dist/plan/platform-diff.js +166 -1
- package/dist/plan/platform-org.d.ts +29 -1
- package/dist/plan/platform-org.js +17 -2
- package/dist/plan/platform-providers.d.ts +45 -1
- package/dist/plan/platform-providers.js +514 -7
- package/dist/plan/step-system.js +1 -0
- package/dist/plan/worker.d.ts +134 -0
- package/dist/plan/worker.js +239 -0
- package/dist/platform.d.ts +22 -3
- package/dist/platform.js +6 -0
- package/dist/store/postgres.js +15 -1
- package/dist/store/schema.d.ts +70 -2
- package/dist/store/schema.js +6 -0
- package/package.json +1 -1
package/dist/plan/step-system.js
CHANGED
|
@@ -4,6 +4,7 @@ const PLATFORM_APP_SYSTEMS = {
|
|
|
4
4
|
'platform:mail': 'stalwart',
|
|
5
5
|
'platform:files': 'files',
|
|
6
6
|
'platform:ai': 'ai',
|
|
7
|
+
'platform:edge': 'edge',
|
|
7
8
|
};
|
|
8
9
|
/** The system `action` touches. The one place that decides it, so no planner sets it by hand. */
|
|
9
10
|
export function stepSystemOf(action) {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { DeclaredOrg, HostingEntry, WorkerConfig } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
/**
|
|
4
|
+
* F8 (#318): one plain Cloudflare Worker per hosting entry that declares
|
|
5
|
+
* `worker`, deployed from a built artifact that entry's own repo publishes
|
|
6
|
+
* (estate DECISIONS.md, 2026-10-04: foundry fetches and verifies it, never
|
|
7
|
+
* checks it out or bundles it). Unlike `planEntryAiKeys`/`planFilesKeys`,
|
|
8
|
+
* there is no renewal window -- a Worker script has no expiry -- so a step
|
|
9
|
+
* is planned again only while the entry's own declared config (`route`,
|
|
10
|
+
* `bindings` or, crucially, `artifactSha256`) differs from what was last
|
|
11
|
+
* applied (recorded as that config's JSON, `workerAppliedScopeKey`):
|
|
12
|
+
* `artifactSha256` is itself the declared signal that the built bytes
|
|
13
|
+
* changed, so a rebuild under an unchanged URL still redeploys as soon as
|
|
14
|
+
* the entry declares the new digest. "Blocked, not failing" (CLAUDE.md): a
|
|
15
|
+
* host whose executor carries no `WorkersPort` reports every entry's step
|
|
16
|
+
* (apply or retire) as blocked, not planned to a step that would throw.
|
|
17
|
+
*/
|
|
18
|
+
export declare const entryWorkerAppliedScopeKey: (entryKey: string) => string;
|
|
19
|
+
export declare const entryWorkerRouteIdScopeKey: (entryKey: string) => string;
|
|
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 entryWorkerRoutePatternScopeKey: (entryKey: string) => string;
|
|
22
|
+
/**
|
|
23
|
+
* The script name retired, once a retire step completes. `runSteps` only
|
|
24
|
+
* ever writes a step's main recorded id from a *truthy* `resourceId`
|
|
25
|
+
* (`plan-apply.ts`), and retire returns none -- so the main
|
|
26
|
+
* `cloudflare:worker:<key>` id is never cleared by returning from the step,
|
|
27
|
+
* and without this marker the "gone" scan below would plan the same retire
|
|
28
|
+
* forever. Compared against that still-live id, the same convergence
|
|
29
|
+
* `entryAiRetiredScopeKey`/`entryFilesRetiredScopeKey`/
|
|
30
|
+
* `entryEdgeRetiredScopeKey` already give their own entry keys.
|
|
31
|
+
*/
|
|
32
|
+
export declare const entryWorkerRetiredScopeKey: (entryKey: string) => string;
|
|
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 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 {
|
|
50
|
+
id: string;
|
|
51
|
+
apex: string;
|
|
52
|
+
}
|
|
53
|
+
export interface WorkerRoutePending extends WorkerRouteZone {
|
|
54
|
+
pattern: string;
|
|
55
|
+
script: string;
|
|
56
|
+
}
|
|
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). */
|
|
58
|
+
export declare const workerTraceScopeKeys: (entryKey: string) => string[];
|
|
59
|
+
/**
|
|
60
|
+
* Every entry key of `orgId` with something foundry deployed still live,
|
|
61
|
+
* mapped to the script name recorded for it (`''` if only a route trace is
|
|
62
|
+
* left). Read by the planner's gone-scan and by org destroy alike, so the two
|
|
63
|
+
* agree on what is live: the main `cloudflare:worker:<org>:<entry>` id
|
|
64
|
+
* (unless the retired marker matches it), or any partial-deploy trace -- the
|
|
65
|
+
* uploaded name, a recorded route id, or a pending route -- regardless of
|
|
66
|
+
* the retired marker, since an apply clears that marker before it leaves any
|
|
67
|
+
* of these (#318 re-review).
|
|
68
|
+
*/
|
|
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;
|
|
72
|
+
/** Cloudflare's own limit on a Worker script name. */
|
|
73
|
+
export declare const WORKER_NAME_MAX_LENGTH = 63;
|
|
74
|
+
/**
|
|
75
|
+
* `${8-hex-char hash of org.id}-${entry.key}-edge`, not `${org.slug}-...`
|
|
76
|
+
* (#318 re-review): a slug and an entry key both allow hyphens, so org
|
|
77
|
+
* `a-b` entry `c` and org `a` entry `b-c` produced the identical name
|
|
78
|
+
* `a-b-c-edge` on the one shared Cloudflare account -- `scriptExists` only
|
|
79
|
+
* caught the second of the two to apply, and two concurrent applies could
|
|
80
|
+
* both pass it. `org.id` is a UUID with no ambiguity of its own, but using
|
|
81
|
+
* it in full would make the fixed part of the name longer than most of the
|
|
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`)
|
|
84
|
+
* checks the resulting name never exceeds that limit.
|
|
85
|
+
*/
|
|
86
|
+
export declare const workerName: (org: DeclaredOrg, entry: HostingEntry) => string;
|
|
87
|
+
/**
|
|
88
|
+
* Foundry's own fixed compatibility date for every Worker it deploys -- not
|
|
89
|
+
* a per-entry declared value, so it is not part of {@link WorkerWant}
|
|
90
|
+
* and never triggers a re-plan on its own; bumping it is a foundry release,
|
|
91
|
+
* like {@link RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS} is a fixed Cloudflare
|
|
92
|
+
* Free plan value rather than something an org declares.
|
|
93
|
+
*/
|
|
94
|
+
export declare const WORKER_COMPATIBILITY_DATE = "2026-09-12";
|
|
95
|
+
export type WorkerMode = 'apply' | 'retire';
|
|
96
|
+
export interface WorkerWant {
|
|
97
|
+
name: string;
|
|
98
|
+
artifactUrl: string;
|
|
99
|
+
artifactSha256: string;
|
|
100
|
+
route: string;
|
|
101
|
+
bindings: WorkerConfig['bindings'];
|
|
102
|
+
/** No-Worker route patterns, sorted; set only for a wildcard route with hostnames to keep out. */
|
|
103
|
+
exclusions?: string[];
|
|
104
|
+
}
|
|
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;
|
|
114
|
+
type Blocked = Array<{
|
|
115
|
+
description: string;
|
|
116
|
+
key: string;
|
|
117
|
+
}>;
|
|
118
|
+
/**
|
|
119
|
+
* One step per entry whose `worker` config is new or changed, plus one retire
|
|
120
|
+
* step per entry whose Worker is recorded but no longer declared -- either
|
|
121
|
+
* because the entry dropped `worker` or because the entry itself is gone, the
|
|
122
|
+
* same "gone" scan `planEntryAiKeys`/`planFilesKeys`/`planEntryEdgeKeys`
|
|
123
|
+
* run over their own recorded keys, so removing a whole entry still cleans
|
|
124
|
+
* up its Worker. `workersAvailable` is the host's own
|
|
125
|
+
* `FoundryPlanApplyOptions.edgeWorkers` (mirrors `jobsAvailable`): without
|
|
126
|
+
* it, every entry's step -- apply or retire -- is reported blocked rather
|
|
127
|
+
* than planned to fail at apply. The caller (`diff.ts`) gates the *apply*
|
|
128
|
+
* steps further, on an active zone, the same way it gates `planRateLimit`.
|
|
129
|
+
*/
|
|
130
|
+
export declare function planWorkers(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, workersAvailable: boolean, platformHostnames?: readonly string[]): {
|
|
131
|
+
steps: DraftStep[];
|
|
132
|
+
blocked: Blocked;
|
|
133
|
+
};
|
|
134
|
+
export {};
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { entryHostnames } from './hostnames.js';
|
|
3
|
+
import { lockedHostnames } from './locked-hostnames.js';
|
|
4
|
+
import { appStepKey } from './platform-diff.js';
|
|
5
|
+
/**
|
|
6
|
+
* F8 (#318): one plain Cloudflare Worker per hosting entry that declares
|
|
7
|
+
* `worker`, deployed from a built artifact that entry's own repo publishes
|
|
8
|
+
* (estate DECISIONS.md, 2026-10-04: foundry fetches and verifies it, never
|
|
9
|
+
* checks it out or bundles it). Unlike `planEntryAiKeys`/`planFilesKeys`,
|
|
10
|
+
* there is no renewal window -- a Worker script has no expiry -- so a step
|
|
11
|
+
* is planned again only while the entry's own declared config (`route`,
|
|
12
|
+
* `bindings` or, crucially, `artifactSha256`) differs from what was last
|
|
13
|
+
* applied (recorded as that config's JSON, `workerAppliedScopeKey`):
|
|
14
|
+
* `artifactSha256` is itself the declared signal that the built bytes
|
|
15
|
+
* changed, so a rebuild under an unchanged URL still redeploys as soon as
|
|
16
|
+
* the entry declares the new digest. "Blocked, not failing" (CLAUDE.md): a
|
|
17
|
+
* host whose executor carries no `WorkersPort` reports every entry's step
|
|
18
|
+
* (apply or retire) as blocked, not planned to a step that would throw.
|
|
19
|
+
*/
|
|
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}`;
|
|
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. */
|
|
24
|
+
export const entryWorkerRoutePatternScopeKey = (entryKey) => `edge_worker_route_pattern_${entryKey}`;
|
|
25
|
+
/**
|
|
26
|
+
* The script name retired, once a retire step completes. `runSteps` only
|
|
27
|
+
* ever writes a step's main recorded id from a *truthy* `resourceId`
|
|
28
|
+
* (`plan-apply.ts`), and retire returns none -- so the main
|
|
29
|
+
* `cloudflare:worker:<key>` id is never cleared by returning from the step,
|
|
30
|
+
* and without this marker the "gone" scan below would plan the same retire
|
|
31
|
+
* forever. Compared against that still-live id, the same convergence
|
|
32
|
+
* `entryAiRetiredScopeKey`/`entryFilesRetiredScopeKey`/
|
|
33
|
+
* `entryEdgeRetiredScopeKey` already give their own entry keys.
|
|
34
|
+
*/
|
|
35
|
+
export const entryWorkerRetiredScopeKey = (entryKey) => `edge_worker_retired_${entryKey}`;
|
|
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. */
|
|
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}`;
|
|
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). */
|
|
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),
|
|
57
|
+
];
|
|
58
|
+
/**
|
|
59
|
+
* Every entry key of `orgId` with something foundry deployed still live,
|
|
60
|
+
* mapped to the script name recorded for it (`''` if only a route trace is
|
|
61
|
+
* left). Read by the planner's gone-scan and by org destroy alike, so the two
|
|
62
|
+
* agree on what is live: the main `cloudflare:worker:<org>:<entry>` id
|
|
63
|
+
* (unless the retired marker matches it), or any partial-deploy trace -- the
|
|
64
|
+
* uploaded name, a recorded route id, or a pending route -- regardless of
|
|
65
|
+
* the retired marker, since an apply clears that marker before it leaves any
|
|
66
|
+
* of these (#318 re-review).
|
|
67
|
+
*/
|
|
68
|
+
export function liveWorkers(orgId, recordedIds) {
|
|
69
|
+
const mainPrefix = `cloudflare:worker:${orgId}:`;
|
|
70
|
+
const tracePrefixes = [
|
|
71
|
+
mainPrefix,
|
|
72
|
+
'edge_worker_uploaded_',
|
|
73
|
+
'edge_worker_route_id_',
|
|
74
|
+
'edge_worker_route_pending_',
|
|
75
|
+
'edge_worker_exclusions_',
|
|
76
|
+
];
|
|
77
|
+
const entryKeys = new Set();
|
|
78
|
+
for (const [name, id] of Object.entries(recordedIds)) {
|
|
79
|
+
if (!id)
|
|
80
|
+
continue;
|
|
81
|
+
const prefix = tracePrefixes.find((p) => name.startsWith(p));
|
|
82
|
+
if (prefix)
|
|
83
|
+
entryKeys.add(name.slice(prefix.length));
|
|
84
|
+
}
|
|
85
|
+
const live = new Map();
|
|
86
|
+
for (const entryKey of entryKeys) {
|
|
87
|
+
const mainId = recordedIds[`${mainPrefix}${entryKey}`] ?? '';
|
|
88
|
+
const uploaded = recordedIds[entryWorkerUploadedScopeKey(entryKey)] ?? '';
|
|
89
|
+
const partial = uploaded ||
|
|
90
|
+
recordedIds[entryWorkerRouteIdScopeKey(entryKey)] ||
|
|
91
|
+
recordedIds[entryWorkerRoutePendingScopeKey(entryKey)] ||
|
|
92
|
+
recordedIds[entryWorkerExclusionsScopeKey(entryKey)];
|
|
93
|
+
const retired = mainId !== '' && recordedIds[entryWorkerRetiredScopeKey(entryKey)] === mainId;
|
|
94
|
+
if (!partial && (!mainId || retired))
|
|
95
|
+
continue;
|
|
96
|
+
live.set(entryKey, mainId || uploaded);
|
|
97
|
+
}
|
|
98
|
+
return live;
|
|
99
|
+
}
|
|
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;
|
|
102
|
+
/** Cloudflare's own limit on a Worker script name. */
|
|
103
|
+
export const WORKER_NAME_MAX_LENGTH = 63;
|
|
104
|
+
/**
|
|
105
|
+
* `${8-hex-char hash of org.id}-${entry.key}-edge`, not `${org.slug}-...`
|
|
106
|
+
* (#318 re-review): a slug and an entry key both allow hyphens, so org
|
|
107
|
+
* `a-b` entry `c` and org `a` entry `b-c` produced the identical name
|
|
108
|
+
* `a-b-c-edge` on the one shared Cloudflare account -- `scriptExists` only
|
|
109
|
+
* caught the second of the two to apply, and two concurrent applies could
|
|
110
|
+
* both pass it. `org.id` is a UUID with no ambiguity of its own, but using
|
|
111
|
+
* it in full would make the fixed part of the name longer than most of the
|
|
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`)
|
|
114
|
+
* checks the resulting name never exceeds that limit.
|
|
115
|
+
*/
|
|
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`;
|
|
119
|
+
/**
|
|
120
|
+
* Foundry's own fixed compatibility date for every Worker it deploys -- not
|
|
121
|
+
* a per-entry declared value, so it is not part of {@link WorkerWant}
|
|
122
|
+
* and never triggers a re-plan on its own; bumping it is a foundry release,
|
|
123
|
+
* like {@link RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS} is a fixed Cloudflare
|
|
124
|
+
* Free plan value rather than something an org declares.
|
|
125
|
+
*/
|
|
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);
|
|
152
|
+
return {
|
|
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,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* One step per entry whose `worker` config is new or changed, plus one retire
|
|
163
|
+
* step per entry whose Worker is recorded but no longer declared -- either
|
|
164
|
+
* because the entry dropped `worker` or because the entry itself is gone, the
|
|
165
|
+
* same "gone" scan `planEntryAiKeys`/`planFilesKeys`/`planEntryEdgeKeys`
|
|
166
|
+
* run over their own recorded keys, so removing a whole entry still cleans
|
|
167
|
+
* up its Worker. `workersAvailable` is the host's own
|
|
168
|
+
* `FoundryPlanApplyOptions.edgeWorkers` (mirrors `jobsAvailable`): without
|
|
169
|
+
* it, every entry's step -- apply or retire -- is reported blocked rather
|
|
170
|
+
* than planned to fail at apply. The caller (`diff.ts`) gates the *apply*
|
|
171
|
+
* steps further, on an active zone, the same way it gates `planRateLimit`.
|
|
172
|
+
*/
|
|
173
|
+
export function planWorkers(org, recordedIds, workersAvailable, platformHostnames = []) {
|
|
174
|
+
const steps = [];
|
|
175
|
+
const blocked = [];
|
|
176
|
+
// Hosting off declares no Worker at all: the gone-scan below still runs,
|
|
177
|
+
// so a Worker deployed while hosting was on is retired rather than left
|
|
178
|
+
// live forever (#318 re-review). Only planning a *new* deploy is skipped.
|
|
179
|
+
const declared = new Set(org.hosting.enabled ? org.hosting.entries.filter((e) => e.worker).map((e) => e.key) : []);
|
|
180
|
+
const mainPrefix = `cloudflare:worker:${org.id}:`;
|
|
181
|
+
// Every entryKey with a live, undeclared trace (`liveWorkers`): the
|
|
182
|
+
// main id or any partial-deploy marker, so a deploy that failed after
|
|
183
|
+
// upload or mid-route-create is still found to retire (#318 review).
|
|
184
|
+
for (const entryKey of liveWorkers(org.id, recordedIds).keys()) {
|
|
185
|
+
if (declared.has(entryKey))
|
|
186
|
+
continue;
|
|
187
|
+
const stepKey = `${org.id}:${entryKey}`;
|
|
188
|
+
if (!workersAvailable) {
|
|
189
|
+
blocked.push({
|
|
190
|
+
description: `${org.slug}'s ${entryKey} Worker is blocked, not planned: the host supplies no WorkersPort`,
|
|
191
|
+
key: `${mainPrefix}${entryKey}`,
|
|
192
|
+
});
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
steps.push({
|
|
196
|
+
action: { system: 'cloudflare', kind: 'worker', key: stepKey, desired: { mode: 'retire' } },
|
|
197
|
+
step: {
|
|
198
|
+
description: `delete ${org.slug}'s ${entryKey} Worker and its route: worker is no longer declared`,
|
|
199
|
+
destroys: true,
|
|
200
|
+
migrates: false,
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
if (!org.hosting.enabled)
|
|
205
|
+
return { steps, blocked };
|
|
206
|
+
for (const entry of org.hosting.entries) {
|
|
207
|
+
if (!entry.worker)
|
|
208
|
+
continue;
|
|
209
|
+
const stepKey = appStepKey(org, entry.key);
|
|
210
|
+
const key = `cloudflare:worker:${stepKey}`;
|
|
211
|
+
const at = `${org.slug}'s ${entry.key} Worker`;
|
|
212
|
+
if (!workersAvailable) {
|
|
213
|
+
blocked.push({
|
|
214
|
+
description: `${at} is blocked, not planned: the host supplies no WorkersPort`,
|
|
215
|
+
key,
|
|
216
|
+
});
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const want = wantedWorker(org, entry, platformHostnames);
|
|
220
|
+
const wantJson = JSON.stringify(want);
|
|
221
|
+
const applied = recordedIds[entryWorkerAppliedScopeKey(entry.key)];
|
|
222
|
+
if (applied === wantJson)
|
|
223
|
+
continue; // Rerun: nothing changed, down to the built artifact's own digest.
|
|
224
|
+
steps.push({
|
|
225
|
+
action: {
|
|
226
|
+
system: 'cloudflare',
|
|
227
|
+
kind: 'worker',
|
|
228
|
+
key: stepKey,
|
|
229
|
+
desired: { mode: 'apply', compatibilityDate: WORKER_COMPATIBILITY_DATE, ...want },
|
|
230
|
+
},
|
|
231
|
+
step: {
|
|
232
|
+
description: `${applied ? 'update' : 'deploy'} ${at} on ${entry.worker.route}`,
|
|
233
|
+
destroys: false,
|
|
234
|
+
migrates: false,
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return { steps, blocked };
|
|
239
|
+
}
|
package/dist/platform.d.ts
CHANGED
|
@@ -12,8 +12,25 @@ export interface PlatformService {
|
|
|
12
12
|
hostname: string;
|
|
13
13
|
gitRef: string;
|
|
14
14
|
}
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* #491: `mail`'s own extra field, on top of every other service's shape.
|
|
17
|
+
* Company Included (or whichever org's mail domain is the platform's own
|
|
18
|
+
* apex) sends through the platform's existing Lettermint project rather than
|
|
19
|
+
* a second `<slug>-app` one of its own -- `undefined`/`null` until an
|
|
20
|
+
* operator declares which project that is. A ref only, never a value: the
|
|
21
|
+
* project's own sending token stays where it already lived, held under
|
|
22
|
+
* `PLATFORM_AUTH_BROKER_MAIL_KEY_REF` (`plan/platform-providers.ts`).
|
|
23
|
+
*/
|
|
24
|
+
export interface PlatformMailService extends PlatformService {
|
|
25
|
+
lettermintProjectId?: string | null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The shared services a declared platform names, decided in #165, plus
|
|
29
|
+
* `edge` (#317, F7): the service that mints an entry's own edge key and
|
|
30
|
+
* holds the Cloudflare token `<username>.<org domain>` claims use, so no
|
|
31
|
+
* hosting entry ever holds that credential itself.
|
|
32
|
+
*/
|
|
33
|
+
export type PlatformServiceKey = 'auth' | 'mail' | 'sfu' | 'files' | 'ai' | 'jobs' | 'integrations' | 'archon' | 'edge';
|
|
17
34
|
export declare const PLATFORM_SERVICE_KEYS: readonly PlatformServiceKey[];
|
|
18
35
|
/**
|
|
19
36
|
* The one shared platform every org's services run on -- decided in #165 as
|
|
@@ -42,13 +59,15 @@ export interface DeclaredPlatform {
|
|
|
42
59
|
*/
|
|
43
60
|
serverIpv4?: string | null;
|
|
44
61
|
auth: PlatformService;
|
|
45
|
-
mail:
|
|
62
|
+
mail: PlatformMailService;
|
|
46
63
|
sfu: PlatformService;
|
|
47
64
|
files: PlatformService;
|
|
48
65
|
ai: PlatformService;
|
|
49
66
|
jobs: PlatformService;
|
|
50
67
|
integrations: PlatformService;
|
|
51
68
|
archon: PlatformService;
|
|
69
|
+
/** #317 (F7). No deploy step reads this service's own `gitRef` yet -- `N1` is deployed by hand until it exists. */
|
|
70
|
+
edge: PlatformService;
|
|
52
71
|
}
|
|
53
72
|
/** `<service>.<apex>` -- the default `hostname` for a platform service, before an operator overrides it. */
|
|
54
73
|
export declare function defaultPlatformHostname(service: PlatformServiceKey, apex: string): string;
|
package/dist/platform.js
CHANGED
|
@@ -9,6 +9,7 @@ export const PLATFORM_SERVICE_KEYS = [
|
|
|
9
9
|
'jobs',
|
|
10
10
|
'integrations',
|
|
11
11
|
'archon',
|
|
12
|
+
'edge',
|
|
12
13
|
];
|
|
13
14
|
/** `<service>.<apex>` -- the default `hostname` for a platform service, before an operator overrides it. */
|
|
14
15
|
export function defaultPlatformHostname(service, apex) {
|
|
@@ -47,5 +48,10 @@ export function validatePlatform(platform) {
|
|
|
47
48
|
return `${key}.gitRef must not be empty once ${key}.enabled is true`;
|
|
48
49
|
}
|
|
49
50
|
}
|
|
51
|
+
// #491: declared, never discovered -- a present-but-blank id is refused the
|
|
52
|
+
// same way `serverIpv4`'s blank string is, rather than being read as unset.
|
|
53
|
+
if (platform.mail.lettermintProjectId != null && !platform.mail.lettermintProjectId.trim()) {
|
|
54
|
+
return 'mail.lettermintProjectId must not be empty when set';
|
|
55
|
+
}
|
|
50
56
|
return null;
|
|
51
57
|
}
|
package/dist/store/postgres.js
CHANGED
|
@@ -578,7 +578,12 @@ function rowToPlatform(row) {
|
|
|
578
578
|
apex: row.apex,
|
|
579
579
|
...(row.serverIpv4 ? { serverIpv4: row.serverIpv4 } : {}),
|
|
580
580
|
auth: { enabled: row.authEnabled, hostname: row.authHostname, gitRef: row.authGitRef },
|
|
581
|
-
mail: {
|
|
581
|
+
mail: {
|
|
582
|
+
enabled: row.mailEnabled,
|
|
583
|
+
hostname: row.mailHostname,
|
|
584
|
+
gitRef: row.mailGitRef,
|
|
585
|
+
...(row.mailLettermintProjectId ? { lettermintProjectId: row.mailLettermintProjectId } : {}),
|
|
586
|
+
},
|
|
582
587
|
sfu: { enabled: row.sfuEnabled, hostname: row.sfuHostname, gitRef: row.sfuGitRef },
|
|
583
588
|
files: { enabled: row.filesEnabled, hostname: row.filesHostname, gitRef: row.filesGitRef },
|
|
584
589
|
ai: { enabled: row.aiEnabled, hostname: row.aiHostname, gitRef: row.aiGitRef },
|
|
@@ -593,6 +598,11 @@ function rowToPlatform(row) {
|
|
|
593
598
|
hostname: row.archonHostname,
|
|
594
599
|
gitRef: row.archonGitRef,
|
|
595
600
|
},
|
|
601
|
+
edge: {
|
|
602
|
+
enabled: row.edgeEnabled,
|
|
603
|
+
hostname: row.edgeHostname,
|
|
604
|
+
gitRef: row.edgeGitRef,
|
|
605
|
+
},
|
|
596
606
|
};
|
|
597
607
|
}
|
|
598
608
|
function platformToRow(platform) {
|
|
@@ -607,6 +617,7 @@ function platformToRow(platform) {
|
|
|
607
617
|
mailEnabled: platform.mail.enabled,
|
|
608
618
|
mailHostname: platform.mail.hostname,
|
|
609
619
|
mailGitRef: platform.mail.gitRef,
|
|
620
|
+
mailLettermintProjectId: platform.mail.lettermintProjectId ?? null,
|
|
610
621
|
sfuEnabled: platform.sfu.enabled,
|
|
611
622
|
sfuHostname: platform.sfu.hostname,
|
|
612
623
|
sfuGitRef: platform.sfu.gitRef,
|
|
@@ -625,6 +636,9 @@ function platformToRow(platform) {
|
|
|
625
636
|
archonEnabled: platform.archon.enabled,
|
|
626
637
|
archonHostname: platform.archon.hostname,
|
|
627
638
|
archonGitRef: platform.archon.gitRef,
|
|
639
|
+
edgeEnabled: platform.edge.enabled,
|
|
640
|
+
edgeHostname: platform.edge.hostname,
|
|
641
|
+
edgeGitRef: platform.edge.gitRef,
|
|
628
642
|
};
|
|
629
643
|
}
|
|
630
644
|
/**
|
package/dist/store/schema.d.ts
CHANGED
|
@@ -1236,7 +1236,7 @@ export declare const foundryAuditEvents: import("drizzle-orm/pg-core").PgTableWi
|
|
|
1236
1236
|
tableName: "foundry_audit_events";
|
|
1237
1237
|
dataType: "string";
|
|
1238
1238
|
columnType: "PgText";
|
|
1239
|
-
data: "
|
|
1239
|
+
data: "apply" | "plan" | "destroy";
|
|
1240
1240
|
driverParam: string;
|
|
1241
1241
|
notNull: true;
|
|
1242
1242
|
hasDefault: false;
|
|
@@ -1248,7 +1248,7 @@ export declare const foundryAuditEvents: import("drizzle-orm/pg-core").PgTableWi
|
|
|
1248
1248
|
identity: undefined;
|
|
1249
1249
|
generated: undefined;
|
|
1250
1250
|
}, {}, {
|
|
1251
|
-
$type: "
|
|
1251
|
+
$type: "apply" | "plan" | "destroy";
|
|
1252
1252
|
}>;
|
|
1253
1253
|
actorId: import("drizzle-orm/pg-core").PgColumn<{
|
|
1254
1254
|
name: "actor_id";
|
|
@@ -1558,6 +1558,23 @@ export declare const foundryDeclaredPlatform: import("drizzle-orm/pg-core").PgTa
|
|
|
1558
1558
|
identity: undefined;
|
|
1559
1559
|
generated: undefined;
|
|
1560
1560
|
}, {}, {}>;
|
|
1561
|
+
mailLettermintProjectId: import("drizzle-orm/pg-core").PgColumn<{
|
|
1562
|
+
name: "mail_lettermint_project_id";
|
|
1563
|
+
tableName: "foundry_declared_platform";
|
|
1564
|
+
dataType: "string";
|
|
1565
|
+
columnType: "PgText";
|
|
1566
|
+
data: string;
|
|
1567
|
+
driverParam: string;
|
|
1568
|
+
notNull: false;
|
|
1569
|
+
hasDefault: false;
|
|
1570
|
+
isPrimaryKey: false;
|
|
1571
|
+
isAutoincrement: false;
|
|
1572
|
+
hasRuntimeDefault: false;
|
|
1573
|
+
enumValues: [string, ...string[]];
|
|
1574
|
+
baseColumn: never;
|
|
1575
|
+
identity: undefined;
|
|
1576
|
+
generated: undefined;
|
|
1577
|
+
}, {}, {}>;
|
|
1561
1578
|
sfuEnabled: import("drizzle-orm/pg-core").PgColumn<{
|
|
1562
1579
|
name: "sfu_enabled";
|
|
1563
1580
|
tableName: "foundry_declared_platform";
|
|
@@ -1864,6 +1881,57 @@ export declare const foundryDeclaredPlatform: import("drizzle-orm/pg-core").PgTa
|
|
|
1864
1881
|
identity: undefined;
|
|
1865
1882
|
generated: undefined;
|
|
1866
1883
|
}, {}, {}>;
|
|
1884
|
+
edgeEnabled: import("drizzle-orm/pg-core").PgColumn<{
|
|
1885
|
+
name: "edge_enabled";
|
|
1886
|
+
tableName: "foundry_declared_platform";
|
|
1887
|
+
dataType: "boolean";
|
|
1888
|
+
columnType: "PgBoolean";
|
|
1889
|
+
data: boolean;
|
|
1890
|
+
driverParam: boolean;
|
|
1891
|
+
notNull: true;
|
|
1892
|
+
hasDefault: true;
|
|
1893
|
+
isPrimaryKey: false;
|
|
1894
|
+
isAutoincrement: false;
|
|
1895
|
+
hasRuntimeDefault: false;
|
|
1896
|
+
enumValues: undefined;
|
|
1897
|
+
baseColumn: never;
|
|
1898
|
+
identity: undefined;
|
|
1899
|
+
generated: undefined;
|
|
1900
|
+
}, {}, {}>;
|
|
1901
|
+
edgeHostname: import("drizzle-orm/pg-core").PgColumn<{
|
|
1902
|
+
name: "edge_hostname";
|
|
1903
|
+
tableName: "foundry_declared_platform";
|
|
1904
|
+
dataType: "string";
|
|
1905
|
+
columnType: "PgText";
|
|
1906
|
+
data: string;
|
|
1907
|
+
driverParam: string;
|
|
1908
|
+
notNull: true;
|
|
1909
|
+
hasDefault: false;
|
|
1910
|
+
isPrimaryKey: false;
|
|
1911
|
+
isAutoincrement: false;
|
|
1912
|
+
hasRuntimeDefault: false;
|
|
1913
|
+
enumValues: [string, ...string[]];
|
|
1914
|
+
baseColumn: never;
|
|
1915
|
+
identity: undefined;
|
|
1916
|
+
generated: undefined;
|
|
1917
|
+
}, {}, {}>;
|
|
1918
|
+
edgeGitRef: import("drizzle-orm/pg-core").PgColumn<{
|
|
1919
|
+
name: "edge_git_ref";
|
|
1920
|
+
tableName: "foundry_declared_platform";
|
|
1921
|
+
dataType: "string";
|
|
1922
|
+
columnType: "PgText";
|
|
1923
|
+
data: string;
|
|
1924
|
+
driverParam: string;
|
|
1925
|
+
notNull: true;
|
|
1926
|
+
hasDefault: true;
|
|
1927
|
+
isPrimaryKey: false;
|
|
1928
|
+
isAutoincrement: false;
|
|
1929
|
+
hasRuntimeDefault: false;
|
|
1930
|
+
enumValues: [string, ...string[]];
|
|
1931
|
+
baseColumn: never;
|
|
1932
|
+
identity: undefined;
|
|
1933
|
+
generated: undefined;
|
|
1934
|
+
}, {}, {}>;
|
|
1867
1935
|
serviceIds: import("drizzle-orm/pg-core").PgColumn<{
|
|
1868
1936
|
name: "service_ids";
|
|
1869
1937
|
tableName: "foundry_declared_platform";
|
package/dist/store/schema.js
CHANGED
|
@@ -158,6 +158,8 @@ export const foundryDeclaredPlatform = pgTable('foundry_declared_platform', {
|
|
|
158
158
|
mailEnabled: boolean('mail_enabled').notNull().default(false),
|
|
159
159
|
mailHostname: text('mail_hostname').notNull(),
|
|
160
160
|
mailGitRef: text('mail_git_ref').notNull().default(''),
|
|
161
|
+
/** #491, `migrations/0044_platform_mail_lettermint_project.sql`. Declared, never discovered. */
|
|
162
|
+
mailLettermintProjectId: text('mail_lettermint_project_id'),
|
|
161
163
|
sfuEnabled: boolean('sfu_enabled').notNull().default(false),
|
|
162
164
|
sfuHostname: text('sfu_hostname').notNull(),
|
|
163
165
|
sfuGitRef: text('sfu_git_ref').notNull().default(''),
|
|
@@ -176,6 +178,10 @@ export const foundryDeclaredPlatform = pgTable('foundry_declared_platform', {
|
|
|
176
178
|
archonEnabled: boolean('archon_enabled').notNull().default(false),
|
|
177
179
|
archonHostname: text('archon_hostname').notNull(),
|
|
178
180
|
archonGitRef: text('archon_git_ref').notNull().default(''),
|
|
181
|
+
/** #317 (F7), `migrations/0043_platform_names.sql`, renamed from `names_*` by `0045_names_to_edge.sql`. */
|
|
182
|
+
edgeEnabled: boolean('edge_enabled').notNull().default(false),
|
|
183
|
+
edgeHostname: text('edge_hostname').notNull(),
|
|
184
|
+
edgeGitRef: text('edge_git_ref').notNull().default(''),
|
|
179
185
|
/** `migrations/0018_platform_apply.sql`: the platform's applied state, `system:kind:key` to provider id. */
|
|
180
186
|
serviceIds: jsonb('service_ids').$type().notNull().default({}),
|
|
181
187
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|