@wtfalch/foundry 0.16.16 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/apply.d.ts +22 -1
- package/dist/bootstrap/bootstrap.d.ts +6 -1
- package/dist/bootstrap/bootstrap.js +26 -3
- package/dist/bootstrap/declare.d.ts +11 -1
- package/dist/bootstrap/declare.js +18 -6
- package/dist/index.d.ts +11 -6
- package/dist/index.js +8 -1
- package/dist/migrations/0038_calls_version_pin.sql +8 -0
- package/dist/migrations/0039_destroy_record.sql +7 -0
- package/dist/migrations/0040_company_included_uuid.sql +72 -0
- package/dist/migrations/0041_company_included_uuid_keys.sql +132 -0
- package/dist/migrations/0042_mutation_leases.sql +12 -0
- package/dist/migrations/0043_platform_names.sql +25 -0
- package/dist/migrations/0044_platform_mail_lettermint_project.sql +11 -0
- package/dist/org.d.ts +128 -3
- package/dist/org.js +186 -1
- package/dist/plan/adopt.d.ts +76 -0
- package/dist/plan/adopt.js +184 -0
- package/dist/plan/agora.d.ts +21 -0
- package/dist/plan/agora.js +37 -0
- package/dist/plan/broker-entries.d.ts +9 -1
- package/dist/plan/broker-entries.js +13 -5
- package/dist/plan/chat-matrix.d.ts +1 -2
- package/dist/plan/chat-matrix.js +15 -4
- package/dist/plan/core-files.d.ts +25 -0
- package/dist/plan/core-files.js +25 -0
- package/dist/plan/diff.d.ts +27 -3
- package/dist/plan/diff.js +264 -86
- package/dist/plan/edge-worker.d.ts +114 -0
- package/dist/plan/edge-worker.js +201 -0
- package/dist/plan/entry-ai-key.d.ts +3 -3
- package/dist/plan/entry-files-key.d.ts +3 -3
- package/dist/plan/entry-names-key.d.ts +73 -0
- package/dist/plan/entry-names-key.js +236 -0
- package/dist/plan/executor.d.ts +292 -24
- package/dist/plan/executor.js +745 -77
- package/dist/plan/index.d.ts +6 -2
- package/dist/plan/index.js +3 -1
- package/dist/plan/live.d.ts +9 -2
- package/dist/plan/login-host.d.ts +13 -2
- package/dist/plan/login-host.js +26 -7
- package/dist/plan/plan-apply.d.ts +153 -6
- package/dist/plan/plan-apply.js +639 -78
- package/dist/plan/platform-diff.d.ts +106 -1
- package/dist/plan/platform-diff.js +182 -2
- package/dist/plan/platform-org.d.ts +45 -1
- package/dist/plan/platform-org.js +34 -3
- package/dist/plan/platform-providers.d.ts +45 -1
- package/dist/plan/platform-providers.js +585 -16
- package/dist/plan/providers-readers.d.ts +2 -0
- package/dist/plan/providers-readers.js +4 -0
- package/dist/plan/rollout.d.ts +52 -0
- package/dist/plan/rollout.js +37 -0
- package/dist/plan/step-system.d.ts +22 -0
- package/dist/plan/step-system.js +38 -0
- package/dist/plan.d.ts +3 -0
- package/dist/platform.d.ts +22 -3
- package/dist/platform.js +6 -0
- package/dist/state.d.ts +13 -0
- package/dist/store/audit.js +15 -1
- package/dist/store/postgres.d.ts +1 -1
- package/dist/store/postgres.js +142 -15
- package/dist/store/schema.d.ts +202 -5
- package/dist/store/schema.js +18 -1
- package/dist/store.d.ts +47 -4
- package/dist/store.js +9 -1
- package/package.json +1 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { DeclaredOrg, EdgeConfig, HostingEntry } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
/**
|
|
4
|
+
* F8 (#318): one plain Cloudflare Worker per hosting entry that declares
|
|
5
|
+
* `edge`, 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, `edgeAppliedScopeKey`):
|
|
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 entryEdgeAppliedScopeKey: (entryKey: string) => string;
|
|
19
|
+
export declare const entryEdgeRouteIdScopeKey: (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 entryEdgeRoutePatternScopeKey: (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
|
+
* `entryNamesRetiredScopeKey` already give their own entry keys.
|
|
31
|
+
*/
|
|
32
|
+
export declare const entryEdgeRetiredScopeKey: (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 entryEdgeUploadedScopeKey: (entryKey: string) => string;
|
|
35
|
+
/** The zone the recorded route id lives in, as {@link EdgeRouteZone} JSON -- so retire and destroy delete it on *that* zone even after `org.domain` changes or goes null. */
|
|
36
|
+
export declare const entryEdgeRouteZoneScopeKey: (entryKey: string) => string;
|
|
37
|
+
/** A route about to be created, as {@link EdgeRoutePending} 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 entryEdgeRoutePendingScopeKey: (entryKey: string) => string;
|
|
39
|
+
export interface EdgeRouteZone {
|
|
40
|
+
id: string;
|
|
41
|
+
apex: string;
|
|
42
|
+
}
|
|
43
|
+
export interface EdgeRoutePending extends EdgeRouteZone {
|
|
44
|
+
pattern: string;
|
|
45
|
+
script: string;
|
|
46
|
+
}
|
|
47
|
+
/** 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 edgeWorkerTraceScopeKeys: (entryKey: string) => string[];
|
|
49
|
+
/**
|
|
50
|
+
* Every entry key of `orgId` with something foundry deployed still live,
|
|
51
|
+
* mapped to the script name recorded for it (`''` if only a route trace is
|
|
52
|
+
* left). Read by the planner's gone-scan and by org destroy alike, so the two
|
|
53
|
+
* agree on what is live: the main `cloudflare:worker:<org>:<entry>` id
|
|
54
|
+
* (unless the retired marker matches it), or any partial-deploy trace -- the
|
|
55
|
+
* uploaded name, a recorded route id, or a pending route -- regardless of
|
|
56
|
+
* the retired marker, since an apply clears that marker before it leaves any
|
|
57
|
+
* of these (#318 re-review).
|
|
58
|
+
*/
|
|
59
|
+
export declare function liveEdgeWorkers(orgId: string, recordedIds: Readonly<Record<string, string>>): Map<string, string>;
|
|
60
|
+
/** The first 8 hex characters of org.id's own sha256 -- see {@link edgeWorkerName}'s doc comment. */
|
|
61
|
+
export declare const EDGE_WORKER_ORG_HASH_LENGTH = 8;
|
|
62
|
+
/** Cloudflare's own limit on a Worker script name. */
|
|
63
|
+
export declare const EDGE_WORKER_NAME_MAX_LENGTH = 63;
|
|
64
|
+
/**
|
|
65
|
+
* `${8-hex-char hash of org.id}-${entry.key}-edge`, not `${org.slug}-...`
|
|
66
|
+
* (#318 re-review): a slug and an entry key both allow hyphens, so org
|
|
67
|
+
* `a-b` entry `c` and org `a` entry `b-c` produced the identical name
|
|
68
|
+
* `a-b-c-edge` on the one shared Cloudflare account -- `scriptExists` only
|
|
69
|
+
* caught the second of the two to apply, and two concurrent applies could
|
|
70
|
+
* both pass it. `org.id` is a UUID with no ambiguity of its own, but using
|
|
71
|
+
* it in full would make the fixed part of the name longer than most of the
|
|
72
|
+
* 63-character budget this port's `EDGE_WORKER_NAME_MAX_LENGTH` gives it,
|
|
73
|
+
* so only a short hash of it is used -- `validateEntryEdge` (`org.ts`)
|
|
74
|
+
* checks the resulting name never exceeds that limit.
|
|
75
|
+
*/
|
|
76
|
+
export declare const edgeWorkerName: (org: DeclaredOrg, entry: HostingEntry) => string;
|
|
77
|
+
/**
|
|
78
|
+
* 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 EdgeWorkerWant}
|
|
80
|
+
* and never triggers a re-plan on its own; bumping it is a foundry release,
|
|
81
|
+
* like {@link RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS} is a fixed Cloudflare
|
|
82
|
+
* Free plan value rather than something an org declares.
|
|
83
|
+
*/
|
|
84
|
+
export declare const EDGE_WORKER_COMPATIBILITY_DATE = "2026-09-12";
|
|
85
|
+
export type EdgeWorkerMode = 'apply' | 'retire';
|
|
86
|
+
export interface EdgeWorkerWant {
|
|
87
|
+
name: string;
|
|
88
|
+
artifactUrl: string;
|
|
89
|
+
artifactSha256: string;
|
|
90
|
+
route: string;
|
|
91
|
+
bindings: EdgeConfig['bindings'];
|
|
92
|
+
}
|
|
93
|
+
export declare function wantedEdgeWorker(org: DeclaredOrg, entry: HostingEntry): EdgeWorkerWant;
|
|
94
|
+
type Blocked = Array<{
|
|
95
|
+
description: string;
|
|
96
|
+
key: string;
|
|
97
|
+
}>;
|
|
98
|
+
/**
|
|
99
|
+
* One step per entry whose `edge` config is new or changed, plus one retire
|
|
100
|
+
* step per entry whose Worker is recorded but no longer declared -- either
|
|
101
|
+
* because the entry dropped `edge` or because the entry itself is gone, the
|
|
102
|
+
* same "gone" scan `planEntryAiKeys`/`planFilesKeys`/`planEntryNamesKeys`
|
|
103
|
+
* run over their own recorded keys, so removing a whole entry still cleans
|
|
104
|
+
* up its Worker. `workersAvailable` is the host's own
|
|
105
|
+
* `FoundryPlanApplyOptions.edgeWorkers` (mirrors `jobsAvailable`): without
|
|
106
|
+
* it, every entry's step -- apply or retire -- is reported blocked rather
|
|
107
|
+
* than planned to fail at apply. The caller (`diff.ts`) gates the *apply*
|
|
108
|
+
* steps further, on an active zone, the same way it gates `planRateLimit`.
|
|
109
|
+
*/
|
|
110
|
+
export declare function planEdgeWorkers(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, workersAvailable: boolean): {
|
|
111
|
+
steps: DraftStep[];
|
|
112
|
+
blocked: Blocked;
|
|
113
|
+
};
|
|
114
|
+
export {};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { appStepKey } from './platform-diff.js';
|
|
3
|
+
/**
|
|
4
|
+
* F8 (#318): one plain Cloudflare Worker per hosting entry that declares
|
|
5
|
+
* `edge`, 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, `edgeAppliedScopeKey`):
|
|
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 const entryEdgeAppliedScopeKey = (entryKey) => `edge_worker_applied_${entryKey}`;
|
|
19
|
+
export const entryEdgeRouteIdScopeKey = (entryKey) => `edge_worker_route_id_${entryKey}`;
|
|
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 const entryEdgeRoutePatternScopeKey = (entryKey) => `edge_worker_route_pattern_${entryKey}`;
|
|
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
|
+
* `entryNamesRetiredScopeKey` already give their own entry keys.
|
|
31
|
+
*/
|
|
32
|
+
export const entryEdgeRetiredScopeKey = (entryKey) => `edge_worker_retired_${entryKey}`;
|
|
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 const entryEdgeUploadedScopeKey = (entryKey) => `edge_worker_uploaded_${entryKey}`;
|
|
35
|
+
/** The zone the recorded route id lives in, as {@link EdgeRouteZone} JSON -- so retire and destroy delete it on *that* zone even after `org.domain` changes or goes null. */
|
|
36
|
+
export const entryEdgeRouteZoneScopeKey = (entryKey) => `edge_worker_route_zone_${entryKey}`;
|
|
37
|
+
/** A route about to be created, as {@link EdgeRoutePending} 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 const entryEdgeRoutePendingScopeKey = (entryKey) => `edge_worker_route_pending_${entryKey}`;
|
|
39
|
+
/** 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 edgeWorkerTraceScopeKeys = (entryKey) => [
|
|
41
|
+
entryEdgeUploadedScopeKey(entryKey),
|
|
42
|
+
entryEdgeRouteIdScopeKey(entryKey),
|
|
43
|
+
entryEdgeRouteZoneScopeKey(entryKey),
|
|
44
|
+
entryEdgeRoutePatternScopeKey(entryKey),
|
|
45
|
+
entryEdgeRoutePendingScopeKey(entryKey),
|
|
46
|
+
entryEdgeRetiredScopeKey(entryKey),
|
|
47
|
+
];
|
|
48
|
+
/**
|
|
49
|
+
* Every entry key of `orgId` with something foundry deployed still live,
|
|
50
|
+
* mapped to the script name recorded for it (`''` if only a route trace is
|
|
51
|
+
* left). Read by the planner's gone-scan and by org destroy alike, so the two
|
|
52
|
+
* agree on what is live: the main `cloudflare:worker:<org>:<entry>` id
|
|
53
|
+
* (unless the retired marker matches it), or any partial-deploy trace -- the
|
|
54
|
+
* uploaded name, a recorded route id, or a pending route -- regardless of
|
|
55
|
+
* the retired marker, since an apply clears that marker before it leaves any
|
|
56
|
+
* of these (#318 re-review).
|
|
57
|
+
*/
|
|
58
|
+
export function liveEdgeWorkers(orgId, recordedIds) {
|
|
59
|
+
const mainPrefix = `cloudflare:worker:${orgId}:`;
|
|
60
|
+
const tracePrefixes = [
|
|
61
|
+
mainPrefix,
|
|
62
|
+
'edge_worker_uploaded_',
|
|
63
|
+
'edge_worker_route_id_',
|
|
64
|
+
'edge_worker_route_pending_',
|
|
65
|
+
];
|
|
66
|
+
const entryKeys = new Set();
|
|
67
|
+
for (const [name, id] of Object.entries(recordedIds)) {
|
|
68
|
+
if (!id)
|
|
69
|
+
continue;
|
|
70
|
+
const prefix = tracePrefixes.find((p) => name.startsWith(p));
|
|
71
|
+
if (prefix)
|
|
72
|
+
entryKeys.add(name.slice(prefix.length));
|
|
73
|
+
}
|
|
74
|
+
const live = new Map();
|
|
75
|
+
for (const entryKey of entryKeys) {
|
|
76
|
+
const mainId = recordedIds[`${mainPrefix}${entryKey}`] ?? '';
|
|
77
|
+
const uploaded = recordedIds[entryEdgeUploadedScopeKey(entryKey)] ?? '';
|
|
78
|
+
const partial = uploaded ||
|
|
79
|
+
recordedIds[entryEdgeRouteIdScopeKey(entryKey)] ||
|
|
80
|
+
recordedIds[entryEdgeRoutePendingScopeKey(entryKey)];
|
|
81
|
+
const retired = mainId !== '' && recordedIds[entryEdgeRetiredScopeKey(entryKey)] === mainId;
|
|
82
|
+
if (!partial && (!mainId || retired))
|
|
83
|
+
continue;
|
|
84
|
+
live.set(entryKey, mainId || uploaded);
|
|
85
|
+
}
|
|
86
|
+
return live;
|
|
87
|
+
}
|
|
88
|
+
/** The first 8 hex characters of org.id's own sha256 -- see {@link edgeWorkerName}'s doc comment. */
|
|
89
|
+
export const EDGE_WORKER_ORG_HASH_LENGTH = 8;
|
|
90
|
+
/** Cloudflare's own limit on a Worker script name. */
|
|
91
|
+
export const EDGE_WORKER_NAME_MAX_LENGTH = 63;
|
|
92
|
+
/**
|
|
93
|
+
* `${8-hex-char hash of org.id}-${entry.key}-edge`, not `${org.slug}-...`
|
|
94
|
+
* (#318 re-review): a slug and an entry key both allow hyphens, so org
|
|
95
|
+
* `a-b` entry `c` and org `a` entry `b-c` produced the identical name
|
|
96
|
+
* `a-b-c-edge` on the one shared Cloudflare account -- `scriptExists` only
|
|
97
|
+
* caught the second of the two to apply, and two concurrent applies could
|
|
98
|
+
* both pass it. `org.id` is a UUID with no ambiguity of its own, but using
|
|
99
|
+
* it in full would make the fixed part of the name longer than most of the
|
|
100
|
+
* 63-character budget this port's `EDGE_WORKER_NAME_MAX_LENGTH` gives it,
|
|
101
|
+
* so only a short hash of it is used -- `validateEntryEdge` (`org.ts`)
|
|
102
|
+
* checks the resulting name never exceeds that limit.
|
|
103
|
+
*/
|
|
104
|
+
export const edgeWorkerName = (org, entry) => `${createHash('sha256').update(org.id).digest('hex').slice(0, EDGE_WORKER_ORG_HASH_LENGTH)}-${entry.key}-edge`;
|
|
105
|
+
/**
|
|
106
|
+
* 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 EdgeWorkerWant}
|
|
108
|
+
* and never triggers a re-plan on its own; bumping it is a foundry release,
|
|
109
|
+
* like {@link RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS} is a fixed Cloudflare
|
|
110
|
+
* Free plan value rather than something an org declares.
|
|
111
|
+
*/
|
|
112
|
+
export const EDGE_WORKER_COMPATIBILITY_DATE = '2026-09-12';
|
|
113
|
+
export function wantedEdgeWorker(org, entry) {
|
|
114
|
+
const edge = entry.edge;
|
|
115
|
+
return {
|
|
116
|
+
name: edgeWorkerName(org, entry),
|
|
117
|
+
artifactUrl: edge.artifactUrl,
|
|
118
|
+
artifactSha256: edge.artifactSha256,
|
|
119
|
+
route: edge.route,
|
|
120
|
+
bindings: edge.bindings,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* One step per entry whose `edge` config is new or changed, plus one retire
|
|
125
|
+
* step per entry whose Worker is recorded but no longer declared -- either
|
|
126
|
+
* because the entry dropped `edge` or because the entry itself is gone, the
|
|
127
|
+
* same "gone" scan `planEntryAiKeys`/`planFilesKeys`/`planEntryNamesKeys`
|
|
128
|
+
* run over their own recorded keys, so removing a whole entry still cleans
|
|
129
|
+
* up its Worker. `workersAvailable` is the host's own
|
|
130
|
+
* `FoundryPlanApplyOptions.edgeWorkers` (mirrors `jobsAvailable`): without
|
|
131
|
+
* it, every entry's step -- apply or retire -- is reported blocked rather
|
|
132
|
+
* than planned to fail at apply. The caller (`diff.ts`) gates the *apply*
|
|
133
|
+
* steps further, on an active zone, the same way it gates `planRateLimit`.
|
|
134
|
+
*/
|
|
135
|
+
export function planEdgeWorkers(org, recordedIds, workersAvailable) {
|
|
136
|
+
const steps = [];
|
|
137
|
+
const blocked = [];
|
|
138
|
+
// Hosting off declares no Worker at all: the gone-scan below still runs,
|
|
139
|
+
// so a Worker deployed while hosting was on is retired rather than left
|
|
140
|
+
// 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.edge).map((e) => e.key) : []);
|
|
142
|
+
const mainPrefix = `cloudflare:worker:${org.id}:`;
|
|
143
|
+
// Every entryKey with a live, undeclared trace (`liveEdgeWorkers`): the
|
|
144
|
+
// main id or any partial-deploy marker, so a deploy that failed after
|
|
145
|
+
// upload or mid-route-create is still found to retire (#318 review).
|
|
146
|
+
for (const entryKey of liveEdgeWorkers(org.id, recordedIds).keys()) {
|
|
147
|
+
if (declared.has(entryKey))
|
|
148
|
+
continue;
|
|
149
|
+
const stepKey = `${org.id}:${entryKey}`;
|
|
150
|
+
if (!workersAvailable) {
|
|
151
|
+
blocked.push({
|
|
152
|
+
description: `${org.slug}'s ${entryKey} edge Worker is blocked, not planned: the host supplies no WorkersPort`,
|
|
153
|
+
key: `${mainPrefix}${entryKey}`,
|
|
154
|
+
});
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
steps.push({
|
|
158
|
+
action: { system: 'cloudflare', kind: 'worker', key: stepKey, desired: { mode: 'retire' } },
|
|
159
|
+
step: {
|
|
160
|
+
description: `delete ${org.slug}'s ${entryKey} edge Worker and its route: edge is no longer declared`,
|
|
161
|
+
destroys: true,
|
|
162
|
+
migrates: false,
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (!org.hosting.enabled)
|
|
167
|
+
return { steps, blocked };
|
|
168
|
+
for (const entry of org.hosting.entries) {
|
|
169
|
+
if (!entry.edge)
|
|
170
|
+
continue;
|
|
171
|
+
const stepKey = appStepKey(org, entry.key);
|
|
172
|
+
const key = `cloudflare:worker:${stepKey}`;
|
|
173
|
+
const at = `${org.slug}'s ${entry.key} edge Worker`;
|
|
174
|
+
if (!workersAvailable) {
|
|
175
|
+
blocked.push({
|
|
176
|
+
description: `${at} is blocked, not planned: the host supplies no WorkersPort`,
|
|
177
|
+
key,
|
|
178
|
+
});
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const want = wantedEdgeWorker(org, entry);
|
|
182
|
+
const wantJson = JSON.stringify(want);
|
|
183
|
+
const applied = recordedIds[entryEdgeAppliedScopeKey(entry.key)];
|
|
184
|
+
if (applied === wantJson)
|
|
185
|
+
continue; // Rerun: nothing changed, down to the built artifact's own digest.
|
|
186
|
+
steps.push({
|
|
187
|
+
action: {
|
|
188
|
+
system: 'cloudflare',
|
|
189
|
+
kind: 'worker',
|
|
190
|
+
key: stepKey,
|
|
191
|
+
desired: { mode: 'apply', compatibilityDate: EDGE_WORKER_COMPATIBILITY_DATE, ...want },
|
|
192
|
+
},
|
|
193
|
+
step: {
|
|
194
|
+
description: `${applied ? 'update' : 'deploy'} ${at} on ${entry.edge.route}`,
|
|
195
|
+
destroys: false,
|
|
196
|
+
migrates: false,
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return { steps, blocked };
|
|
201
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type DeclaredOrg, type HostingEntry } from '../org.js';
|
|
2
|
-
import type {
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
3
|
import type { OrgPlatformReadiness } from './platform-org.js';
|
|
4
4
|
/**
|
|
5
5
|
* F6a: one ai key per entry with `uses.ai`, minted below the org's own ai key
|
|
@@ -49,8 +49,8 @@ export type EntryAiMode = 'mint' | 'renew' | 'settings' | 'finish' | 'retire';
|
|
|
49
49
|
* entry's app or the ai address is not recorded, or after a mint whose
|
|
50
50
|
* delivery failed: a second mint would leave two live keys.
|
|
51
51
|
*/
|
|
52
|
-
export declare function planEntryAiKeys(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly
|
|
53
|
-
steps:
|
|
52
|
+
export declare function planEntryAiKeys(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], readiness: OrgPlatformReadiness, now: number): {
|
|
53
|
+
steps: DraftStep[];
|
|
54
54
|
blocked: Array<{
|
|
55
55
|
description: string;
|
|
56
56
|
key: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type DeclaredOrg, type HostingEntry } from '../org.js';
|
|
2
|
-
import type {
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
3
|
import type { OrgPlatformReadiness } from './platform-org.js';
|
|
4
4
|
/**
|
|
5
5
|
* F6b: the org's own files key, minted below the files organisation's
|
|
@@ -77,8 +77,8 @@ type Blocked = Array<{
|
|
|
77
77
|
* it by its own `renew` step, and only then does `revoke-previous` revoke the
|
|
78
78
|
* old org key, which revokes everything still below it.
|
|
79
79
|
*/
|
|
80
|
-
export declare function planFilesKeys(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly
|
|
81
|
-
steps:
|
|
80
|
+
export declare function planFilesKeys(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], readiness: OrgPlatformReadiness, now: number): {
|
|
81
|
+
steps: DraftStep[];
|
|
82
82
|
blocked: Blocked;
|
|
83
83
|
};
|
|
84
84
|
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { DeclaredOrg, HostingEntry } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
import type { OrgPlatformReadiness } from './platform-org.js';
|
|
4
|
+
/**
|
|
5
|
+
* F7 (#317): one names key per entry with `uses.names`, minted directly
|
|
6
|
+
* below the org with the names service (`NamesPort.mintKey({
|
|
7
|
+
* organisationId, ... })` -- no org-level root key the way files/ai have
|
|
8
|
+
* one, since the names service takes the org id itself). `NAMES_URL` and
|
|
9
|
+
* `NAMES_API_KEY` go to the entry's env and nowhere else. A names key has no
|
|
10
|
+
* declared renewal window in the issue, so unlike `planEntryAiKeys`/
|
|
11
|
+
* `planFilesKeys` this mints once and never renews.
|
|
12
|
+
*
|
|
13
|
+
* `NAMES_*` is written in this step, after the app's own `coolify:app`
|
|
14
|
+
* create (which never knows the key yet), so the app's first boot has
|
|
15
|
+
* neither and its first deploy restarts once this step's env write lands --
|
|
16
|
+
* the same one-extra-restart shape `planEntryAiKeys`'s `AI_*`/
|
|
17
|
+
* `planFilesKeys`'s `FILES_*` already have, not a names-specific gap.
|
|
18
|
+
*
|
|
19
|
+
* A domain change after mint is not detected (#317 re-review): the key is
|
|
20
|
+
* bound to the domain passed at mint time, but nothing here records that
|
|
21
|
+
* domain to compare against a later `org.domain`, so an org that changes
|
|
22
|
+
* domains keeps a names key still bound to the old one. Not built --
|
|
23
|
+
* resolving it needs a manual revoke-and-re-mint (drop `uses.names`, apply,
|
|
24
|
+
* re-add it) until this is.
|
|
25
|
+
*/
|
|
26
|
+
export declare const entryNamesUndeliveredScopeKey: (entryKey: string) => string;
|
|
27
|
+
/** The id of a key that was live when its entry stopped declaring `uses.names`, once handled. */
|
|
28
|
+
export declare const entryNamesRetiredScopeKey: (entryKey: string) => string;
|
|
29
|
+
export declare const ENTRY_NAMES_KEY_DELIVERY_ATTEMPTS = 3;
|
|
30
|
+
/** How long an entry's names key lives. The issue names no renewal window, so this mints once and never renews. */
|
|
31
|
+
export declare const NAMES_ENTRY_KEY_DAYS = 365;
|
|
32
|
+
/** The ISO time a delivered key's mint succeeded -- the only trace of its age, since there is no renewal to track against. */
|
|
33
|
+
export declare const entryNamesMintedAtScopeKey: (entryKey: string) => string;
|
|
34
|
+
/** A key with fewer days left than this is surfaced as a blocked warning row (#317 review): still live, but nothing here renews it. */
|
|
35
|
+
export declare const NAMES_ENTRY_KEY_WARN_WINDOW_DAYS = 30;
|
|
36
|
+
/** The reserved labels last sent to the names service, as that sorted list's own JSON. */
|
|
37
|
+
export declare const NAMES_RESERVED_LABELS_APPLIED_SCOPE_KEY = "names_reserved_labels_applied";
|
|
38
|
+
export declare const entryNamesKeyName: (org: DeclaredOrg, entry: HostingEntry) => string;
|
|
39
|
+
/**
|
|
40
|
+
* Every label a declared entry serves (`@`/`*` reserve nothing of their own:
|
|
41
|
+
* the bare domain and the wildcard are not names a customer could claim
|
|
42
|
+
* under names anyway), plus `www`, which `setReservedLabels`'s own doc
|
|
43
|
+
* comment in `executor.ts` explains is reserved unconditionally.
|
|
44
|
+
*/
|
|
45
|
+
export declare function namesReservedLabels(org: DeclaredOrg): string[];
|
|
46
|
+
type Blocked = Array<{
|
|
47
|
+
description: string;
|
|
48
|
+
key: string;
|
|
49
|
+
}>;
|
|
50
|
+
/**
|
|
51
|
+
* One step per entry whose names key is missing, plus one retire step per
|
|
52
|
+
* entry whose `uses.names` was removed but still has a recorded key.
|
|
53
|
+
* Blocked, not planned (rule 2), while the platform has no names service
|
|
54
|
+
* wired, the entry's app is not recorded, or a previous mint's delivery
|
|
55
|
+
* failed.
|
|
56
|
+
*/
|
|
57
|
+
export declare function planEntryNamesKeys(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], readiness: OrgPlatformReadiness, now?: number): {
|
|
58
|
+
steps: DraftStep[];
|
|
59
|
+
blocked: Blocked;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* The reserved-labels step: resent whenever the org's own label set
|
|
63
|
+
* (`namesReservedLabels`) differs from what was last sent. While the org
|
|
64
|
+
* uses names at all (at least one entry with `uses.names`) that set is
|
|
65
|
+
* `namesReservedLabels(org)`; once the last entry drops `uses.names`, this
|
|
66
|
+
* clears whatever was last sent (an empty list) rather than leaving it
|
|
67
|
+
* stuck in names forever -- a one-time step, same as any other change.
|
|
68
|
+
*/
|
|
69
|
+
export declare function planNamesReservedLabels(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, readiness: OrgPlatformReadiness): {
|
|
70
|
+
step: DraftStep | null;
|
|
71
|
+
blocked: Blocked;
|
|
72
|
+
};
|
|
73
|
+
export {};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { appStepKey } from './platform-diff.js';
|
|
2
|
+
/**
|
|
3
|
+
* F7 (#317): one names key per entry with `uses.names`, minted directly
|
|
4
|
+
* below the org with the names service (`NamesPort.mintKey({
|
|
5
|
+
* organisationId, ... })` -- no org-level root key the way files/ai have
|
|
6
|
+
* one, since the names service takes the org id itself). `NAMES_URL` and
|
|
7
|
+
* `NAMES_API_KEY` go to the entry's env and nowhere else. A names key has no
|
|
8
|
+
* declared renewal window in the issue, so unlike `planEntryAiKeys`/
|
|
9
|
+
* `planFilesKeys` this mints once and never renews.
|
|
10
|
+
*
|
|
11
|
+
* `NAMES_*` is written in this step, after the app's own `coolify:app`
|
|
12
|
+
* create (which never knows the key yet), so the app's first boot has
|
|
13
|
+
* neither and its first deploy restarts once this step's env write lands --
|
|
14
|
+
* the same one-extra-restart shape `planEntryAiKeys`'s `AI_*`/
|
|
15
|
+
* `planFilesKeys`'s `FILES_*` already have, not a names-specific gap.
|
|
16
|
+
*
|
|
17
|
+
* A domain change after mint is not detected (#317 re-review): the key is
|
|
18
|
+
* bound to the domain passed at mint time, but nothing here records that
|
|
19
|
+
* domain to compare against a later `org.domain`, so an org that changes
|
|
20
|
+
* domains keeps a names key still bound to the old one. Not built --
|
|
21
|
+
* resolving it needs a manual revoke-and-re-mint (drop `uses.names`, apply,
|
|
22
|
+
* re-add it) until this is.
|
|
23
|
+
*/
|
|
24
|
+
export const entryNamesUndeliveredScopeKey = (entryKey) => `names_key_undelivered_${entryKey}`;
|
|
25
|
+
/** The id of a key that was live when its entry stopped declaring `uses.names`, once handled. */
|
|
26
|
+
export const entryNamesRetiredScopeKey = (entryKey) => `names_key_retired_${entryKey}`;
|
|
27
|
+
export const ENTRY_NAMES_KEY_DELIVERY_ATTEMPTS = 3;
|
|
28
|
+
/** How long an entry's names key lives. The issue names no renewal window, so this mints once and never renews. */
|
|
29
|
+
export const NAMES_ENTRY_KEY_DAYS = 365;
|
|
30
|
+
/** The ISO time a delivered key's mint succeeded -- the only trace of its age, since there is no renewal to track against. */
|
|
31
|
+
export const entryNamesMintedAtScopeKey = (entryKey) => `names_key_minted_at_${entryKey}`;
|
|
32
|
+
/** A key with fewer days left than this is surfaced as a blocked warning row (#317 review): still live, but nothing here renews it. */
|
|
33
|
+
export const NAMES_ENTRY_KEY_WARN_WINDOW_DAYS = 30;
|
|
34
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
35
|
+
/** The reserved labels last sent to the names service, as that sorted list's own JSON. */
|
|
36
|
+
export const NAMES_RESERVED_LABELS_APPLIED_SCOPE_KEY = 'names_reserved_labels_applied';
|
|
37
|
+
export const entryNamesKeyName = (org, entry) => `${org.slug}-${entry.key}-names`;
|
|
38
|
+
/**
|
|
39
|
+
* Every label a declared entry serves (`@`/`*` reserve nothing of their own:
|
|
40
|
+
* the bare domain and the wildcard are not names a customer could claim
|
|
41
|
+
* under names anyway), plus `www`, which `setReservedLabels`'s own doc
|
|
42
|
+
* comment in `executor.ts` explains is reserved unconditionally.
|
|
43
|
+
*/
|
|
44
|
+
export function namesReservedLabels(org) {
|
|
45
|
+
const labels = new Set(['www']);
|
|
46
|
+
for (const entry of org.hosting.entries) {
|
|
47
|
+
for (const name of entry.hostnames) {
|
|
48
|
+
if (name !== '@' && name !== '*')
|
|
49
|
+
labels.add(name);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return [...labels].sort();
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* One step per entry whose names key is missing, plus one retire step per
|
|
56
|
+
* entry whose `uses.names` was removed but still has a recorded key.
|
|
57
|
+
* Blocked, not planned (rule 2), while the platform has no names service
|
|
58
|
+
* wired, the entry's app is not recorded, or a previous mint's delivery
|
|
59
|
+
* failed.
|
|
60
|
+
*/
|
|
61
|
+
export function planEntryNamesKeys(org, recordedIds, plannedSteps, readiness, now = Date.now()) {
|
|
62
|
+
const steps = [];
|
|
63
|
+
const blocked = [];
|
|
64
|
+
const planned = (system, kind, k) => plannedSteps.some((p) => p.action.system === system && p.action.kind === kind && p.action.key === k);
|
|
65
|
+
// A key whose entry lost `uses.names`, or whose entry is gone entirely --
|
|
66
|
+
// either way it is no longer in `declared`, the same "gone" scan
|
|
67
|
+
// `planEntryAiKeys`/`planFilesKeys` run over their own recorded keys.
|
|
68
|
+
// Gated on `namesReady`: without a port there is nothing that could revoke
|
|
69
|
+
// it, so it is reported blocked, not planned to a step that would throw.
|
|
70
|
+
//
|
|
71
|
+
// Scanned from both the main recorded id and the undelivered marker: a
|
|
72
|
+
// key minted but never delivered never gets a main id (the mint step
|
|
73
|
+
// throws before `runSteps` sees a `resourceId`), so scanning the main
|
|
74
|
+
// prefix alone would never find it to revoke once its entry is gone
|
|
75
|
+
// (#317 review).
|
|
76
|
+
const declared = new Set(org.hosting.entries.filter((e) => e.uses?.names).map((e) => e.key));
|
|
77
|
+
const mainPrefix = `names:key:${org.id}:`;
|
|
78
|
+
const undeliveredPrefix = 'names_key_undelivered_';
|
|
79
|
+
const goneEntryKeys = new Set();
|
|
80
|
+
for (const [name, id] of Object.entries(recordedIds)) {
|
|
81
|
+
if (!id)
|
|
82
|
+
continue;
|
|
83
|
+
let entryKey;
|
|
84
|
+
if (name.startsWith(mainPrefix))
|
|
85
|
+
entryKey = name.slice(mainPrefix.length);
|
|
86
|
+
else if (name.startsWith(undeliveredPrefix))
|
|
87
|
+
entryKey = name.slice(undeliveredPrefix.length);
|
|
88
|
+
if (entryKey === undefined || declared.has(entryKey))
|
|
89
|
+
continue;
|
|
90
|
+
// Only the main id is subject to the retired-marker convergence check
|
|
91
|
+
// -- it's the one `runSteps` never clears on its own. An undelivered
|
|
92
|
+
// id is cleared directly on retire (no marker needed), so its mere
|
|
93
|
+
// presence here means it still needs revoking, regardless of what a
|
|
94
|
+
// stale, already-retired main id says: a key minted after a retire
|
|
95
|
+
// (mint, three failed deliveries, undeclare again) must not hide
|
|
96
|
+
// behind id A's retired marker just because id A's main-id field is
|
|
97
|
+
// still truthy (#317 re-review).
|
|
98
|
+
if (name.startsWith(mainPrefix) && recordedIds[entryNamesRetiredScopeKey(entryKey)] === id) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
goneEntryKeys.add(entryKey);
|
|
102
|
+
}
|
|
103
|
+
for (const entryKey of goneEntryKeys) {
|
|
104
|
+
if (!readiness.namesReady) {
|
|
105
|
+
blocked.push({
|
|
106
|
+
description: `${org.slug}'s ${entryKey} names key is blocked, not planned: the host supplies no names service, or the platform has not enabled it`,
|
|
107
|
+
key: `${mainPrefix}${entryKey}`,
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
steps.push({
|
|
112
|
+
action: {
|
|
113
|
+
system: 'names',
|
|
114
|
+
kind: 'key',
|
|
115
|
+
key: `${org.id}:${entryKey}`,
|
|
116
|
+
desired: { mode: 'retire' },
|
|
117
|
+
},
|
|
118
|
+
step: {
|
|
119
|
+
description: `revoke ${org.slug}'s ${entryKey} names key: the entry no longer declares uses.names`,
|
|
120
|
+
destroys: true,
|
|
121
|
+
migrates: false,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
for (const entry of org.hosting.entries) {
|
|
126
|
+
if (!entry.uses?.names)
|
|
127
|
+
continue;
|
|
128
|
+
const stepKey = appStepKey(org, entry.key);
|
|
129
|
+
const key = `names:key:${stepKey}`;
|
|
130
|
+
const liveKeyId = recordedIds[key] && recordedIds[entryNamesRetiredScopeKey(entry.key)] !== recordedIds[key]
|
|
131
|
+
? recordedIds[key]
|
|
132
|
+
: undefined;
|
|
133
|
+
if (liveKeyId) {
|
|
134
|
+
// No renewal: minted once. Still surfaced once it is close to or past
|
|
135
|
+
// its silent expiry -- there is no step to plan, since nothing here
|
|
136
|
+
// renews it, but a plan that says nothing about a key about to stop
|
|
137
|
+
// working is the gap this is for (#317 review). Prefixed `warning:`,
|
|
138
|
+
// the same convention `planBrokerEntries`'s invalid-AUTH_APPS notice
|
|
139
|
+
// uses, so a caller reading this list for real blockers (one a human
|
|
140
|
+
// mint could ever clear) can tell the two apart -- a permanently
|
|
141
|
+
// "blocked" row past day 335 that no action removes is the problem
|
|
142
|
+
// this prefix exists to avoid.
|
|
143
|
+
const mintedAt = recordedIds[entryNamesMintedAtScopeKey(entry.key)];
|
|
144
|
+
if (mintedAt) {
|
|
145
|
+
const expiresAt = Date.parse(mintedAt) + NAMES_ENTRY_KEY_DAYS * DAY_MS;
|
|
146
|
+
const daysLeft = Math.floor((expiresAt - now) / DAY_MS);
|
|
147
|
+
if (daysLeft < NAMES_ENTRY_KEY_WARN_WINDOW_DAYS) {
|
|
148
|
+
blocked.push({
|
|
149
|
+
description: `warning: ${org.slug}'s ${entry.key} names key ${daysLeft < 0 ? 'expired' : 'expires'} ${new Date(expiresAt).toISOString().slice(0, 10)} and is never renewed (#317): mint a new key by hand and update the entry's env`,
|
|
150
|
+
key,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const at = `${org.slug}'s ${entry.key} names key`;
|
|
157
|
+
const block = (why) => blocked.push({ description: `${at} is blocked: ${why}`, key });
|
|
158
|
+
// An undelivered key from a previous mint is no longer a reason to
|
|
159
|
+
// block forever: nothing here keeps the secret around to retry
|
|
160
|
+
// delivering it, so a mint step is still planned, and the executor
|
|
161
|
+
// itself revokes the stale id first before minting a fresh one (#317
|
|
162
|
+
// re-review) -- blocking indefinitely, as a prior round did, left the
|
|
163
|
+
// orphan live for up to 365 days with no recorded trace an operator
|
|
164
|
+
// could act on beyond this description.
|
|
165
|
+
if (!readiness.namesReady) {
|
|
166
|
+
block('the host supplies no names service, or the platform has not enabled it');
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (!recordedIds[`coolify:app:${stepKey}`] && !planned('coolify', 'app', stepKey)) {
|
|
170
|
+
block("the entry's app is not recorded yet");
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
steps.push({
|
|
174
|
+
action: {
|
|
175
|
+
system: 'names',
|
|
176
|
+
kind: 'key',
|
|
177
|
+
key: stepKey,
|
|
178
|
+
desired: {
|
|
179
|
+
mode: 'mint',
|
|
180
|
+
name: entryNamesKeyName(org, entry),
|
|
181
|
+
namesUrl: readiness.namesUrl,
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
step: {
|
|
185
|
+
description: `mint ${at} below the org, write NAMES_* env, restart the app`,
|
|
186
|
+
destroys: false,
|
|
187
|
+
migrates: false,
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return { steps, blocked };
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The reserved-labels step: resent whenever the org's own label set
|
|
195
|
+
* (`namesReservedLabels`) differs from what was last sent. While the org
|
|
196
|
+
* uses names at all (at least one entry with `uses.names`) that set is
|
|
197
|
+
* `namesReservedLabels(org)`; once the last entry drops `uses.names`, this
|
|
198
|
+
* clears whatever was last sent (an empty list) rather than leaving it
|
|
199
|
+
* stuck in names forever -- a one-time step, same as any other change.
|
|
200
|
+
*/
|
|
201
|
+
export function planNamesReservedLabels(org, recordedIds, readiness) {
|
|
202
|
+
const usesNames = org.hosting.entries.some((e) => e.uses?.names);
|
|
203
|
+
const applied = recordedIds[NAMES_RESERVED_LABELS_APPLIED_SCOPE_KEY];
|
|
204
|
+
if (!usesNames && (!applied || applied === '[]')) {
|
|
205
|
+
return { step: null, blocked: [] };
|
|
206
|
+
}
|
|
207
|
+
if (!readiness.namesReady) {
|
|
208
|
+
return {
|
|
209
|
+
step: null,
|
|
210
|
+
blocked: [
|
|
211
|
+
{
|
|
212
|
+
description: `${org.slug}'s reserved labels are blocked: the host supplies no names service, or the platform has not enabled it`,
|
|
213
|
+
key: `names:reserved-labels:${org.id}`,
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const labels = usesNames ? namesReservedLabels(org) : [];
|
|
219
|
+
const wantJson = JSON.stringify(labels);
|
|
220
|
+
if (applied === wantJson) {
|
|
221
|
+
return { step: null, blocked: [] };
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
step: {
|
|
225
|
+
action: { system: 'names', kind: 'reserved-labels', key: org.id, desired: { labels } },
|
|
226
|
+
step: {
|
|
227
|
+
description: usesNames
|
|
228
|
+
? `send ${org.slug}'s reserved labels to names: ${labels.join(', ')}`
|
|
229
|
+
: `clear ${org.slug}'s reserved labels from names: no entry uses names any more`,
|
|
230
|
+
destroys: false,
|
|
231
|
+
migrates: false,
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
blocked: [],
|
|
235
|
+
};
|
|
236
|
+
}
|