@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.
- package/dist/bootstrap/declare.js +8 -6
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -2
- package/dist/plan/agora.d.ts +11 -0
- package/dist/plan/agora.js +13 -0
- package/dist/plan/chat-matrix.d.ts +3 -0
- package/dist/plan/chat-matrix.js +2 -0
- package/dist/plan/cloudflared-connector.d.ts +3 -2
- package/dist/plan/cloudflared-connector.js +2 -2
- package/dist/plan/diff.js +26 -5
- package/dist/plan/edge-access.d.ts +68 -0
- package/dist/plan/edge-access.js +123 -0
- package/dist/plan/edge-dns.d.ts +89 -0
- package/dist/plan/edge-dns.js +227 -0
- package/dist/plan/edge-ratelimits.d.ts +54 -0
- package/dist/plan/edge-ratelimits.js +79 -0
- package/dist/plan/edge-tunnels.d.ts +44 -0
- package/dist/plan/edge-tunnels.js +108 -0
- package/dist/plan/edge-workers.d.ts +31 -0
- package/dist/plan/edge-workers.js +165 -0
- package/dist/plan/edge-zones.d.ts +37 -0
- package/dist/plan/edge-zones.js +64 -0
- package/dist/plan/entry-edge-key.d.ts +10 -0
- package/dist/plan/entry-edge-key.js +38 -8
- package/dist/plan/executor.d.ts +124 -143
- package/dist/plan/executor.js +288 -89
- package/dist/plan/index.d.ts +15 -3
- package/dist/plan/index.js +8 -2
- package/dist/plan/plan-apply.js +5 -1
- package/dist/plan/platform-diff.d.ts +9 -0
- package/dist/plan/platform-diff.js +32 -8
- package/dist/plan/platform-providers.d.ts +53 -6
- package/dist/plan/platform-providers.js +87 -44
- package/dist/plan/providers-readers.d.ts +23 -1
- package/dist/plan/providers-readers.js +29 -0
- package/package.json +2 -1
|
@@ -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
|
-
|
|
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
|
|
150
|
-
|
|
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
|
-
|
|
215
|
+
if (!isBoule)
|
|
216
|
+
block('the host supplies no edge service, or the platform has not enabled it');
|
|
191
217
|
continue;
|
|
192
218
|
}
|
|
193
|
-
|
|
194
|
-
|
|
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:
|
|
234
|
+
name: holder.name,
|
|
205
235
|
edgeUrl: readiness.edgeUrl,
|
|
206
236
|
},
|
|
207
237
|
},
|