@wtfalch/foundry 0.19.0 → 0.20.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.
@@ -0,0 +1,165 @@
1
+ import { EdgeApiError } from '@wtfalch/edge';
2
+ import { EDGE_DNS_WRITE_CONCURRENCY, limiter, rateLimitRetrier, } from './edge-dns.js';
3
+ /** Edge answers a first upload of a name that another upload is making, or a delete in flight, `conflict` + `busy`. */
4
+ export const EDGE_WORKERS_BUSY_ATTEMPTS = 5;
5
+ const BUSY_FIRST_DELAY_MS = 1000;
6
+ const BUSY_MAX_DELAY_MS = 8000;
7
+ const isNotFound = (error) => error instanceof EdgeApiError && error.code === 'not_found';
8
+ const isExists = (error) => error instanceof EdgeApiError && error.code === 'conflict' && String(error.reason) === 'exists';
9
+ const isBusy = (error) => error instanceof EdgeApiError && error.code === 'conflict' && String(error.reason) === 'busy';
10
+ /**
11
+ * Retries a call edge answered `conflict` + `busy`, waiting 1 s, 2 s, 4 s, then
12
+ * 8 s (the cap), up to {@link EDGE_WORKERS_BUSY_ATTEMPTS} tries.
13
+ */
14
+ function busyRetrier(sleepOption) {
15
+ const sleep = sleepOption ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
16
+ return async (task) => {
17
+ for (let attempt = 1;; attempt++) {
18
+ try {
19
+ return await task();
20
+ }
21
+ catch (error) {
22
+ if (!isBusy(error) || attempt >= EDGE_WORKERS_BUSY_ATTEMPTS)
23
+ throw error;
24
+ await sleep(Math.min(BUSY_FIRST_DELAY_MS * 2 ** (attempt - 1), BUSY_MAX_DELAY_MS));
25
+ }
26
+ }
27
+ };
28
+ }
29
+ /**
30
+ * {@link WorkersPort} over edge's `platform.workers`. The host fetches the
31
+ * artifact (`fetchArtifact`); this sends its base64 bytes and the sha256 to
32
+ * edge, which checks the digest again. The bytes are never kept, logged or
33
+ * put into an error here.
34
+ *
35
+ * Edge refuses to touch a live script or route that has no edge row (an upload
36
+ * of a live name answers `exists`, a delete answers `not_found`). So the
37
+ * adapter adopts one the moment it is about to be uploaded over or deleted:
38
+ * it lists the name (or the zone's routes), and adopts only a view whose
39
+ * `owner` is `null`. Adopt is idempotent and the list reports the owner
40
+ * afterwards, so nothing is recorded in the plan. A `skipped` route is an
41
+ * error. `scriptExists` and `findRoute` only read: a foreign script or route
42
+ * foundry refuses to overwrite is never adopted. A 404 on a delete means gone.
43
+ * A `rate_limited` or `busy` answer is retried.
44
+ */
45
+ export function createEdgeWorkers(client, options) {
46
+ const rateRetry = rateLimitRetrier(options.sleep);
47
+ const busyRetry = busyRetrier(options.sleep);
48
+ const retrying = (task) => busyRetry(() => rateRetry(task));
49
+ const writeSlot = options.writeSlot ?? limiter(EDGE_DNS_WRITE_CONCURRENCY);
50
+ /** Script names and `zoneId/routeId` keys already adopted (or created) through this adapter. */
51
+ const adoptedScripts = new Set();
52
+ const adoptedRoutes = new Set();
53
+ const liveScript = async (name) => {
54
+ const { scripts } = await retrying(() => client.listScripts({ name }));
55
+ return scripts.find((s) => s.name === name && s.state !== 'removed');
56
+ };
57
+ const liveRoute = async (zoneId, pattern) => {
58
+ const { routes } = await retrying(() => client.listRoutes({ zoneId, pattern }));
59
+ return routes.find((r) => r.pattern === pattern && r.state !== 'removed');
60
+ };
61
+ /** Adopts `name` when its live script has no edge row. Returns false when no live script exists. */
62
+ const adoptScript = async (name) => {
63
+ if (adoptedScripts.has(name))
64
+ return true;
65
+ const view = await liveScript(name);
66
+ if (!view)
67
+ return false;
68
+ if (view.owner === null) {
69
+ const { scripts } = await writeSlot(() => retrying(() => client.adopt({ scripts: [{ name }] })));
70
+ if (scripts.notFound.length > 0)
71
+ return false;
72
+ }
73
+ adoptedScripts.add(name);
74
+ return true;
75
+ };
76
+ /** Adopts the route `id` in `zoneId` when it has no edge row. Returns false when it is gone. */
77
+ const adoptRoute = async (zoneId, id) => {
78
+ const key = `${zoneId}/${id}`;
79
+ if (adoptedRoutes.has(key))
80
+ return true;
81
+ const { routes: live } = await retrying(() => client.listRoutes({ zoneId }));
82
+ const view = live.find((r) => r.id === id && r.state !== 'removed');
83
+ if (!view)
84
+ return false;
85
+ if (view.owner === null) {
86
+ const { routes } = await writeSlot(() => retrying(() => client.adopt({ routes: [{ zoneId, pattern: view.pattern, id: view.id }] })));
87
+ const skipped = routes.skipped[0];
88
+ if (skipped) {
89
+ throw new Error(`edge workers: route ${view.pattern} in zone ${zoneId} cannot be adopted (${skipped.reason}); remove or fix it by hand`);
90
+ }
91
+ if (routes.notFound.length > 0)
92
+ return false;
93
+ }
94
+ adoptedRoutes.add(key);
95
+ return true;
96
+ };
97
+ return {
98
+ async scriptExists(name) {
99
+ return (await liveScript(name)) !== undefined;
100
+ },
101
+ async uploadScript(input) {
102
+ await adoptScript(input.name);
103
+ const bytes = await options.fetchArtifact(input.artifactUrl, input.artifactSha256);
104
+ await writeSlot(() => retrying(() => client.uploadScript(input.name, {
105
+ script: Buffer.from(bytes).toString('base64'),
106
+ artifactSha256: input.artifactSha256,
107
+ compatibilityDate: input.compatibilityDate,
108
+ ...(input.bindings ? { bindings: input.bindings } : {}),
109
+ })));
110
+ adoptedScripts.add(input.name);
111
+ },
112
+ async findRoute(zoneId, pattern) {
113
+ const view = await liveRoute(zoneId, pattern);
114
+ return view?.script ? { id: view.id, script: view.script } : undefined;
115
+ },
116
+ async createRoute(zoneId, pattern, script) {
117
+ const create = () => writeSlot(() => retrying(() => client.createRoute({ zoneId, pattern, ...(script ? { script } : {}) })));
118
+ let created;
119
+ try {
120
+ created = await create();
121
+ }
122
+ catch (error) {
123
+ if (!isExists(error))
124
+ throw error;
125
+ // A live route on the pattern with no edge row (made before edge owned
126
+ // Workers): adopt it when it is the one route there and runs the same
127
+ // script (or none). Anything else is not foundry's to take.
128
+ const { routes } = await retrying(() => client.listRoutes({ zoneId, pattern }));
129
+ const live = routes.filter((r) => r.pattern === pattern && r.state !== 'removed');
130
+ const only = live.length === 1 ? live[0] : undefined;
131
+ if (!only || only.owner !== null || (only.script ?? null) !== (script ?? null))
132
+ throw error;
133
+ if (!(await adoptRoute(zoneId, only.id)))
134
+ throw error;
135
+ created = { id: only.id };
136
+ }
137
+ adoptedRoutes.add(`${zoneId}/${created.id}`);
138
+ return { id: created.id };
139
+ },
140
+ async deleteRoute(zoneId, routeId) {
141
+ if (!(await adoptRoute(zoneId, routeId)))
142
+ return;
143
+ try {
144
+ await writeSlot(() => retrying(() => client.removeRoute(zoneId, routeId)));
145
+ }
146
+ catch (error) {
147
+ if (!isNotFound(error))
148
+ throw error;
149
+ }
150
+ adoptedRoutes.delete(`${zoneId}/${routeId}`);
151
+ },
152
+ async deleteScript(name) {
153
+ if (!(await adoptScript(name)))
154
+ return;
155
+ try {
156
+ await writeSlot(() => retrying(() => client.removeScript(name)));
157
+ }
158
+ catch (error) {
159
+ if (!isNotFound(error))
160
+ throw error;
161
+ }
162
+ adoptedScripts.delete(name);
163
+ },
164
+ };
165
+ }
@@ -0,0 +1,37 @@
1
+ import type { createEdgeClient } from '@wtfalch/edge';
2
+ import { type WriteSlot } from './edge-dns.js';
3
+ /** The edge SDK's platform zones client: `createEdgeClient({ ... }).platform.zones`. */
4
+ export type EdgePlatformZones = ReturnType<typeof createEdgeClient>['platform']['zones'];
5
+ /**
6
+ * Foundry's Cloudflare zone calls. Both go through this; the one implementation
7
+ * is {@link createEdgeZones}, which speaks to the edge service with the platform credential.
8
+ */
9
+ export interface ZonePort {
10
+ /** The zone that serves `domain`: the longest suffix of it that is a live zone, or `undefined`. */
11
+ findZoneByApex(domain: string): Promise<{
12
+ id: string;
13
+ name: string;
14
+ } | undefined>;
15
+ /** Makes the zone, or returns the one already made for that name. */
16
+ createZone(name: string): Promise<{
17
+ id: string;
18
+ name: string;
19
+ }>;
20
+ }
21
+ export interface EdgeZonesOptions {
22
+ sleep?: (ms: number) => Promise<void>;
23
+ /** Share one slot of {@link EDGE_DNS_WRITE_CONCURRENCY} with the other edge adapters: edge counts them all. */
24
+ writeSlot?: WriteSlot;
25
+ }
26
+ /**
27
+ * {@link ZonePort} over edge's `platform.zones`.
28
+ *
29
+ * Edge lists a zone by its exact name, so a lookup tries the full name and
30
+ * then each parent down to two labels, and never writes. Edge answers a create
31
+ * of a live zone that has no edge row `exists`. So the adapter adopts that zone
32
+ * the moment it is about to create it: it lists the name, and adopts a view
33
+ * whose `owner` is `null`. Adopt is idempotent and the list reports the owner
34
+ * afterwards, so nothing is recorded in the plan. A zone edge reports as
35
+ * `taken` is an error. A `rate_limited` or `busy` answer is retried.
36
+ */
37
+ export declare function createEdgeZones(client: EdgePlatformZones, options?: EdgeZonesOptions): ZonePort;
@@ -0,0 +1,64 @@
1
+ import { EdgeApiError } from '@wtfalch/edge';
2
+ import { EDGE_DNS_WRITE_CONCURRENCY, limiter, rateLimitRetrier, } from './edge-dns.js';
3
+ import { busyRetrier, hasReason } from './edge-ratelimits.js';
4
+ /**
5
+ * {@link ZonePort} over edge's `platform.zones`.
6
+ *
7
+ * Edge lists a zone by its exact name, so a lookup tries the full name and
8
+ * then each parent down to two labels, and never writes. Edge answers a create
9
+ * of a live zone that has no edge row `exists`. So the adapter adopts that zone
10
+ * the moment it is about to create it: it lists the name, and adopts a view
11
+ * whose `owner` is `null`. Adopt is idempotent and the list reports the owner
12
+ * afterwards, so nothing is recorded in the plan. A zone edge reports as
13
+ * `taken` is an error. A `rate_limited` or `busy` answer is retried.
14
+ */
15
+ export function createEdgeZones(client, options = {}) {
16
+ const rateRetry = rateLimitRetrier(options.sleep);
17
+ const busyRetry = busyRetrier(options.sleep);
18
+ const retrying = (task) => busyRetry(() => rateRetry(task));
19
+ const writeSlot = options.writeSlot ?? limiter(EDGE_DNS_WRITE_CONCURRENCY);
20
+ const adoptLive = async (name) => {
21
+ const { zones } = await retrying(() => client.list({ name }));
22
+ const view = zones.find((z) => z.name === name);
23
+ if (!view || view.owner !== null)
24
+ return;
25
+ const result = await writeSlot(() => retrying(() => client.adopt({ zones: [{ name }] })));
26
+ if (result.skipped.length > 0) {
27
+ throw new Error(`edge zones: zone ${name} cannot be adopted (${result.skipped[0]?.reason}); remove or fix it by hand`);
28
+ }
29
+ };
30
+ const create = (name) => writeSlot(() => retrying(() => client.create({ name }))).catch((error) => {
31
+ if (error instanceof EdgeApiError && error.code === 'not_configured') {
32
+ throw new Error(`edge zones: edge's Cloudflare token cannot create zones yet, so zone ${name} was not created; give it the Zone Edit permission or create the zone by hand`);
33
+ }
34
+ throw error;
35
+ });
36
+ return {
37
+ async findZoneByApex(domain) {
38
+ const labels = domain.toLowerCase().split('.');
39
+ for (let i = 0; labels.length - i >= 2; i++) {
40
+ const name = labels.slice(i).join('.');
41
+ const { zones } = await retrying(() => client.list({ name }));
42
+ const view = zones.find((z) => z.name === name);
43
+ if (view)
44
+ return { id: view.id, name: view.name };
45
+ }
46
+ return undefined;
47
+ },
48
+ async createZone(name) {
49
+ await adoptLive(name);
50
+ try {
51
+ const made = await create(name);
52
+ return { id: made.id, name: made.name };
53
+ }
54
+ catch (error) {
55
+ if (!hasReason(error, 'exists'))
56
+ throw error;
57
+ // The zone went live between the adopt and the create: adopt it and try once more.
58
+ await adoptLive(name);
59
+ const made = await create(name);
60
+ return { id: made.id, name: made.name };
61
+ }
62
+ },
63
+ };
64
+ }
@@ -35,6 +35,16 @@ export declare const entryEdgeMintedAtScopeKey: (entryKey: string) => string;
35
35
  export declare const EDGE_ENTRY_KEY_WARN_WINDOW_DAYS = 30;
