@wtfalch/foundry 0.31.0 → 0.33.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 +6 -1
- package/dist/bootstrap/bootstrap.d.ts +18 -1
- package/dist/bootstrap/bootstrap.js +167 -28
- package/dist/bootstrap/declare.d.ts +5 -1
- package/dist/bootstrap/declare.js +3 -1
- package/dist/bootstrap/index.d.ts +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.js +2 -1
- package/dist/org.d.ts +12 -3
- package/dist/org.js +19 -3
- package/dist/plan/boule.d.ts +24 -1
- package/dist/plan/boule.js +29 -1
- package/dist/plan/confirm-upgrade.d.ts +17 -0
- package/dist/plan/confirm-upgrade.js +47 -0
- package/dist/plan/diff.d.ts +3 -1
- package/dist/plan/diff.js +174 -12
- package/dist/plan/executor.d.ts +65 -7
- package/dist/plan/executor.js +164 -45
- package/dist/plan/health-targets.js +1 -1
- package/dist/plan/index.d.ts +5 -2
- package/dist/plan/index.js +2 -1
- package/dist/plan/plan-apply.d.ts +40 -2
- package/dist/plan/plan-apply.js +43 -2
- package/dist/plan/platform-diff.d.ts +39 -10
- package/dist/plan/platform-diff.js +135 -33
- package/dist/plan/platform-org.d.ts +2 -0
- package/dist/plan/platform-org.js +2 -0
- package/dist/plan/platform-providers.d.ts +53 -7
- package/dist/plan/platform-providers.js +351 -99
- package/dist/plan/sign-in-org.d.ts +99 -0
- package/dist/plan/sign-in-org.js +133 -0
- package/dist/plan/version-status.d.ts +1 -2
- package/dist/plan.d.ts +30 -0
- package/package.json +1 -1
package/dist/plan/executor.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { URL } from 'node:url';
|
|
3
|
+
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
3
4
|
import { resolveCallsSfuUrls } from '../calls-sfu.js';
|
|
4
5
|
import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, sharedSecretRef, } from '../org.js';
|
|
5
6
|
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatCallsApplied, chatHasUpstreams, chatIntrospectionAppName, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
|
|
6
|
-
import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
7
|
+
import { BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
7
8
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
8
9
|
import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
9
10
|
import { CHAT_MAS_PORT, CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, assertChatServerName, chatInternalNames, chatMasConfigYaml, chatRekeyedInternalNames, chatServerName, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
|
|
@@ -27,6 +28,7 @@ import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyC
|
|
|
27
28
|
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appFolderDiffers, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
|
|
28
29
|
import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, } from './platform-providers.js';
|
|
29
30
|
import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
|
|
31
|
+
import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
|
|
30
32
|
import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
|
|
31
33
|
/**
|
|
32
34
|
* The real {@link StepExecutor} (#20 "provision Company Factory end to
|
|
@@ -51,7 +53,7 @@ import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerR
|
|
|
51
53
|
*/
|
|
52
54
|
/** #569: the description foundry gives the Coolify project it creates for an org. */
|
|
53
55
|
const COOLIFY_PROJECT_MARK_PREFIX = 'foundry org ';
|
|
54
|
-
function coolifyProjectMark(org) {
|
|
56
|
+
export function coolifyProjectMark(org) {
|
|
55
57
|
return `${COOLIFY_PROJECT_MARK_PREFIX}${org.id}`;
|
|
56
58
|
}
|
|
57
59
|
/**
|
|
@@ -230,24 +232,6 @@ const RATE_LIMIT_RULE_DESCRIPTION = 'foundry: rate limit sign-in paths';
|
|
|
230
232
|
*/
|
|
231
233
|
const RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS = 10;
|
|
232
234
|
const RATE_LIMIT_REQUESTS_PER_PERIOD = 10;
|
|
233
|
-
/**
|
|
234
|
-
* The shared standard app a level-1 org with no `bespokeAppRepo` gets
|
|
235
|
-
* deployed (gap P1, `.plans/2026-09-26-level-1-org-map.md`): one repo, the
|
|
236
|
-
* same for every such org, decided by William 2026-09-26. `coolifyApp` below
|
|
237
|
-
* names it as the git source in exactly the shape `bespokeAppRepo` itself
|
|
238
|
-
* uses (`org.ts`'s `DeclaredOrg.bespokeAppRepo: string | null`) whenever an
|
|
239
|
-
* org declares none of its own — the repo may not exist yet at decision
|
|
240
|
-
* time, and Coolify's own GitHub App create call is what will fail loudly
|
|
241
|
-
* against it, the same as any other repo name that does not exist.
|
|
242
|
-
*
|
|
243
|
-
* Hestia moved into this repo's own `apps/hestia` (estate DECISIONS.md,
|
|
244
|
-
* 2026-09-28; the hestia repo archives once this move lands), so the
|
|
245
|
-
* standard app now builds from foundry's own repo, told apart from
|
|
246
|
-
* `packages/foundry` by `dockerfile_location` — the same one-repo,
|
|
247
|
-
* multiple-apps shape `cmsSite`/`CMS_APP_DOCKERFILE` already use below.
|
|
248
|
-
*/
|
|
249
|
-
const DEFAULT_APP_REPO = 'wtfalch/foundry';
|
|
250
|
-
const DEFAULT_APP_DOCKERFILE = '/apps/hestia/Dockerfile';
|
|
251
235
|
/**
|
|
252
236
|
* The shared repo `cmsSite` builds its cms admin app from, always, and its
|
|
253
237
|
* site app from by default (https://github.com/wtfalch/cms) -- the
|
|
@@ -275,7 +259,7 @@ const CMS_APP_PORT = 3000;
|
|
|
275
259
|
* #164: the repo `coolifyAgora` builds the org's Agora from
|
|
276
260
|
* (https://github.com/wtfalch/agora) -- the issue's own "How" names this
|
|
277
261
|
* exact Dockerfile, and it is the only build strategy any app this file
|
|
278
|
-
* provisions ever uses ({@link
|
|
262
|
+
* provisions ever uses ({@link CMS_APP_DOCKERFILE}/
|
|
279
263
|
* `CALLS_SERVICE_DOCKERFILE` below are all `dockerfile_location` into a
|
|
280
264
|
* `createGithubAppApplication` build-from-repo call, never a registry
|
|
281
265
|
* image): "Coolify builds each org's Agora from the agora repo" is not a
|
|
@@ -779,9 +763,14 @@ export class FoundryStepExecutor {
|
|
|
779
763
|
edgeRateLimits;
|
|
780
764
|
edgeZones;
|
|
781
765
|
edgeWriteSlot;
|
|
766
|
+
/** Secrets this executor minted or was handed; see `StepExecutor.secretsHandled`. */
|
|
767
|
+
handledSecrets = new Set();
|
|
782
768
|
constructor(ports) {
|
|
783
769
|
this.ports = ports;
|
|
784
770
|
}
|
|
771
|
+
secretsHandled() {
|
|
772
|
+
return [...this.handledSecrets];
|
|
773
|
+
}
|
|
785
774
|
/** #555: the edge-routed ports this executor was built with itself (they win over edge). */
|
|
786
775
|
ownEdgePorts() {
|
|
787
776
|
return {
|
|
@@ -797,6 +786,10 @@ export class FoundryStepExecutor {
|
|
|
797
786
|
switch (`${action.system}:${action.kind}`) {
|
|
798
787
|
case 'zitadel:issuer':
|
|
799
788
|
return this.zitadelIssuer(action, org, context);
|
|
789
|
+
case 'zitadel:registration-closed':
|
|
790
|
+
return this.zitadelRegistrationClosed(org, context);
|
|
791
|
+
case 'zitadel:domain':
|
|
792
|
+
return this.zitadelDomain(action, org, context);
|
|
800
793
|
case 'zitadel:owner':
|
|
801
794
|
return this.zitadelOwner(action, org, context);
|
|
802
795
|
case 'zitadel:login-host':
|
|
@@ -1308,17 +1301,60 @@ export class FoundryStepExecutor {
|
|
|
1308
1301
|
// their own design" -- not built in v1).
|
|
1309
1302
|
throw new NotBuiltError(`zitadel:issuer move for ${org.slug}`, 'moving the issuer onto or off the org\'s own domain is not built in v1; see foundry\'s .claude/decisions-2026-09-24-executor.md, "Executor decisions"');
|
|
1310
1303
|
}
|
|
1311
|
-
const {
|
|
1312
|
-
|
|
1313
|
-
|
|
1304
|
+
const { orgId } = await ensureSignInOrg({
|
|
1305
|
+
zitadel: this.ports.zitadel,
|
|
1306
|
+
name: org.slug,
|
|
1307
|
+
domain: signInOrgDomain(org),
|
|
1308
|
+
recordScopeId: (key, id) => context.recordScopeId(key, id),
|
|
1309
|
+
persistScopeIds: () => context.persistScopeIds(),
|
|
1310
|
+
registrationClosedScopeKey: ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY,
|
|
1311
|
+
});
|
|
1312
|
+
return { resourceId: orgId };
|
|
1313
|
+
}
|
|
1314
|
+
/**
|
|
1315
|
+
* `plan/diff.ts`'s `planOrgRegistrationClosed`: closes self-registration on
|
|
1316
|
+
* the recorded ZITADEL org, for an org made before foundry did this or whose
|
|
1317
|
+
* close failed in the issuer step. Recorded only after the close returned.
|
|
1318
|
+
*/
|
|
1319
|
+
async zitadelRegistrationClosed(org, context) {
|
|
1320
|
+
const zitadelOrgId = context.recordedIds().zitadel_org;
|
|
1321
|
+
if (!zitadelOrgId) {
|
|
1322
|
+
throw new Error(`zitadel:registration-closed for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
|
|
1323
|
+
}
|
|
1324
|
+
await closeSignInOrgRegistration(this.ports.zitadel, zitadelOrgId, (key, id) => context.recordScopeId(key, id));
|
|
1325
|
+
return {};
|
|
1314
1326
|
}
|
|
1315
1327
|
/**
|
|
1316
|
-
* `plan/diff.ts`'s `
|
|
1317
|
-
*
|
|
1318
|
-
*
|
|
1319
|
-
*
|
|
1320
|
-
|
|
1321
|
-
|
|
1328
|
+
* `plan/diff.ts`'s `planOrgDomain`: registers the org's sign-in domain on
|
|
1329
|
+
* its recorded ZITADEL org and makes it primary, for an org whose ZITADEL
|
|
1330
|
+
* org was made before foundry did this. Records the domain only after both
|
|
1331
|
+
* calls succeeded.
|
|
1332
|
+
*/
|
|
1333
|
+
async zitadelDomain(action, org, context) {
|
|
1334
|
+
const zitadelOrgId = context.recordedIds().zitadel_org;
|
|
1335
|
+
if (!zitadelOrgId) {
|
|
1336
|
+
throw new Error(`zitadel:domain for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
|
|
1337
|
+
}
|
|
1338
|
+
const domain = action.desired.domain;
|
|
1339
|
+
// A refusal (the sign-in server's domain policy wants proof) is recorded and reported as
|
|
1340
|
+
// blocked by the planner; it does not fail every apply.
|
|
1341
|
+
const registered = await registerSignInDomainOrBlock(this.ports.zitadel, zitadelOrgId, domain, (key, id) => context.recordScopeId(key, id));
|
|
1342
|
+
if (registered)
|
|
1343
|
+
context.recordScopeId(ZITADEL_ORG_DOMAIN_SCOPE_KEY, domain);
|
|
1344
|
+
return {};
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* `plan/diff.ts`'s `planOwners`: makes the owner's account in the org's
|
|
1348
|
+
* ZITADEL org and mails the first-sign-in link to their personal address.
|
|
1349
|
+
* The account's username is the local part of `mailboxAddress` and its email
|
|
1350
|
+
* is `mailboxAddress`, so the owner signs in as `name@<org domain>`; the
|
|
1351
|
+
* personal address is kept as the `personal_email` metadata. The invite code
|
|
1352
|
+
* ZITADEL returns goes only into that mail. Nothing here logs, returns or
|
|
1353
|
+
* throws the personal address or the code -- only the invited user id, handed
|
|
1354
|
+
* back as `resourceId` so `runSteps` records it under
|
|
1355
|
+
* `zitadel:owner:<orgId>:<email>`, which is exactly what makes the next
|
|
1356
|
+
* `plan()` call idempotent. A failure after the account exists records
|
|
1357
|
+
* nothing: the next apply finds the account by username and mints a new code.
|
|
1322
1358
|
*/
|
|
1323
1359
|
async zitadelOwner(action, org, context) {
|
|
1324
1360
|
const zitadelOrgId = context.recordedIds().zitadel_org;
|
|
@@ -1331,11 +1367,31 @@ export class FoundryStepExecutor {
|
|
|
1331
1367
|
if (!authIssuer) {
|
|
1332
1368
|
throw new Error(`zitadel:owner for ${org.slug}: no invite template issuer -- refusing to send ZITADEL's default invite link, which 404s on this platform`);
|
|
1333
1369
|
}
|
|
1334
|
-
const
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1370
|
+
const mailboxAddress = action.desired.mailboxAddress;
|
|
1371
|
+
if (!mailboxAddress) {
|
|
1372
|
+
throw new Error(`zitadel:owner for ${org.slug}: no mailboxAddress -- the planner reports this as blocked, so it should not reach the executor`);
|
|
1373
|
+
}
|
|
1374
|
+
const inviteMail = this.ports.ownerInviteMail;
|
|
1375
|
+
if (!inviteMail) {
|
|
1376
|
+
throw new Error(`zitadel:owner for ${org.slug}: no ownerInviteMail port -- the owner's work mailbox cannot be opened yet, so the invite is not sent there`);
|
|
1377
|
+
}
|
|
1378
|
+
// foundry's own record of this owner's account, so a returning owner is not mistaken for a stranger.
|
|
1379
|
+
const invitedBefore = context.recordedIds()[`zitadel:owner:${action.key}`];
|
|
1380
|
+
const { userId } = await inviteOwnerWithLink({
|
|
1381
|
+
zitadel: this.ports.zitadel,
|
|
1382
|
+
mail: inviteMail,
|
|
1383
|
+
orgId: zitadelOrgId,
|
|
1384
|
+
authIssuer,
|
|
1385
|
+
owner: {
|
|
1386
|
+
givenName: action.desired.givenName,
|
|
1387
|
+
familyName: action.desired.familyName,
|
|
1388
|
+
mailboxAddress,
|
|
1389
|
+
personalEmail: action.desired.email,
|
|
1390
|
+
},
|
|
1391
|
+
label: `zitadel:owner for ${org.slug}`,
|
|
1392
|
+
onSecret: (value) => this.handledSecrets.add(value),
|
|
1393
|
+
...(invitedBefore ? { invitedUserId: invitedBefore } : {}),
|
|
1394
|
+
});
|
|
1339
1395
|
// An owner invited after chat was built never reaches `coolifyChat`
|
|
1340
1396
|
// again, so the role is granted here once the messaging project exists.
|
|
1341
1397
|
const messagingProjectId = context.recordedIds()[ZITADEL_MESSAGING_PROJECT_SCOPE_KEY];
|
|
@@ -1868,7 +1924,19 @@ export class FoundryStepExecutor {
|
|
|
1868
1924
|
const recordedAppId = context.recordedIds()[`coolify:app:${action.key}`];
|
|
1869
1925
|
if (action.desired.enabled === false) {
|
|
1870
1926
|
if (!recordedAppId) {
|
|
1871
|
-
throw new Error(`coolify:app for ${org.slug}: no live app recorded, nothing to park`);
|
|
1927
|
+
throw new Error(`coolify:app for ${org.slug}: no live app recorded, nothing to ${action.desired.delete === true ? 'delete' : 'park'}`);
|
|
1928
|
+
}
|
|
1929
|
+
// `planApp` sets `delete` only on the retired default app. A 404 counts
|
|
1930
|
+
// as deleted, so a retry after a crash past the Coolify delete succeeds.
|
|
1931
|
+
if (action.desired.delete === true) {
|
|
1932
|
+
try {
|
|
1933
|
+
await this.ports.coolify.deleteApplication(recordedAppId);
|
|
1934
|
+
}
|
|
1935
|
+
catch (error) {
|
|
1936
|
+
if (!isNotFoundError(error))
|
|
1937
|
+
throw error;
|
|
1938
|
+
}
|
|
1939
|
+
return {};
|
|
1872
1940
|
}
|
|
1873
1941
|
await this.ports.coolify.raw('POST', `/applications/${recordedAppId}/stop`);
|
|
1874
1942
|
return { resourceId: recordedAppId };
|
|
@@ -1876,10 +1944,12 @@ export class FoundryStepExecutor {
|
|
|
1876
1944
|
const entry = org.hosting.entries.find((e) => appStepKey(org, e.key) === action.key);
|
|
1877
1945
|
if (!entry)
|
|
1878
1946
|
throw new Error(`coolify:app for ${org.slug}: no hosting entry for ${action.key}`);
|
|
1879
|
-
//
|
|
1880
|
-
//
|
|
1881
|
-
|
|
1882
|
-
|
|
1947
|
+
// `planApp` blocks a `repo: null` entry at plan time; reaching here with
|
|
1948
|
+
// one means a stale or hand-built plan.
|
|
1949
|
+
if (entry.repo === null) {
|
|
1950
|
+
throw new Error(`coolify:app for ${org.slug}: entry '${entry.key}' has no repo`);
|
|
1951
|
+
}
|
|
1952
|
+
const appRepo = entry.repo;
|
|
1883
1953
|
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
1884
1954
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
1885
1955
|
// The recorded id first; the name when nothing is recorded, or when the
|
|
@@ -1953,9 +2023,6 @@ export class FoundryStepExecutor {
|
|
|
1953
2023
|
// copies `base_directory`, and reads `publish_directory` only for a
|
|
1954
2024
|
// built app.
|
|
1955
2025
|
base_directory: entry.folder === null ? undefined : `/${entry.folder}`,
|
|
1956
|
-
// Only the shared standard app builds from this repo; a bespoke
|
|
1957
|
-
// app's Dockerfile is at its own repo's root.
|
|
1958
|
-
dockerfile_location: entry.repo ? undefined : DEFAULT_APP_DOCKERFILE,
|
|
1959
2026
|
});
|
|
1960
2027
|
appId = created.uuid;
|
|
1961
2028
|
// Create sent every setting, so nothing is left to patch.
|
|
@@ -2500,6 +2567,23 @@ export class FoundryStepExecutor {
|
|
|
2500
2567
|
// Last: a run that failed above plans the step again.
|
|
2501
2568
|
context.recordScopeId(brokerEntryAppliedScopeKey(entryKey), JSON.stringify(want));
|
|
2502
2569
|
}
|
|
2570
|
+
/**
|
|
2571
|
+
* `null` once the app already has `AUTHZ_PEER_PASSWORD_OPERATOR`: minted only
|
|
2572
|
+
* the first time and never rotated, like {@link ensureAuthCookieSecret}.
|
|
2573
|
+
*/
|
|
2574
|
+
async ensurePeerPassword(appId) {
|
|
2575
|
+
return this.mintIfAbsent(appId, BOULE_PEER_PASSWORD_ENV, 24);
|
|
2576
|
+
}
|
|
2577
|
+
/** Same rule for the secret Archon's invite form sends to Boule. */
|
|
2578
|
+
async ensureBridgeSecret(appId) {
|
|
2579
|
+
return this.mintIfAbsent(appId, BOULE_BRIDGE_SECRET_ENV, 32);
|
|
2580
|
+
}
|
|
2581
|
+
async mintIfAbsent(appId, key, bytes) {
|
|
2582
|
+
const envs = await this.ports.coolify.listEnvs(appId);
|
|
2583
|
+
if (envs.some((e) => e.key === key && !e.is_preview))
|
|
2584
|
+
return null;
|
|
2585
|
+
return randomBytes(bytes).toString('base64url');
|
|
2586
|
+
}
|
|
2503
2587
|
/**
|
|
2504
2588
|
* `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
|
|
2505
2589
|
* only the first time (task brief), never regenerated or overwritten, so a
|
|
@@ -4714,7 +4798,29 @@ export class FoundryStepExecutor {
|
|
|
4714
4798
|
const appKey = await this.ensureAuthAppKey(app.appId);
|
|
4715
4799
|
if (appKey)
|
|
4716
4800
|
envs.push({ key: 'AUTH_APP_KEY', value: appKey });
|
|
4717
|
-
|
|
4801
|
+
// Archon's way into this Boule's people store: only the platform's own
|
|
4802
|
+
// org's Boule gets it, minted once and never rotated (the role Boule's boot
|
|
4803
|
+
// creates from it would otherwise keep the old password).
|
|
4804
|
+
const peerPassword = action.desired.platformOwner === true ? await this.ensurePeerPassword(app.appId) : null;
|
|
4805
|
+
if (peerPassword) {
|
|
4806
|
+
this.handledSecrets.add(peerPassword);
|
|
4807
|
+
envs.push({ key: BOULE_PEER_PASSWORD_ENV, value: peerPassword });
|
|
4808
|
+
}
|
|
4809
|
+
// Archon's invite form calls Boule with this secret; same rule as the password.
|
|
4810
|
+
const bridgeSecret = action.desired.platformOwner === true ? await this.ensureBridgeSecret(app.appId) : null;
|
|
4811
|
+
if (bridgeSecret) {
|
|
4812
|
+
this.handledSecrets.add(bridgeSecret);
|
|
4813
|
+
envs.push({ key: BOULE_BRIDGE_SECRET_ENV, value: bridgeSecret });
|
|
4814
|
+
}
|
|
4815
|
+
try {
|
|
4816
|
+
await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
|
|
4817
|
+
}
|
|
4818
|
+
catch (error) {
|
|
4819
|
+
const minted = [peerPassword, bridgeSecret].filter((v) => v !== null);
|
|
4820
|
+
if (minted.length === 0)
|
|
4821
|
+
throw error;
|
|
4822
|
+
throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), minted));
|
|
4823
|
+
}
|
|
4718
4824
|
const dbPush = await this.pushDatabaseUrl(app.appId, dbId, dbName);
|
|
4719
4825
|
if (!dbPush.pushed) {
|
|
4720
4826
|
throw new Error(`Boule for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
@@ -4730,6 +4836,8 @@ export class FoundryStepExecutor {
|
|
|
4730
4836
|
// that a previous apply created but crashed before deploying) sat built
|
|
4731
4837
|
// but never started. Deployed once this executor has confirmed, never
|
|
4732
4838
|
// again -- a live app is only ever restarted from here on.
|
|
4839
|
+
const secretsNotRestarted = action.desired.platformOwner === true &&
|
|
4840
|
+
!context.recordedIds()[BOULE_SECRETS_RESTARTED_SCOPE_KEY];
|
|
4733
4841
|
if (!context.recordedIds()[BOULE_DEPLOYED_SCOPE_KEY]) {
|
|
4734
4842
|
// #543: every deploy waits for health -- Boule's own public hostname.
|
|
4735
4843
|
const runtime = this.ports.runtime ?? {};
|
|
@@ -4738,10 +4846,21 @@ export class FoundryStepExecutor {
|
|
|
4738
4846
|
}
|
|
4739
4847
|
else if (app.existed &&
|
|
4740
4848
|
!mail.restarted &&
|
|
4741
|
-
(envChanged ||
|
|
4849
|
+
(envChanged ||
|
|
4850
|
+
cookieSecret !== null ||
|
|
4851
|
+
appKey !== null ||
|
|
4852
|
+
peerPassword !== null ||
|
|
4853
|
+
bridgeSecret !== null ||
|
|
4854
|
+
secretsNotRestarted ||
|
|
4855
|
+
dbPush.changed ||
|
|
4856
|
+
mail.changed)) {
|
|
4742
4857
|
// A restart inside the mail rule already came after every env write above.
|
|
4743
4858
|
await this.ports.coolify.restart(app.appId);
|
|
4744
4859
|
}
|
|
4860
|
+
// Last, after the restart or deploy: a run that failed before here repeats it, minting nothing.
|
|
4861
|
+
if (action.desired.platformOwner === true) {
|
|
4862
|
+
context.recordScopeId(BOULE_SECRETS_RESTARTED_SCOPE_KEY, '1');
|
|
4863
|
+
}
|
|
4745
4864
|
return { resourceId: app.appId };
|
|
4746
4865
|
}
|
|
4747
4866
|
/**
|
|
@@ -28,7 +28,7 @@ const CALLS_HEALTH_PATH = '/healthz';
|
|
|
28
28
|
function alwaysOnTargets(org, platform) {
|
|
29
29
|
// The platform's own org signs in on the shared instance host, not its own
|
|
30
30
|
// `loginHostnameFor` (#488; `login-host.ts`, `diff.ts`'s `planLoginHost`).
|
|
31
|
-
const loginHost = platform && isPlatformOwnOrg(org, platform.
|
|
31
|
+
const loginHost = platform && isPlatformOwnOrg(org, platform.ownerOrgId)
|
|
32
32
|
? platform.auth.hostname
|
|
33
33
|
: loginHostnameFor(org);
|
|
34
34
|
return [
|
package/dist/plan/index.d.ts
CHANGED
|
@@ -12,8 +12,9 @@ export type { PlatformOrgClientFactories } from './platform-org-ports.js';
|
|
|
12
12
|
export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
|
|
13
13
|
export type { AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot } from './adopt.js';
|
|
14
14
|
export { AdoptRefusedError } from './adopt.js';
|
|
15
|
-
export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan-apply.js';
|
|
16
|
-
export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
|
|
15
|
+
export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, FoundryApplyOptions, } from './plan-apply.js';
|
|
16
|
+
export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
|
|
17
|
+
export type { UnconfirmedUpgrade } from './confirm-upgrade.js';
|
|
17
18
|
export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, } from './platform-providers.js';
|
|
18
19
|
export type { PlatformAuthDeps, PlatformAuthSmtp, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformDnsFactory, PlatformAccessFactory, PlatformAccessPort, PlatformTunnelsFactory, PlatformEdgeRuntime, } from './platform-providers.js';
|
|
19
20
|
export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './platform-providers.js';
|
|
@@ -40,6 +41,8 @@ export type { MailAllowlistApp } from './mail-identity-allowlist.js';
|
|
|
40
41
|
export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
|
|
41
42
|
export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, } from './executor.js';
|
|
42
43
|
export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
|
|
44
|
+
export type { EnsureSignInOrgOptions } from './sign-in-org.js';
|
|
45
|
+
export { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ensureSignInOrg, registerSignInDomain, signInOrgDomain, } from './sign-in-org.js';
|
|
43
46
|
export type { HealthTarget, HealthTargetDepth, HealthTargetExpect } from './health-targets.js';
|
|
44
47
|
export { healthTargets, platformHealthTargets } from './health-targets.js';
|
|
45
48
|
export { createEdgeDns, edgeDnsRecordOwners, EDGE_DNS_WRITE_CONCURRENCY } from './edge-dns.js';
|
package/dist/plan/index.js
CHANGED
|
@@ -5,7 +5,7 @@ export { planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDUL
|
|
|
5
5
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
|
|
6
6
|
export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
|
|
7
7
|
export { AdoptRefusedError } from './adopt.js';
|
|
8
|
-
export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
|
|
8
|
+
export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
|
|
9
9
|
export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, } from './platform-providers.js';
|
|
10
10
|
// #171: how a host signs files management requests with the delivered key.
|
|
11
11
|
export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './platform-providers.js';
|
|
@@ -21,6 +21,7 @@ export { loginHostnameFor } from './login-host.js';
|
|
|
21
21
|
export { checkMailIdentityAudience } from './mail-identity.js';
|
|
22
22
|
export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
|
|
23
23
|
export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
|
|
24
|
+
export { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ensureSignInOrg, registerSignInDomain, signInOrgDomain, } from './sign-in-org.js';
|
|
24
25
|
export { healthTargets, platformHealthTargets } from './health-targets.js';
|
|
25
26
|
export { createEdgeDns, edgeDnsRecordOwners, EDGE_DNS_WRITE_CONCURRENCY } from './edge-dns.js';
|
|
26
27
|
export { createEdgeAccess } from './edge-access.js';
|
|
@@ -7,6 +7,7 @@ import type { AppliedState } from '../state.js';
|
|
|
7
7
|
import { type AppliedVersionsStore, type DeclaredOrgStore, type PlatformStore } from '../store.js';
|
|
8
8
|
import { type AdoptCandidate, type AdoptInput, type AdoptPlan } from './adopt.js';
|
|
9
9
|
import { type AgoraHostPort } from './agora.js';
|
|
10
|
+
import { type UnconfirmedUpgrade } from './confirm-upgrade.js';
|
|
10
11
|
import { type StepAction } from './diff.js';
|
|
11
12
|
import { type LiveReaders, type LiveResource, type PlatformLiveReaders } from './live.js';
|
|
12
13
|
import { type EdgePortName, type HeldKeyResolver } from './platform-org.js';
|
|
@@ -171,6 +172,12 @@ export interface StepExecutor {
|
|
|
171
172
|
* an executor without it supplies none.
|
|
172
173
|
*/
|
|
173
174
|
ownEdgePorts?(): Partial<Record<EdgePortName, boolean>>;
|
|
175
|
+
/**
|
|
176
|
+
* Every secret this executor minted or read so far (peer password, bridge
|
|
177
|
+
* secret, invite code and link). The host's run adds them to its redactor
|
|
178
|
+
* after each step, so an error or progress line that quotes one is masked.
|
|
179
|
+
*/
|
|
180
|
+
secretsHandled?(): readonly string[];
|
|
174
181
|
}
|
|
175
182
|
/**
|
|
176
183
|
* #167: {@link StepExecutionContext}'s platform-scoped counterpart -- what
|
|
@@ -313,6 +320,15 @@ export declare class StalePlanError extends Error {
|
|
|
313
320
|
export declare class ConfirmationRequiredError extends Error {
|
|
314
321
|
constructor(orgId: OrgId);
|
|
315
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* A plan holds a version change that needs confirmation
|
|
325
|
+
* (`VersionChangeItem.needsConfirmation`) and `confirmUpgrade` does not name
|
|
326
|
+
* its service exactly. `upgrades` lists every such service with its versions.
|
|
327
|
+
*/
|
|
328
|
+
export declare class UpgradeConfirmationRequiredError extends Error {
|
|
329
|
+
readonly upgrades: readonly UnconfirmedUpgrade[];
|
|
330
|
+
constructor(upgrades: readonly UnconfirmedUpgrade[]);
|
|
331
|
+
}
|
|
316
332
|
export declare class NotAuthorizedError extends Error {
|
|
317
333
|
constructor(actor: AuditActor, orgId: OrgId);
|
|
318
334
|
}
|
|
@@ -336,6 +352,11 @@ export declare class PlatformStalePlanError extends Error {
|
|
|
336
352
|
export declare class PlatformConfirmationRequiredError extends Error {
|
|
337
353
|
constructor();
|
|
338
354
|
}
|
|
355
|
+
/** The third argument of `apply` and `applyPlatform`. */
|
|
356
|
+
export interface FoundryApplyOptions {
|
|
357
|
+
/** Service names (`VersionChangeItem.service`), matched exactly, whose version change the operator confirmed. */
|
|
358
|
+
confirmUpgrade?: readonly string[];
|
|
359
|
+
}
|
|
339
360
|
export interface FoundryPlanApplyOptions {
|
|
340
361
|
store: DeclaredOrgStore;
|
|
341
362
|
readers: LiveReaders;
|
|
@@ -380,6 +401,12 @@ export interface FoundryPlanApplyOptions {
|
|
|
380
401
|
* reported blocked instead of planned.
|
|
381
402
|
*/
|
|
382
403
|
edgeWorkers?: boolean;
|
|
404
|
+
/**
|
|
405
|
+
* True when the host's executor was built with `ownerInviteMail`. Optional,
|
|
406
|
+
* and false without it: each owner's invite is then reported blocked
|
|
407
|
+
* instead of planned.
|
|
408
|
+
*/
|
|
409
|
+
ownerInviteMail?: boolean;
|
|
383
410
|
/**
|
|
384
411
|
* #177: true when the host's executor was built with its own `filesOrganisation`
|
|
385
412
|
* / `aiOrganisation` port. Without one, the org step needs the platform's
|
|
@@ -393,6 +420,15 @@ export interface FoundryPlanApplyOptions {
|
|
|
393
420
|
* reported blocked instead of planned to fail.
|
|
394
421
|
*/
|
|
395
422
|
edge?: boolean;
|
|
423
|
+
/**
|
|
424
|
+
* Narrows every org plan to the steps `step` accepts, and its blocked items
|
|
425
|
+
* to the keys `blocked` accepts, so a plan, its stale check and its apply
|
|
426
|
+
* all see the same slice. Absent: the whole plan.
|
|
427
|
+
*/
|
|
428
|
+
onlySteps?: {
|
|
429
|
+
step(action: StepAction): boolean;
|
|
430
|
+
blocked(key: string): boolean;
|
|
431
|
+
};
|
|
396
432
|
/** Overridable for tests; defaults to `() => new Date()`. */
|
|
397
433
|
now?: () => Date;
|
|
398
434
|
/** #441: how long a mutation lease lasts before another caller may take it over. Defaults to 30 minutes. */
|
|
@@ -471,9 +507,11 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
471
507
|
private readonly zitadelIdTokenUserinfo;
|
|
472
508
|
private readonly broker;
|
|
473
509
|
private readonly edgeWorkers;
|
|
510
|
+
private readonly ownerInviteMail;
|
|
474
511
|
private readonly hostFiles;
|
|
475
512
|
private readonly hostAi;
|
|
476
513
|
private readonly hostEdge;
|
|
514
|
+
private readonly onlySteps;
|
|
477
515
|
private readonly now;
|
|
478
516
|
private readonly leaseTtlMs;
|
|
479
517
|
private readonly appliedVersions;
|
|
@@ -518,7 +556,7 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
518
556
|
*/
|
|
519
557
|
release(id: OrgId, entryKey: string): Promise<AppliedState>;
|
|
520
558
|
private computeAdopt;
|
|
521
|
-
apply(plan: Plan, confirmedOrgName: string): Promise<AppliedState>;
|
|
559
|
+
apply(plan: Plan, confirmedOrgName: string, options?: FoundryApplyOptions): Promise<AppliedState>;
|
|
522
560
|
/**
|
|
523
561
|
* After an org apply: gives the platform files runner a changed share-domain
|
|
524
562
|
* set (its Coolify `domains` and `FILES_PUBLIC_HOSTS`, derived from the
|
|
@@ -543,7 +581,7 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
543
581
|
* shape of their own. `confirmedName` must be the literal word `platform`
|
|
544
582
|
* rather than a declared name -- `DeclaredPlatform` has none.
|
|
545
583
|
*/
|
|
546
|
-
applyPlatform(plan: Plan, confirmedName: string): Promise<{
|
|
584
|
+
applyPlatform(plan: Plan, confirmedName: string, options?: FoundryApplyOptions): Promise<{
|
|
547
585
|
serviceIds: Record<string, string>;
|
|
548
586
|
}>;
|
|
549
587
|
/**
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -6,6 +6,7 @@ import { AlreadyRunningError, StepFailedError } from '../runner/errors.js';
|
|
|
6
6
|
import { StaleLeaseError, } from '../store.js';
|
|
7
7
|
import { AdoptRefusedError, adoptCandidates, adoptedKeysUnreadable, adoptedScopeKey, parseAdoptSnapshot, planAdoptFrom, } from './adopt.js';
|
|
8
8
|
import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
|
|
9
|
+
import { unconfirmedUpgrades } from './confirm-upgrade.js';
|
|
9
10
|
import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
|
|
10
11
|
import { entryAiUndeliveredScopeKey } from './entry-ai-key.js';
|
|
11
12
|
import { BOULE_EDGE_ENTRY_KEY, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
|
|
@@ -53,6 +54,21 @@ export class ConfirmationRequiredError extends Error {
|
|
|
53
54
|
this.name = 'ConfirmationRequiredError';
|
|
54
55
|
}
|
|
55
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* A plan holds a version change that needs confirmation
|
|
59
|
+
* (`VersionChangeItem.needsConfirmation`) and `confirmUpgrade` does not name
|
|
60
|
+
* its service exactly. `upgrades` lists every such service with its versions.
|
|
61
|
+
*/
|
|
62
|
+
export class UpgradeConfirmationRequiredError extends Error {
|
|
63
|
+
upgrades;
|
|
64
|
+
constructor(upgrades) {
|
|
65
|
+
super(`the plan changes versions that must be confirmed by service name: ${upgrades
|
|
66
|
+
.map((u) => `${u.service} ${u.from ?? 'unknown'} -> ${u.to ?? 'unknown'}`)
|
|
67
|
+
.join(', ')}; pass confirmUpgrade naming each one`);
|
|
68
|
+
this.upgrades = upgrades;
|
|
69
|
+
this.name = 'UpgradeConfirmationRequiredError';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
56
72
|
export class NotAuthorizedError extends Error {
|
|
57
73
|
constructor(actor, orgId) {
|
|
58
74
|
super(`${actor.display} does not hold the permission to apply org ${orgId} (#9, #19)`);
|
|
@@ -124,9 +140,11 @@ export class FoundryPlanApply {
|
|
|
124
140
|
zitadelIdTokenUserinfo;
|
|
125
141
|
broker;
|
|
126
142
|
edgeWorkers;
|
|
143
|
+
ownerInviteMail;
|
|
127
144
|
hostFiles;
|
|
128
145
|
hostAi;
|
|
129
146
|
hostEdge;
|
|
147
|
+
onlySteps;
|
|
130
148
|
now;
|
|
131
149
|
leaseTtlMs;
|
|
132
150
|
appliedVersions;
|
|
@@ -146,9 +164,11 @@ export class FoundryPlanApply {
|
|
|
146
164
|
this.zitadelIdTokenUserinfo = options.zitadelIdTokenUserinfo ?? false;
|
|
147
165
|
this.broker = options.broker ?? false;
|
|
148
166
|
this.edgeWorkers = options.edgeWorkers ?? false;
|
|
167
|
+
this.ownerInviteMail = options.ownerInviteMail ?? false;
|
|
149
168
|
this.hostFiles = options.filesOrganisation ?? false;
|
|
150
169
|
this.hostAi = options.aiOrganisation ?? false;
|
|
151
170
|
this.hostEdge = options.edge ?? false;
|
|
171
|
+
this.onlySteps = options.onlySteps;
|
|
152
172
|
this.now = options.now ?? (() => new Date());
|
|
153
173
|
this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
|
|
154
174
|
this.appliedVersions = options.appliedVersions;
|
|
@@ -341,7 +361,7 @@ export class FoundryPlanApply {
|
|
|
341
361
|
previous,
|
|
342
362
|
};
|
|
343
363
|
}
|
|
344
|
-
async apply(plan, confirmedOrgName) {
|
|
364
|
+
async apply(plan, confirmedOrgName, options = {}) {
|
|
345
365
|
const orgId = plan.orgId;
|
|
346
366
|
const authorized = await this.authz.canApply(this.actor, orgId);
|
|
347
367
|
if (!authorized) {
|
|
@@ -354,6 +374,11 @@ export class FoundryPlanApply {
|
|
|
354
374
|
await this.refuse(plan, 'stale: live state has moved since this plan was computed');
|
|
355
375
|
throw new StalePlanError(orgId);
|
|
356
376
|
}
|
|
377
|
+
const unconfirmed = unconfirmedUpgrades(freshPlan.versionChanges, options.confirmUpgrade);
|
|
378
|
+
if (unconfirmed.length > 0) {
|
|
379
|
+
await this.refuse(plan, `version change without confirmUpgrade naming: ${unconfirmed.map((u) => u.service).join(', ')}`);
|
|
380
|
+
throw new UpgradeConfirmationRequiredError(unconfirmed);
|
|
381
|
+
}
|
|
357
382
|
if (planNeedsConfirmation(plan.steps) &&
|
|
358
383
|
confirmedOrgName.trim() !== org.displayName.trim()) {
|
|
359
384
|
await this.refuse(plan, "destructive or migrating step without the org's name typed correctly");
|
|
@@ -426,7 +451,7 @@ export class FoundryPlanApply {
|
|
|
426
451
|
* shape of their own. `confirmedName` must be the literal word `platform`
|
|
427
452
|
* rather than a declared name -- `DeclaredPlatform` has none.
|
|
428
453
|
*/
|
|
429
|
-
async applyPlatform(plan, confirmedName) {
|
|
454
|
+
async applyPlatform(plan, confirmedName, options = {}) {
|
|
430
455
|
const { store, executor } = this.requirePlatformDeps();
|
|
431
456
|
const declared = await store.get();
|
|
432
457
|
if (!declared)
|
|
@@ -449,6 +474,11 @@ export class FoundryPlanApply {
|
|
|
449
474
|
await this.refusePlatform(plan, platform.ownerOrgId, 'stale: live state has moved since this plan was computed');
|
|
450
475
|
throw new PlatformStalePlanError();
|
|
451
476
|
}
|
|
477
|
+
const unconfirmed = unconfirmedUpgrades(freshPlan.versionChanges, options.confirmUpgrade);
|
|
478
|
+
if (unconfirmed.length > 0) {
|
|
479
|
+
await this.refusePlatform(plan, platform.ownerOrgId, `version change without confirmUpgrade naming: ${unconfirmed.map((u) => u.service).join(', ')}`);
|
|
480
|
+
throw new UpgradeConfirmationRequiredError(unconfirmed);
|
|
481
|
+
}
|
|
452
482
|
if (planNeedsConfirmation(plan.steps) && confirmedName.trim() !== 'platform') {
|
|
453
483
|
await this.refusePlatform(plan, platform.ownerOrgId, "destructive or migrating step without 'platform' typed correctly");
|
|
454
484
|
throw new PlatformConfirmationRequiredError();
|
|
@@ -803,6 +833,10 @@ export class FoundryPlanApply {
|
|
|
803
833
|
const { system, kind, key } = planned.action;
|
|
804
834
|
serviceIds[`${system}:${kind}:${key}`] = result.resourceId;
|
|
805
835
|
}
|
|
836
|
+
else if (planned.action.desired.delete === true) {
|
|
837
|
+
const { system, kind, key } = planned.action;
|
|
838
|
+
delete serviceIds[`${system}:${kind}:${key}`];
|
|
839
|
+
}
|
|
806
840
|
checkpoints.push({ step: planned.step.description, completedAt: this.now().toISOString() });
|
|
807
841
|
}
|
|
808
842
|
}
|
|
@@ -881,6 +915,7 @@ export class FoundryPlanApply {
|
|
|
881
915
|
zitadelIdTokenUserinfo: this.zitadelIdTokenUserinfo,
|
|
882
916
|
broker: this.broker,
|
|
883
917
|
edgeWorkers: this.edgeWorkers,
|
|
918
|
+
ownerInviteMail: this.ownerInviteMail,
|
|
884
919
|
otherSlugs,
|
|
885
920
|
now: () => this.now().getTime(),
|
|
886
921
|
archonUrl,
|
|
@@ -896,6 +931,11 @@ export class FoundryPlanApply {
|
|
|
896
931
|
key: 'hosting:invalid',
|
|
897
932
|
});
|
|
898
933
|
}
|
|
934
|
+
if (this.onlySteps) {
|
|
935
|
+
const { step, blocked } = this.onlySteps;
|
|
936
|
+
detail.plannedSteps = detail.plannedSteps.filter(({ action }) => step(action));
|
|
937
|
+
detail.unmanaged = detail.unmanaged.filter((item) => item.key === undefined || blocked(item.key));
|
|
938
|
+
}
|
|
899
939
|
return { org, plan: toPlan(detail), detail };
|
|
900
940
|
}
|
|
901
941
|
/**
|
|
@@ -1459,6 +1499,7 @@ function canonical(plan) {
|
|
|
1459
1499
|
// version-change line must still be caught as stale, the same as any
|
|
1460
1500
|
// other field here.
|
|
1461
1501
|
versionChanges: [...(plan.versionChanges ?? [])].sort((x, y) => x.description.localeCompare(y.description)),
|
|
1502
|
+
notes: [...(plan.notes ?? [])].sort((x, y) => x.key.localeCompare(y.key)),
|
|
1462
1503
|
};
|
|
1463
1504
|
}
|
|
1464
1505
|
/**
|