@wtfalch/foundry 0.76.1 → 0.77.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/app-canary-worker.d.ts +35 -0
- package/dist/app-canary-worker.js +56 -0
- package/dist/audit/connector.d.ts +6 -2
- package/dist/audit/connector.js +3 -2
- package/dist/audit/evidence.d.ts +4 -0
- package/dist/audit/evidence.js +14 -2
- package/dist/audit/host-store.d.ts +58 -0
- package/dist/audit/host-store.js +147 -0
- package/dist/audit/host.d.ts +53 -0
- package/dist/audit/host.js +294 -0
- package/dist/audit/index.d.ts +2 -0
- package/dist/audit/index.js +2 -0
- package/dist/audit/registry.d.ts +14 -0
- package/dist/audit/sealer-role.d.ts +12 -0
- package/dist/audit/sealer-role.js +26 -2
- package/dist/audit/tick.d.ts +10 -2
- package/dist/audit/tick.js +15 -2
- package/dist/catalogue/copy-en.js +5 -0
- package/dist/catalogue/integrations.js +5 -0
- package/dist/entry-rules.d.ts +35 -0
- package/dist/entry-rules.js +35 -0
- package/dist/hosted-label.js +2 -1
- package/dist/index.d.ts +11 -3
- package/dist/index.js +6 -1
- package/dist/migrations/0072_audit_host.sql +46 -0
- package/dist/migrations/0074_platform_app_canary.sql +11 -0
- package/dist/modules/ai.js +15 -1
- package/dist/modules/assistants.js +3 -0
- package/dist/modules/email.d.ts +6 -1
- package/dist/modules/email.js +88 -5
- package/dist/modules/files.js +79 -2
- package/dist/modules/index.js +7 -0
- package/dist/modules/jobs.d.ts +5 -1
- package/dist/modules/jobs.js +74 -4
- package/dist/modules/reporting.d.ts +9 -0
- package/dist/modules/reporting.js +29 -0
- package/dist/modules/sign-in.js +20 -2
- package/dist/modules/types.d.ts +2 -0
- package/dist/modules/workspaces.js +3 -0
- package/dist/org.d.ts +114 -13
- package/dist/org.js +233 -20
- package/dist/plan/ai-boule-key-apply.d.ts +65 -0
- package/dist/plan/ai-boule-key-apply.js +169 -0
- package/dist/plan/ai-boule-key.d.ts +89 -0
- package/dist/plan/ai-boule-key.js +195 -0
- package/dist/plan/analytics.d.ts +8 -1
- package/dist/plan/analytics.js +8 -1
- package/dist/plan/app-canary-apply.d.ts +49 -0
- package/dist/plan/app-canary-apply.js +232 -0
- package/dist/plan/app-canary.d.ts +44 -0
- package/dist/plan/app-canary.js +110 -0
- package/dist/plan/blue-green-apply.d.ts +6 -2
- package/dist/plan/blue-green-apply.js +14 -3
- package/dist/plan/broker-entries.js +20 -5
- package/dist/plan/company-backups.js +2 -0
- package/dist/plan/diff.d.ts +22 -0
- package/dist/plan/diff.js +231 -18
- package/dist/plan/entry-files-key.d.ts +39 -3
- package/dist/plan/entry-files-key.js +103 -6
- package/dist/plan/entry-jobs-boule-key-apply.d.ts +65 -0
- package/dist/plan/entry-jobs-boule-key-apply.js +192 -0
- package/dist/plan/entry-jobs-boule-key.d.ts +59 -0
- package/dist/plan/entry-jobs-boule-key.js +141 -0
- package/dist/plan/entry-jobs.d.ts +2 -0
- package/dist/plan/entry-reporting-key.d.ts +36 -0
- package/dist/plan/entry-reporting-key.js +136 -0
- package/dist/plan/executor.d.ts +108 -1
- package/dist/plan/executor.js +669 -23
- package/dist/plan/files-boule-key-apply.d.ts +73 -0
- package/dist/plan/files-boule-key-apply.js +223 -0
- package/dist/plan/files-boule-key.d.ts +92 -0
- package/dist/plan/files-boule-key.js +175 -0
- package/dist/plan/hostnames.js +4 -0
- package/dist/plan/index.d.ts +4 -0
- package/dist/plan/index.js +2 -0
- package/dist/plan/jobs-scheduler-http.d.ts +13 -0
- package/dist/plan/jobs-scheduler-http.js +39 -0
- package/dist/plan/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.d.ts +1 -1
- package/dist/plan/locked-hostnames.js +4 -1
- package/dist/plan/login-host.js +2 -2
- package/dist/plan/org-names.js +3 -0
- package/dist/plan/own-diff.js +3 -2
- package/dist/plan/own-jobs-route.d.ts +5 -2
- package/dist/plan/own-jobs-route.js +6 -2
- package/dist/plan/own-limits.d.ts +6 -0
- package/dist/plan/own-limits.js +1 -0
- package/dist/plan/plan-apply.d.ts +24 -0
- package/dist/plan/plan-apply.js +39 -4
- package/dist/plan/platform-diff.d.ts +2 -0
- package/dist/plan/platform-diff.js +15 -1
- package/dist/plan/platform-providers.d.ts +9 -0
- package/dist/plan/platform-providers.js +24 -0
- package/dist/plan/providers-readers.js +25 -1
- package/dist/plan/registered-app-env.d.ts +31 -0
- package/dist/plan/registered-app-env.js +63 -0
- package/dist/plan/registered-app.d.ts +121 -0
- package/dist/plan/registered-app.js +262 -0
- package/dist/plan/release-host.js +2 -1
- package/dist/plan/reporting-boule.d.ts +18 -0
- package/dist/plan/reporting-boule.js +44 -0
- package/dist/plan/reporting.d.ts +90 -0
- package/dist/plan/reporting.js +135 -0
- package/dist/plan/stage-env.js +1 -0
- package/dist/plan/stage-isolation.d.ts +3 -1
- package/dist/plan/stage-isolation.js +20 -2
- package/dist/plan/stage-services.d.ts +3 -1
- package/dist/plan/stage-services.js +8 -1
- package/dist/plan/stage-sink.d.ts +2 -2
- package/dist/plan/stage-sink.js +2 -2
- package/dist/plan/workspace-client.d.ts +1 -1
- package/dist/plan/workspace-client.js +2 -1
- package/dist/plan/workspace-sign-in.d.ts +1 -1
- package/dist/plan/workspace-sign-in.js +39 -7
- package/dist/platform.d.ts +25 -0
- package/dist/platform.js +20 -0
- package/dist/service-labels.d.ts +8 -0
- package/dist/service-labels.js +8 -0
- package/dist/slug.d.ts +1 -1
- package/dist/slug.js +5 -2
- package/dist/store/postgres.js +13 -0
- package/dist/store/schema.d.ts +485 -2
- package/dist/store/schema.js +38 -1
- package/dist/worker-app-config.js +2 -2
- package/package.json +1 -1
|
@@ -12,11 +12,17 @@ export const FILES_KEY_DAYS = 365;
|
|
|
12
12
|
/** A key with fewer days left than this is renewed. */
|
|
13
13
|
export const FILES_KEY_RENEW_WINDOW_DAYS = 30;
|
|
14
14
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* What the org's own key holds: it mints and revokes, and holds what its entries and Boule's key may hold
|
|
17
|
+
* (foundry#1121: `files.usage:read`, `files.shares:read` and `files.files:share` for Boule's Files page).
|
|
18
|
+
*/
|
|
16
19
|
export const FILES_ROOT_PERMISSIONS = [
|
|
17
20
|
'credentials:create',
|
|
18
21
|
'credentials:revoke',
|
|
19
22
|
...FILES_ENTRY_PERMISSIONS,
|
|
23
|
+
'files.usage:read',
|
|
24
|
+
'files.shares:read',
|
|
25
|
+
'files.files:share',
|
|
20
26
|
];
|
|
21
27
|
/**
|
|
22
28
|
* Flat scope ids (no `:`). The org's own key: the id minted last (it blocks
|
|
@@ -25,6 +31,12 @@ export const FILES_ROOT_PERMISSIONS = [
|
|
|
25
31
|
*/
|
|
26
32
|
export const FILES_ROOT_UNDELIVERED_SCOPE_KEY = 'files_key_undelivered';
|
|
27
33
|
export const FILES_ROOT_EXPIRES_SCOPE_KEY = 'files_key_expires';
|
|
34
|
+
/**
|
|
35
|
+
* foundry#1121: the permissions the recorded org key was minted with, as JSON. A key minted before this was
|
|
36
|
+
* recorded, or with fewer permissions than {@link FILES_ROOT_PERMISSIONS}, is renewed: a key below it can
|
|
37
|
+
* hold only what it holds.
|
|
38
|
+
*/
|
|
39
|
+
export const FILES_ROOT_PERMISSIONS_SCOPE_KEY = 'files_key_permissions';
|
|
28
40
|
/** Ids of org keys replaced by a renewal, as a JSON array, revoked once every entry holds a key below the new one. */
|
|
29
41
|
export const FILES_ROOT_PREVIOUS_SCOPE_KEY = 'files_key_previous';
|
|
30
42
|
/** Ids of replaced org keys whose revoke failed, as a JSON array. They expire on their own; the plan lists each. */
|
|
@@ -52,6 +64,72 @@ export const entryFilesRetiredScopeKey = (entryKey) => `files_entry_retired_${en
|
|
|
52
64
|
export const entryFilesUnrevokedScopeKey = (entryKey) => `files_entry_unrevoked_${entryKey}`;
|
|
53
65
|
export const ENTRY_FILES_UNREVOKED_PREFIX = 'files_entry_unrevoked_';
|
|
54
66
|
export const ENTRY_FILES_UNDELIVERED_PREFIX = 'files_entry_undelivered_';
|
|
67
|
+
/** foundry#1121 (`files-boule-key.ts`). Boule's key is minted, not held yet: its id, never the key. */
|
|
68
|
+
export const FILES_BOULE_UNDELIVERED_SCOPE_KEY = 'files_boule_key_undelivered';
|
|
69
|
+
/** The id of Boule's key once it is held. */
|
|
70
|
+
export const FILES_BOULE_KEY_SCOPE_KEY = 'files_boule_key';
|
|
71
|
+
/** The id of the org's own files key Boule's key was minted below. */
|
|
72
|
+
export const FILES_BOULE_MINTER_SCOPE_KEY = 'files_boule_key_minter';
|
|
73
|
+
/** Ids of Boule keys a renewal replaced, as a JSON array, revoked once the new one is in Boule's env. */
|
|
74
|
+
export const FILES_BOULE_PREVIOUS_SCOPE_KEY = 'files_boule_key_previous';
|
|
75
|
+
/** Per entry: label to collection name in files, every name the entry ever had. */
|
|
76
|
+
export const entryFilesCollectionsScopeKey = (entryKey) => `files_entry_collections_${entryKey}`;
|
|
77
|
+
/** The name a new collection of an entry gets in files. */
|
|
78
|
+
export const entryCollectionName = (entryKey, label) => `${entryKey}.${label}`;
|
|
79
|
+
function parseNames(raw) {
|
|
80
|
+
if (!raw)
|
|
81
|
+
return null;
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(raw);
|
|
84
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
85
|
+
return null;
|
|
86
|
+
const out = {};
|
|
87
|
+
for (const [label, name] of Object.entries(parsed)) {
|
|
88
|
+
if (typeof name === 'string' && name)
|
|
89
|
+
out[label] = name;
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Every collection name the entry has had, label to name: the recorded map, or, before one is recorded, each
|
|
99
|
+
* label of the entry's last applied files marker under its own name (the name files was given before
|
|
100
|
+
* collections were named per app).
|
|
101
|
+
*/
|
|
102
|
+
export function knownCollectionNames(entryKey, recordedIds) {
|
|
103
|
+
const recorded = parseNames(recordedIds[entryFilesCollectionsScopeKey(entryKey)]);
|
|
104
|
+
if (recorded)
|
|
105
|
+
return recorded;
|
|
106
|
+
const legacy = {};
|
|
107
|
+
try {
|
|
108
|
+
const applied = JSON.parse(recordedIds[entryFilesAppliedScopeKey(entryKey)] ?? '{}');
|
|
109
|
+
const named = parseNames(JSON.stringify(applied.collectionNames ?? null));
|
|
110
|
+
if (named)
|
|
111
|
+
return named;
|
|
112
|
+
if (Array.isArray(applied.collections)) {
|
|
113
|
+
for (const label of applied.collections) {
|
|
114
|
+
if (typeof label === 'string' && label)
|
|
115
|
+
legacy[label] = label;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// An unreadable marker has no collections to keep.
|
|
121
|
+
}
|
|
122
|
+
return legacy;
|
|
123
|
+
}
|
|
124
|
+
/** The files name of each collection the entry declares now, label to name, in the declared order. */
|
|
125
|
+
export function entryCollectionNames(entry, recordedIds) {
|
|
126
|
+
const known = knownCollectionNames(entry.key, recordedIds);
|
|
127
|
+
const out = {};
|
|
128
|
+
for (const label of entry.uses?.files?.collections ?? []) {
|
|
129
|
+
out[label] = known[label] ?? entryCollectionName(entry.key, label);
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
55
133
|
export function parseIdList(raw) {
|
|
56
134
|
try {
|
|
57
135
|
const parsed = raw ? JSON.parse(raw) : [];
|
|
@@ -68,15 +146,29 @@ export function filesOwnerUserId(org, recordedIds) {
|
|
|
68
146
|
const first = org.owners[0];
|
|
69
147
|
return first ? recordedIds[`zitadel:owner:${org.id}:${first.email.toLowerCase()}`] : undefined;
|
|
70
148
|
}
|
|
71
|
-
export function wantedEntryFiles(entry, filesUrl, shareHostname = null) {
|
|
149
|
+
export function wantedEntryFiles(entry, filesUrl, shareHostname = null, recordedIds = {}) {
|
|
72
150
|
const files = entry.uses?.files;
|
|
73
151
|
return {
|
|
74
152
|
permissions: [...(files?.permissions ?? FILES_ENTRY_PERMISSIONS)],
|
|
75
153
|
collections: [...(files?.collections ?? [])],
|
|
76
154
|
filesUrl,
|
|
77
155
|
...(files?.shareHost && shareHostname ? { shareOrigin: `https://${shareHostname}` } : {}),
|
|
156
|
+
collectionNames: entryCollectionNames(entry, recordedIds),
|
|
78
157
|
};
|
|
79
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* foundry#1121: Boule's files key, when it has one, is below `rootKeyId` and delivered, so revoking the org
|
|
161
|
+
* key it replaced revokes nothing Boule still uses.
|
|
162
|
+
*/
|
|
163
|
+
export function bouleKeyBelow(recordedIds, rootKeyId) {
|
|
164
|
+
// A renewal's old key is still in Boule's env until the new one is written.
|
|
165
|
+
if (parseIdList(recordedIds[FILES_BOULE_PREVIOUS_SCOPE_KEY]).length > 0)
|
|
166
|
+
return false;
|
|
167
|
+
if (!recordedIds[FILES_BOULE_KEY_SCOPE_KEY] && !recordedIds[FILES_BOULE_UNDELIVERED_SCOPE_KEY])
|
|
168
|
+
return true;
|
|
169
|
+
return (recordedIds[FILES_BOULE_MINTER_SCOPE_KEY] === rootKeyId &&
|
|
170
|
+
!recordedIds[FILES_BOULE_UNDELIVERED_SCOPE_KEY]);
|
|
171
|
+
}
|
|
80
172
|
/**
|
|
81
173
|
* The org key's steps, then one step per entry whose key is missing, near
|
|
82
174
|
* expiry, minted below a replaced org key or whose declared settings changed.
|
|
@@ -171,9 +263,12 @@ export function planFilesKeys(org, recordedIds, plannedSteps, readiness, now) {
|
|
|
171
263
|
const rootExpires = Date.parse(rootExpiresRaw ?? '');
|
|
172
264
|
const rootShort = Boolean(rootKeyId) &&
|
|
173
265
|
(Number.isNaN(rootExpires) || rootExpires - now < FILES_KEY_RENEW_WINDOW_DAYS * DAY_MS);
|
|
266
|
+
// foundry#1121: a key minted with fewer permissions than the org key now holds is renewed.
|
|
267
|
+
const rootNarrow = Boolean(rootKeyId) &&
|
|
268
|
+
recordedIds[FILES_ROOT_PERMISSIONS_SCOPE_KEY] !== JSON.stringify(FILES_ROOT_PERMISSIONS);
|
|
174
269
|
const rootUndelivered = recordedIds[FILES_ROOT_UNDELIVERED_SCOPE_KEY];
|
|
175
270
|
let rootMode = null;
|
|
176
|
-
if (!rootKeyId || rootShort) {
|
|
271
|
+
if (!rootKeyId || rootShort || rootNarrow) {
|
|
177
272
|
const ownerKey = org.owners[0] ? `${org.id}:${org.owners[0].email.toLowerCase()}` : '';
|
|
178
273
|
const orgPlanned = planned('files', 'organisation', org.id);
|
|
179
274
|
if (rootUndelivered && rootUndelivered !== rootKeyId) {
|
|
@@ -206,7 +301,9 @@ export function planFilesKeys(org, recordedIds, plannedSteps, readiness, now) {
|
|
|
206
301
|
},
|
|
207
302
|
step: {
|
|
208
303
|
description: rootMode === 'renew'
|
|
209
|
-
?
|
|
304
|
+
? rootNarrow && !rootShort
|
|
305
|
+
? `renew ${org.slug}'s own files key with the permissions Boule's files key needs, and re-mint its entry keys below it`
|
|
306
|
+
: `renew ${org.slug}'s own files key and re-mint its entry keys below it`
|
|
210
307
|
: `mint ${org.slug}'s own files key below its owner`,
|
|
211
308
|
destroys: false,
|
|
212
309
|
migrates: false,
|
|
@@ -241,7 +338,7 @@ export function planFilesKeys(org, recordedIds, plannedSteps, readiness, now) {
|
|
|
241
338
|
block("the entry's app is not recorded yet");
|
|
242
339
|
continue;
|
|
243
340
|
}
|
|
244
|
-
const want = wantedEntryFiles(entry, readiness.filesUrl, shareHostname);
|
|
341
|
+
const want = wantedEntryFiles(entry, readiness.filesUrl, shareHostname, recordedIds);
|
|
245
342
|
const applied = recordedIds[entryFilesAppliedScopeKey(entry.key)];
|
|
246
343
|
const wantJson = JSON.stringify(want);
|
|
247
344
|
let mode = null;
|
|
@@ -304,7 +401,7 @@ export function planFilesKeys(org, recordedIds, plannedSteps, readiness, now) {
|
|
|
304
401
|
return (recordedIds[`files:key:${stepKey}`] &&
|
|
305
402
|
recordedIds[entryFilesMinterScopeKey(e.key)] === rootKeyId &&
|
|
306
403
|
!recordedIds[entryFilesUndeliveredScopeKey(e.key)]);
|
|
307
|
-
});
|
|
404
|
+
}) && bouleKeyBelow(recordedIds, rootKeyId);
|
|
308
405
|
if (rootMode === 'renew' || (previous.length > 0 && consistent)) {
|
|
309
406
|
steps.push({
|
|
310
407
|
action: {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Every call this module makes (scheduler, held keys, Coolify) is cut off after this long. */
|
|
2
|
+
export declare const JOBS_BOULE_KEY_CALL_TIMEOUT_MS = 30000;
|
|
3
|
+
/** How many times Boule's key is tried against the held keys. */
|
|
4
|
+
export declare const JOBS_BOULE_KEY_HOLD_ATTEMPTS = 3;
|
|
5
|
+
/** The scheduler's project-key calls (jobs#79), as `JobsSchedulerPort` offers them. */
|
|
6
|
+
export interface JobsProjectKeysPort {
|
|
7
|
+
mintProjectKey(project: string, grant: {
|
|
8
|
+
principal: string;
|
|
9
|
+
scopes: readonly string[];
|
|
10
|
+
}): Promise<{
|
|
11
|
+
id: string;
|
|
12
|
+
key: string;
|
|
13
|
+
}>;
|
|
14
|
+
/** Revokes one key; a key or project the scheduler does not know throws with `status: 404`. */
|
|
15
|
+
revokeProjectKey(project: string, keyId: string): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export interface JobsBouleKeyDeps {
|
|
18
|
+
scheduler: JobsProjectKeysPort;
|
|
19
|
+
heldKeys: {
|
|
20
|
+
resolve(ref: string): Promise<string | null | undefined>;
|
|
21
|
+
put(ref: string, value: string): Promise<void>;
|
|
22
|
+
};
|
|
23
|
+
coolify: {
|
|
24
|
+
bulkUpsertEnvs(appId: string, envs: Array<{
|
|
25
|
+
key: string;
|
|
26
|
+
value: string;
|
|
27
|
+
literal?: boolean;
|
|
28
|
+
}>): Promise<unknown>;
|
|
29
|
+
listEnvs(appId: string): Promise<Array<{
|
|
30
|
+
uuid?: string;
|
|
31
|
+
key: string;
|
|
32
|
+
value: string;
|
|
33
|
+
is_preview: boolean;
|
|
34
|
+
}>>;
|
|
35
|
+
deleteEnv(appId: string, envUuid: string): Promise<unknown>;
|
|
36
|
+
restart(appId: string): Promise<unknown>;
|
|
37
|
+
};
|
|
38
|
+
org: {
|
|
39
|
+
id: string;
|
|
40
|
+
slug: string;
|
|
41
|
+
};
|
|
42
|
+
entryKey: string;
|
|
43
|
+
/** The scheduler's name for the entry's project, `<slug>-<entry>`. */
|
|
44
|
+
project: string;
|
|
45
|
+
/** Boule's Coolify app, when it is recorded. */
|
|
46
|
+
bouleAppId: string | undefined;
|
|
47
|
+
recorded: () => Readonly<Record<string, string>>;
|
|
48
|
+
record: (key: string, value: string) => void;
|
|
49
|
+
forget: (key: string) => void;
|
|
50
|
+
persist: () => Promise<void>;
|
|
51
|
+
/** Registers a value for redaction before anything else can see it. */
|
|
52
|
+
secret: (value: string) => void;
|
|
53
|
+
timeoutMs?: number;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Mints (or reuses the held) key for one entry's project and puts it in Boule's `JOBS_PROJECTS` at `url`.
|
|
57
|
+
* See `entry-jobs-boule-key.ts` for the order. Errors name the project, the key id and the ref, never the key.
|
|
58
|
+
*/
|
|
59
|
+
export declare function ensureJobsBouleKey(deps: JobsBouleKeyDeps, url: string): Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* Revokes Boule's key for an entry that no longer uses jobs (a held one, or one minted and never held), takes
|
|
62
|
+
* the entry out of `JOBS_PROJECTS` and restarts Boule, then clears the records. A key the scheduler no longer
|
|
63
|
+
* knows counts as revoked.
|
|
64
|
+
*/
|
|
65
|
+
export declare function retireJobsBouleKey(deps: JobsBouleKeyDeps): Promise<void>;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { JOBS_BOULE_KEY_GRANT, JOBS_PROJECTS_ENV, jobsBouleKeyAppliedScopeKey, jobsBouleKeyRef, jobsBouleKeyScopeKey, jobsBouleKeyUndeliveredScopeKey, } from './entry-jobs-boule-key.js';
|
|
2
|
+
/** Every call this module makes (scheduler, held keys, Coolify) is cut off after this long. */
|
|
3
|
+
export const JOBS_BOULE_KEY_CALL_TIMEOUT_MS = 30_000;
|
|
4
|
+
/** How many times Boule's key is tried against the held keys. */
|
|
5
|
+
export const JOBS_BOULE_KEY_HOLD_ATTEMPTS = 3;
|
|
6
|
+
async function timed(work, ms) {
|
|
7
|
+
let timer;
|
|
8
|
+
try {
|
|
9
|
+
return await Promise.race([
|
|
10
|
+
work(),
|
|
11
|
+
new Promise((_, reject) => {
|
|
12
|
+
timer = setTimeout(() => reject(new Error('timeout')), ms);
|
|
13
|
+
}),
|
|
14
|
+
]);
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
clearTimeout(timer);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const isNotFound = (error) => error?.status === 404;
|
|
21
|
+
/** Boule's `JOBS_PROJECTS` as it is now, keeping only well-formed entries; every key in it is registered for redaction. */
|
|
22
|
+
async function readProjects(deps, appId, at, ms) {
|
|
23
|
+
let rows;
|
|
24
|
+
try {
|
|
25
|
+
rows = await timed(() => deps.coolify.listEnvs(appId), ms);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
throw new Error(`${at}: Boule's env could not be read`);
|
|
29
|
+
}
|
|
30
|
+
const row = rows.find((e) => e.key === JOBS_PROJECTS_ENV && !e.is_preview);
|
|
31
|
+
const projects = {};
|
|
32
|
+
if (row?.value) {
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(row.value);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
parsed = null;
|
|
39
|
+
}
|
|
40
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
41
|
+
for (const [entry, value] of Object.entries(parsed)) {
|
|
42
|
+
const v = value;
|
|
43
|
+
if (v && typeof v.url === 'string' && typeof v.key === 'string') {
|
|
44
|
+
deps.secret(v.key);
|
|
45
|
+
projects[entry] = { url: v.url, key: v.key };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { projects, uuid: row?.uuid };
|
|
51
|
+
}
|
|
52
|
+
/** Writes `JOBS_PROJECTS` (or removes the row when it is empty) and restarts Boule. The error is fixed text. */
|
|
53
|
+
async function writeProjects(deps, appId, projects, uuid, at, ms, failure) {
|
|
54
|
+
try {
|
|
55
|
+
if (Object.keys(projects).length > 0) {
|
|
56
|
+
await timed(() => deps.coolify.bulkUpsertEnvs(appId, [
|
|
57
|
+
{ key: JOBS_PROJECTS_ENV, value: JSON.stringify(projects), literal: true },
|
|
58
|
+
]), ms);
|
|
59
|
+
}
|
|
60
|
+
else if (uuid) {
|
|
61
|
+
await timed(() => deps.coolify.deleteEnv(appId, uuid), ms);
|
|
62
|
+
}
|
|
63
|
+
await timed(() => deps.coolify.restart(appId), ms);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// The error is dropped in case Coolify echoes the request body, which holds keys.
|
|
67
|
+
throw new Error(`${at}: ${failure}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Mints (or reuses the held) key for one entry's project and puts it in Boule's `JOBS_PROJECTS` at `url`.
|
|
72
|
+
* See `entry-jobs-boule-key.ts` for the order. Errors name the project, the key id and the ref, never the key.
|
|
73
|
+
*/
|
|
74
|
+
export async function ensureJobsBouleKey(deps, url) {
|
|
75
|
+
const ms = deps.timeoutMs ?? JOBS_BOULE_KEY_CALL_TIMEOUT_MS;
|
|
76
|
+
const at = `jobs:boule-key for ${deps.org.slug} entry '${deps.entryKey}'`;
|
|
77
|
+
const ref = jobsBouleKeyRef(deps.org.id, deps.entryKey);
|
|
78
|
+
const undeliveredKey = jobsBouleKeyUndeliveredScopeKey(deps.entryKey);
|
|
79
|
+
const guardKey = jobsBouleKeyScopeKey(deps.entryKey);
|
|
80
|
+
if (!deps.bouleAppId)
|
|
81
|
+
throw new Error(`${at}: Boule is not recorded yet`);
|
|
82
|
+
const recorded = deps.recorded();
|
|
83
|
+
const undelivered = recorded[undeliveredKey];
|
|
84
|
+
let guard = recorded[guardKey];
|
|
85
|
+
if (undelivered && undelivered !== guard) {
|
|
86
|
+
throw new Error(`${at}: key ${undelivered} was minted but never held, and the scheduler shows a key once; revoke it (DELETE /admin/projects/${deps.project}/keys/${undelivered}) and remove the recorded key ${undeliveredKey}`);
|
|
87
|
+
}
|
|
88
|
+
let key = null;
|
|
89
|
+
if (guard) {
|
|
90
|
+
try {
|
|
91
|
+
key = (await timed(() => deps.heldKeys.resolve(ref), ms)) || null;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
throw new Error(`${at}: the held key ${ref} could not be read`);
|
|
95
|
+
}
|
|
96
|
+
if (key) {
|
|
97
|
+
deps.secret(key);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
// The held value is gone: the key Boule had is revoked before a new one is minted.
|
|
101
|
+
try {
|
|
102
|
+
await timed(() => deps.scheduler.revokeProjectKey(deps.project, guard), ms);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (!isNotFound(error)) {
|
|
106
|
+
throw new Error(`${at}: the held key ${ref} is missing and key ${guard} could not be revoked before a new one is minted; the next apply tries again`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
deps.forget(guardKey);
|
|
110
|
+
deps.forget(undeliveredKey);
|
|
111
|
+
await deps.persist();
|
|
112
|
+
guard = undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!key) {
|
|
116
|
+
let minted;
|
|
117
|
+
try {
|
|
118
|
+
minted = await timed(() => deps.scheduler.mintProjectKey(deps.project, {
|
|
119
|
+
principal: JOBS_BOULE_KEY_GRANT.principal,
|
|
120
|
+
scopes: [...JOBS_BOULE_KEY_GRANT.scopes],
|
|
121
|
+
}), ms);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
const status = error?.status;
|
|
125
|
+
throw new Error(`${at}: the scheduler refused or did not answer the key for project ${deps.project}${typeof status === 'number' ? ` (${status})` : ''}`);
|
|
126
|
+
}
|
|
127
|
+
if (typeof minted?.key === 'string' && minted.key)
|
|
128
|
+
deps.secret(minted.key);
|
|
129
|
+
if (typeof minted?.id !== 'string' || !minted.id) {
|
|
130
|
+
throw new Error(`${at}: the scheduler answered the key for project ${deps.project} with no id`);
|
|
131
|
+
}
|
|
132
|
+
deps.record(undeliveredKey, minted.id);
|
|
133
|
+
await deps.persist();
|
|
134
|
+
if (typeof minted.key !== 'string' || !minted.key) {
|
|
135
|
+
throw new Error(`${at}: key ${minted.id} was minted but the scheduler returned no key, which it shows once; revoke it (DELETE /admin/projects/${deps.project}/keys/${minted.id}) and remove the recorded key ${undeliveredKey}`);
|
|
136
|
+
}
|
|
137
|
+
let stored = false;
|
|
138
|
+
for (let attempt = 1; attempt <= JOBS_BOULE_KEY_HOLD_ATTEMPTS && !stored; attempt++) {
|
|
139
|
+
try {
|
|
140
|
+
await timed(() => deps.heldKeys.put(ref, minted.key), ms);
|
|
141
|
+
stored = true;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// Retried; the error is dropped in case it echoes the key.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (!stored) {
|
|
148
|
+
throw new Error(`${at}: key ${minted.id} was minted but could not be held at ${ref} after ${JOBS_BOULE_KEY_HOLD_ATTEMPTS} attempts; revoke it (DELETE /admin/projects/${deps.project}/keys/${minted.id}) and remove the recorded key ${undeliveredKey}`);
|
|
149
|
+
}
|
|
150
|
+
deps.record(guardKey, minted.id);
|
|
151
|
+
await deps.persist();
|
|
152
|
+
key = minted.key;
|
|
153
|
+
}
|
|
154
|
+
const { projects, uuid } = await readProjects(deps, deps.bouleAppId, at, ms);
|
|
155
|
+
projects[deps.entryKey] = { url, key };
|
|
156
|
+
await writeProjects(deps, deps.bouleAppId, projects, uuid, at, ms, `the key is held at ${ref} but could not be written to Boule's env; the next apply writes it again`);
|
|
157
|
+
deps.forget(undeliveredKey);
|
|
158
|
+
deps.record(jobsBouleKeyAppliedScopeKey(deps.entryKey), url);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Revokes Boule's key for an entry that no longer uses jobs (a held one, or one minted and never held), takes
|
|
162
|
+
* the entry out of `JOBS_PROJECTS` and restarts Boule, then clears the records. A key the scheduler no longer
|
|
163
|
+
* knows counts as revoked.
|
|
164
|
+
*/
|
|
165
|
+
export async function retireJobsBouleKey(deps) {
|
|
166
|
+
const ms = deps.timeoutMs ?? JOBS_BOULE_KEY_CALL_TIMEOUT_MS;
|
|
167
|
+
const at = `jobs:boule-key for ${deps.org.slug} entry '${deps.entryKey}'`;
|
|
168
|
+
const undeliveredKey = jobsBouleKeyUndeliveredScopeKey(deps.entryKey);
|
|
169
|
+
const guardKey = jobsBouleKeyScopeKey(deps.entryKey);
|
|
170
|
+
const recorded = deps.recorded();
|
|
171
|
+
const ids = [...new Set([recorded[guardKey], recorded[undeliveredKey]].filter(Boolean))];
|
|
172
|
+
for (const id of ids) {
|
|
173
|
+
try {
|
|
174
|
+
await timed(() => deps.scheduler.revokeProjectKey(deps.project, id), ms);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (!isNotFound(error)) {
|
|
178
|
+
throw new Error(`${at}: key ${id} of project ${deps.project} could not be revoked; the next apply tries again`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (deps.bouleAppId) {
|
|
183
|
+
const { projects, uuid } = await readProjects(deps, deps.bouleAppId, at, ms);
|
|
184
|
+
if (deps.entryKey in projects) {
|
|
185
|
+
delete projects[deps.entryKey];
|
|
186
|
+
await writeProjects(deps, deps.bouleAppId, projects, uuid, at, ms, "the key is revoked but could not be taken out of Boule's env; the next apply tries again");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
deps.forget(guardKey);
|
|
190
|
+
deps.forget(undeliveredKey);
|
|
191
|
+
deps.forget(jobsBouleKeyAppliedScopeKey(deps.entryKey));
|
|
192
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { DeclaredOrg } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
import type { OrgPlatformReadiness } from './platform-org.js';
|
|
4
|
+
/**
|
|
5
|
+
* foundry#1119: Boule's key for one `uses.jobs` entry's project. Boule's Jobs page (boule#651) reads and
|
|
6
|
+
* presses one app's jobs through the scheduler's `/project/*` routes (jobs#79) with a key scoped to that
|
|
7
|
+
* project. One `jobs:boule-key` step per entry, keyed `<orgId>:<entry>`, after the entry's `jobs:project`:
|
|
8
|
+
*
|
|
9
|
+
* 1. mints `{ principal: 'boule', scopes: ['jobs:read', 'jobs:control'] }` through
|
|
10
|
+
* `POST /admin/projects/<slug>-<entry>/keys`, which shows the key once;
|
|
11
|
+
* 2. records its id as `jobs_boule_key_undelivered.<entry>` and persists it before anything else;
|
|
12
|
+
* 3. holds the key at `jobs:project:<orgId>:<entry>:bouleKey` (3 tries), then records the id as
|
|
13
|
+
* `jobs_boule_key.<entry>` (the held guard) and persists it;
|
|
14
|
+
* 4. sets the entry in Boule's `JOBS_PROJECTS` (JSON: entry key to `{ url, key }`, `url` the scheduler's
|
|
15
|
+
* public origin) and restarts Boule;
|
|
16
|
+
* 5. only then clears the undelivered marker and records the address as `jobs_boule_key_applied.<entry>`.
|
|
17
|
+
*
|
|
18
|
+
* A marker with the held guard equal to it is a delivery that did not finish: the step is planned again and
|
|
19
|
+
* reuses the held key. A marker without it (minted, never held) is blocked and never minted again. A held guard
|
|
20
|
+
* whose held value is gone is repaired by the step itself: the old key is revoked first, then a new one is
|
|
21
|
+
* minted the same way, so at most one key of Boule's is live per project.
|
|
22
|
+
*
|
|
23
|
+
* Removing `uses.jobs` (or the entry) plans the retire form: revoke the key (`DELETE …/keys/<id>`), a delivered
|
|
24
|
+
* one or one minted and never held, take the entry out of `JOBS_PROJECTS`, restart Boule, clear the records.
|
|
25
|
+
* The held value stays (the held store has no delete); it is never read again without a recorded guard.
|
|
26
|
+
*
|
|
27
|
+
* The key reaches the held keys and Boule's env and nothing else: not a plan, a log, an error or a recorded id.
|
|
28
|
+
*/
|
|
29
|
+
/** Boule's key is minted, not held yet: its id, never the key. */
|
|
30
|
+
export declare const jobsBouleKeyUndeliveredScopeKey: (entryKey: string) => string;
|
|
31
|
+
/** The id of Boule's key once it is held. */
|
|
32
|
+
export declare const jobsBouleKeyScopeKey: (entryKey: string) => string;
|
|
33
|
+
/** The scheduler address Boule's env was last written with for the entry; a change writes again. */
|
|
34
|
+
export declare const jobsBouleKeyAppliedScopeKey: (entryKey: string) => string;
|
|
35
|
+
/** The held-key ref of Boule's key for an entry's project. */
|
|
36
|
+
export declare const jobsBouleKeyRef: (orgId: string, entryKey: string) => string;
|
|
37
|
+
/** Boule's env name: JSON, entry key to `{ url, key }`. */
|
|
38
|
+
export declare const JOBS_PROJECTS_ENV = "JOBS_PROJECTS";
|
|
39
|
+
/** What Boule's key is minted as. */
|
|
40
|
+
export declare const JOBS_BOULE_KEY_GRANT: {
|
|
41
|
+
readonly principal: "boule";
|
|
42
|
+
readonly scopes: readonly ["jobs:read", "jobs:control"];
|
|
43
|
+
};
|
|
44
|
+
type Blocked = Array<{
|
|
45
|
+
description: string;
|
|
46
|
+
key: string;
|
|
47
|
+
}>;
|
|
48
|
+
/**
|
|
49
|
+
* Mint or deliver one step per `uses.jobs` entry whose key is not in Boule's env at the scheduler's current
|
|
50
|
+
* address; retire one per entry that no longer declares it and still has a key recorded. Blocked, not planned
|
|
51
|
+
* (rule 2), while the host's scheduler port has no key calls (`projectKeys`), the held keys cannot hold the
|
|
52
|
+
* key, the scheduler's public address is not recorded, Boule is not recorded or planned, or the entry's project
|
|
53
|
+
* is not registered or planned.
|
|
54
|
+
*/
|
|
55
|
+
export declare function planEntryJobsBouleKeys(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], readiness: OrgPlatformReadiness, projectKeys: boolean): {
|
|
56
|
+
steps: DraftStep[];
|
|
57
|
+
blocked: Blocked;
|
|
58
|
+
};
|
|
59
|
+
export {};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { entryJobsAppliedScopeKey, entryJobsProjectName } from './entry-jobs.js';
|
|
2
|
+
import { appStepKey } from './platform-diff.js';
|
|
3
|
+
/**
|
|
4
|
+
* foundry#1119: Boule's key for one `uses.jobs` entry's project. Boule's Jobs page (boule#651) reads and
|
|
5
|
+
* presses one app's jobs through the scheduler's `/project/*` routes (jobs#79) with a key scoped to that
|
|
6
|
+
* project. One `jobs:boule-key` step per entry, keyed `<orgId>:<entry>`, after the entry's `jobs:project`:
|
|
7
|
+
*
|
|
8
|
+
* 1. mints `{ principal: 'boule', scopes: ['jobs:read', 'jobs:control'] }` through
|
|
9
|
+
* `POST /admin/projects/<slug>-<entry>/keys`, which shows the key once;
|
|
10
|
+
* 2. records its id as `jobs_boule_key_undelivered.<entry>` and persists it before anything else;
|
|
11
|
+
* 3. holds the key at `jobs:project:<orgId>:<entry>:bouleKey` (3 tries), then records the id as
|
|
12
|
+
* `jobs_boule_key.<entry>` (the held guard) and persists it;
|
|
13
|
+
* 4. sets the entry in Boule's `JOBS_PROJECTS` (JSON: entry key to `{ url, key }`, `url` the scheduler's
|
|
14
|
+
* public origin) and restarts Boule;
|
|
15
|
+
* 5. only then clears the undelivered marker and records the address as `jobs_boule_key_applied.<entry>`.
|
|
16
|
+
*
|
|
17
|
+
* A marker with the held guard equal to it is a delivery that did not finish: the step is planned again and
|
|
18
|
+
* reuses the held key. A marker without it (minted, never held) is blocked and never minted again. A held guard
|
|
19
|
+
* whose held value is gone is repaired by the step itself: the old key is revoked first, then a new one is
|
|
20
|
+
* minted the same way, so at most one key of Boule's is live per project.
|
|
21
|
+
*
|
|
22
|
+
* Removing `uses.jobs` (or the entry) plans the retire form: revoke the key (`DELETE …/keys/<id>`), a delivered
|
|
23
|
+
* one or one minted and never held, take the entry out of `JOBS_PROJECTS`, restart Boule, clear the records.
|
|
24
|
+
* The held value stays (the held store has no delete); it is never read again without a recorded guard.
|
|
25
|
+
*
|
|
26
|
+
* The key reaches the held keys and Boule's env and nothing else: not a plan, a log, an error or a recorded id.
|
|
27
|
+
*/
|
|
28
|
+
/** Boule's key is minted, not held yet: its id, never the key. */
|
|
29
|
+
export const jobsBouleKeyUndeliveredScopeKey = (entryKey) => `jobs_boule_key_undelivered.${entryKey}`;
|
|
30
|
+
/** The id of Boule's key once it is held. */
|
|
31
|
+
export const jobsBouleKeyScopeKey = (entryKey) => `jobs_boule_key.${entryKey}`;
|
|
32
|
+
/** The scheduler address Boule's env was last written with for the entry; a change writes again. */
|
|
33
|
+
export const jobsBouleKeyAppliedScopeKey = (entryKey) => `jobs_boule_key_applied.${entryKey}`;
|
|
34
|
+
/** The held-key ref of Boule's key for an entry's project. */
|
|
35
|
+
export const jobsBouleKeyRef = (orgId, entryKey) => `jobs:project:${orgId}:${entryKey}:bouleKey`;
|
|
36
|
+
/** Boule's env name: JSON, entry key to `{ url, key }`. */
|
|
37
|
+
export const JOBS_PROJECTS_ENV = 'JOBS_PROJECTS';
|
|
38
|
+
/** What Boule's key is minted as. */
|
|
39
|
+
export const JOBS_BOULE_KEY_GRANT = {
|
|
40
|
+
principal: 'boule',
|
|
41
|
+
scopes: ['jobs:read', 'jobs:control'],
|
|
42
|
+
};
|
|
43
|
+
const usesJobs = (entry) => entry.uses?.jobs === true;
|
|
44
|
+
/**
|
|
45
|
+
* Mint or deliver one step per `uses.jobs` entry whose key is not in Boule's env at the scheduler's current
|
|
46
|
+
* address; retire one per entry that no longer declares it and still has a key recorded. Blocked, not planned
|
|
47
|
+
* (rule 2), while the host's scheduler port has no key calls (`projectKeys`), the held keys cannot hold the
|
|
48
|
+
* key, the scheduler's public address is not recorded, Boule is not recorded or planned, or the entry's project
|
|
49
|
+
* is not registered or planned.
|
|
50
|
+
*/
|
|
51
|
+
export function planEntryJobsBouleKeys(org, recordedIds, plannedSteps, readiness, projectKeys) {
|
|
52
|
+
const steps = [];
|
|
53
|
+
const blocked = [];
|
|
54
|
+
const planned = (system, kind, key) => plannedSteps.some((p) => p.action.system === system && p.action.kind === kind && p.action.key === key);
|
|
55
|
+
const wanted = new Set(org.hosting.enabled ? org.hosting.entries.filter(usesJobs).map((e) => e.key) : []);
|
|
56
|
+
// Keys of entries that dropped uses.jobs or are gone: a held one, or one minted and never held.
|
|
57
|
+
const retiring = new Set();
|
|
58
|
+
for (const prefix of [jobsBouleKeyScopeKey(''), jobsBouleKeyUndeliveredScopeKey('')]) {
|
|
59
|
+
for (const [name, id] of Object.entries(recordedIds)) {
|
|
60
|
+
if (!id || !name.startsWith(prefix))
|
|
61
|
+
continue;
|
|
62
|
+
const entryKey = name.slice(prefix.length);
|
|
63
|
+
if (!entryKey || wanted.has(entryKey) || retiring.has(entryKey))
|
|
64
|
+
continue;
|
|
65
|
+
retiring.add(entryKey);
|
|
66
|
+
const key = `jobs:boule-key:${org.id}:${entryKey}`;
|
|
67
|
+
if (!projectKeys) {
|
|
68
|
+
blocked.push({
|
|
69
|
+
description: `${org.slug}'s ${entryKey} Boule jobs key is not revoked, not planned: the host's jobs scheduler port cannot revoke project keys`,
|
|
70
|
+
key,
|
|
71
|
+
});
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
steps.push({
|
|
75
|
+
action: {
|
|
76
|
+
system: 'jobs',
|
|
77
|
+
kind: 'boule-key',
|
|
78
|
+
key: `${org.id}:${entryKey}`,
|
|
79
|
+
desired: { mode: 'retire', name: entryJobsProjectName(org, entryKey) },
|
|
80
|
+
},
|
|
81
|
+
step: {
|
|
82
|
+
description: `revoke Boule's key for ${org.slug}'s ${entryKey} jobs and take it out of Boule's JOBS_PROJECTS, restart Boule: the entry no longer uses jobs`,
|
|
83
|
+
destroys: true,
|
|
84
|
+
migrates: false,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const entryKey of wanted) {
|
|
90
|
+
const stepKey = appStepKey(org, entryKey);
|
|
91
|
+
const key = `jobs:boule-key:${stepKey}`;
|
|
92
|
+
const at = `${org.slug}'s ${entryKey} Boule jobs key`;
|
|
93
|
+
const block = (why) => blocked.push({ description: `${at} is blocked: ${why}`, key });
|
|
94
|
+
const name = entryJobsProjectName(org, entryKey);
|
|
95
|
+
const undelivered = recordedIds[jobsBouleKeyUndeliveredScopeKey(entryKey)];
|
|
96
|
+
const held = recordedIds[jobsBouleKeyScopeKey(entryKey)];
|
|
97
|
+
if (undelivered && undelivered !== held) {
|
|
98
|
+
block(`key ${undelivered} was minted but never held, and the scheduler shows a key once; revoke it (DELETE /admin/projects/${name}/keys/${undelivered}) and remove the recorded key ${jobsBouleKeyUndeliveredScopeKey(entryKey)}`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const url = readiness.jobsPublicUrl;
|
|
102
|
+
if (!undelivered && held && url && recordedIds[jobsBouleKeyAppliedScopeKey(entryKey)] === url)
|
|
103
|
+
continue;
|
|
104
|
+
if (!projectKeys) {
|
|
105
|
+
block("the host's jobs scheduler port cannot mint project keys");
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (readiness.canHoldSecrets !== true) {
|
|
109
|
+
block("the host's held keys cannot hold Boule's key");
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!url) {
|
|
113
|
+
block("the jobs scheduler's public address is not recorded yet");
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (!recordedIds[`coolify:boule:${org.id}`] && !planned('coolify', 'boule', org.id)) {
|
|
117
|
+
block("the company's Boule is not recorded yet");
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!recordedIds[entryJobsAppliedScopeKey(entryKey)] && !planned('jobs', 'project', stepKey)) {
|
|
121
|
+
block("the entry's jobs project is not registered yet");
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
steps.push({
|
|
125
|
+
action: {
|
|
126
|
+
system: 'jobs',
|
|
127
|
+
kind: 'boule-key',
|
|
128
|
+
key: stepKey,
|
|
129
|
+
desired: { mode: 'ensure', name, url },
|
|
130
|
+
},
|
|
131
|
+
step: {
|
|
132
|
+
description: held
|
|
133
|
+
? `write Boule's key for ${org.slug}'s ${entryKey} jobs to Boule's JOBS_PROJECTS, restart Boule`
|
|
134
|
+
: `mint Boule a key for jobs project ${name}, hold it, write it to Boule's JOBS_PROJECTS, restart Boule`,
|
|
135
|
+
destroys: false,
|
|
136
|
+
migrates: false,
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return { steps, blocked };
|
|
141
|
+
}
|
|
@@ -17,6 +17,8 @@ import type { OrgPlatformReadiness } from './platform-org.js';
|
|
|
17
17
|
* error or a recorded id. A workspace (stage) gets no project: its copy of the app is built without
|
|
18
18
|
* `JOBS_*` (`stage-env.ts`), so a stage never schedules or receives a live job.
|
|
19
19
|
*
|
|
20
|
+
* Boule's own key for the project (its Jobs page) is the next step, `jobs:boule-key` (`entry-jobs-boule-key.ts`).
|
|
21
|
+
*
|
|
20
22
|
* Nothing removes the project when the entry drops `uses.jobs`: the admin API can deregister, but the
|
|
21
23
|
* scheduler port has no such call yet, so the project and its held credentials stay until a person
|
|
22
24
|
* deregisters it.
|