36
36
  /** The reserved labels last sent to the edge service, as that sorted list's own JSON. */
37
37
  export declare const EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY = "edge_reserved_labels_applied";
38
+ /**
39
+ * F3: the org's own Boule holds an edge key too, so a company edits its DNS
40
+ * records there (`records:read`, `records:edit`: edge's entry keys carry both).
41
+ * It rides the hosting-entry key path under this entry key. The leading
42
+ * underscore is one no hosting entry key can have (`ENTRY_KEY` in `org.ts`),
43
+ * so it never collides with a declared entry.
44
+ */
45
+ export declare const BOULE_EDGE_ENTRY_KEY = "_boule";
46
+ /** The recorded-id key of the Coolify app an edge key's env is written to. */
47
+ export declare function edgeKeyAppRecordKey(org: DeclaredOrg, entryKey: string): string;
38
48
  export declare const entryEdgeKeyName: (org: DeclaredOrg, entry: HostingEntry) => string;
39
49
  /**
40
50
  * Every label under `org.domain` that foundry provisions or the platform
@@ -36,6 +36,20 @@ export const EDGE_ENTRY_KEY_WARN_WINDOW_DAYS = 30;
36
36
  const DAY_MS = 24 * 60 * 60 * 1000;
37
37
  /** The reserved labels last sent to the edge service, as that sorted list's own JSON. */
38
38
  export const EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY = 'edge_reserved_labels_applied';
39
+ /**
40
+ * F3: the org's own Boule holds an edge key too, so a company edits its DNS
41
+ * records there (`records:read`, `records:edit`: edge's entry keys carry both).
42
+ * It rides the hosting-entry key path under this entry key. The leading
43
+ * underscore is one no hosting entry key can have (`ENTRY_KEY` in `org.ts`),
44
+ * so it never collides with a declared entry.
45
+ */
46
+ export const BOULE_EDGE_ENTRY_KEY = '_boule';
47
+ /** The recorded-id key of the Coolify app an edge key's env is written to. */
48
+ export function edgeKeyAppRecordKey(org, entryKey) {
49
+ return entryKey === BOULE_EDGE_ENTRY_KEY
50
+ ? `coolify:boule:${org.id}`
51
+ : `coolify:app:${appStepKey(org, entryKey)}`;
52
+ }
39
53
  export const entryEdgeKeyName = (org, entry) => `${org.slug}-${entry.key}-edge`;
40
54
  /**
41
55
  * Mail names foundry's mail setup puts on the org's domain. Stalwart's and
@@ -97,7 +111,15 @@ export function planEntryEdgeKeys(org, recordedIds, plannedSteps, readiness, now
97
111
  // throws before `runSteps` sees a `resourceId`), so scanning the main
98
112
  // prefix alone would never find it to revoke once its entry is gone
99
113
  // (#317 review).
100
- const declared = new Set(org.hosting.entries.filter((e) => e.uses?.edge).map((e) => e.key));
114
+ // Boule's key is wanted whenever the org has its own domain (with-domain
115
+ // mode): edge refuses a company on oikos.host, so none is minted there.
116
+ const holders = org.hosting.entries
117
+ .filter((e) => e.uses?.edge)
118
+ .map((e) => ({ entryKey: e.key, name: entryEdgeKeyName(org, e) }));
119
+ if (org.domain) {
120
+ holders.push({ entryKey: BOULE_EDGE_ENTRY_KEY, name: `${org.slug}-boule-edge` });
121
+ }
122
+ const declared = new Set(holders.map((h) => h.entryKey));
101
123
  const mainPrefix = `edge:key:${org.id}:`;
102
124
  const undeliveredPrefix = 'edge_key_undelivered_';
103
125
  const goneEntryKeys = new Set();
@@ -146,11 +168,11 @@ export function planEntryEdgeKeys(org, recordedIds, plannedSteps, readiness, now
146
168
  },
147
169
  });
148
170
  }
149
- for (const entry of org.hosting.entries) {
150
- if (!entry.uses?.edge)
151
- continue;
171
+ for (const holder of holders) {
172
+ const entry = { key: holder.entryKey };
152
173
  const stepKey = appStepKey(org, entry.key);
153
174
  const key = `edge:key:${stepKey}`;
175
+ const appRecordKey = edgeKeyAppRecordKey(org, entry.key);
154
176
  const liveKeyId = recordedIds[key] && recordedIds[entryEdgeRetiredScopeKey(entry.key)] !== recordedIds[key]
155
177
  ? recordedIds[key]
156
178
  : undefined;
@@ -186,12 +208,20 @@ export function planEntryEdgeKeys(org, recordedIds, plannedSteps, readiness, now
186
208
  // re-review) -- blocking indefinitely, as a prior round did, left the
187
209
  // orphan live for up to 365 days with no recorded trace an operator
188
210
  // could act on beyond this description.
211
+ // Boule's key is not something the org declared, so a platform with no
212
+ // edge service, or a Boule not planned yet, is quiet: no blocked row.
213
+ const isBoule = entry.key === BOULE_EDGE_ENTRY_KEY;
189
214
  if (!readiness.edgeReady) {
190
- block('the host supplies no edge service, or the platform has not enabled it');
215
+ if (!isBoule)
216
+ block('the host supplies no edge service, or the platform has not enabled it');
191
217
  continue;
192
218
  }
193
- if (!recordedIds[`coolify:app:${stepKey}`] && !planned('coolify', 'app', stepKey)) {
194
- block("the entry's app is not recorded yet");
219
+ const appPlanned = isBoule
220
+ ? planned('coolify', 'boule', org.id)
221
+ : planned('coolify', 'app', stepKey);
222
+ if (!recordedIds[appRecordKey] && !appPlanned) {
223
+ if (!isBoule)
224
+ block("the entry's app is not recorded yet");
195
225
  continue;
196
226
  }
197
227
  steps.push({
@@ -201,7 +231,7 @@ export function planEntryEdgeKeys(org, recordedIds, plannedSteps, readiness, now
201
231
  key: stepKey,
202
232
  desired: {
203
233
  mode: 'mint',
204
- name: entryEdgeKeyName(org, entry),
234
+ name: holder.name,
205
235
  edgeUrl: readiness.edgeUrl,
206
236
  },
207
237
  },