@wtfalch/foundry 0.42.1 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/bootstrap/versions.d.ts +2 -0
  2. package/dist/index.d.ts +8 -4
  3. package/dist/index.js +4 -2
  4. package/dist/migrations/0055_release_requests.sql +20 -0
  5. package/dist/migrations/0056_platform_archon_blue_green.sql +7 -0
  6. package/dist/migrations/0057_org_customers.sql +29 -0
  7. package/dist/org.d.ts +55 -0
  8. package/dist/org.js +90 -27
  9. package/dist/plan/archon-blue-green.d.ts +54 -0
  10. package/dist/plan/archon-blue-green.js +72 -0
  11. package/dist/plan/blue-green-apply.d.ts +82 -0
  12. package/dist/plan/blue-green-apply.js +360 -0
  13. package/dist/plan/blue-green.d.ts +223 -0
  14. package/dist/plan/blue-green.js +444 -0
  15. package/dist/plan/boule.d.ts +43 -2
  16. package/dist/plan/boule.js +48 -1
  17. package/dist/plan/broker-entries.d.ts +28 -0
  18. package/dist/plan/broker-entries.js +46 -1
  19. package/dist/plan/cloudflared-connector.d.ts +26 -0
  20. package/dist/plan/cloudflared-connector.js +37 -0
  21. package/dist/plan/diff.d.ts +20 -0
  22. package/dist/plan/diff.js +207 -20
  23. package/dist/plan/executor.d.ts +134 -2
  24. package/dist/plan/executor.js +517 -25
  25. package/dist/plan/hostnames.d.ts +7 -0
  26. package/dist/plan/hostnames.js +17 -2
  27. package/dist/plan/locked-hostnames.d.ts +11 -0
  28. package/dist/plan/locked-hostnames.js +13 -0
  29. package/dist/plan/mcp.d.ts +57 -0
  30. package/dist/plan/mcp.js +131 -0
  31. package/dist/plan/org-names.d.ts +2 -2
  32. package/dist/plan/org-names.js +16 -1
  33. package/dist/plan/plan-apply.d.ts +77 -0
  34. package/dist/plan/plan-apply.js +267 -5
  35. package/dist/plan/platform-diff.d.ts +9 -0
  36. package/dist/plan/platform-diff.js +24 -1
  37. package/dist/plan/platform-providers.d.ts +15 -1
  38. package/dist/plan/platform-providers.js +90 -5
  39. package/dist/plan/proxy-labels.d.ts +2 -2
  40. package/dist/plan/proxy-labels.js +14 -4
  41. package/dist/plan/release-host.d.ts +59 -0
  42. package/dist/plan/release-host.js +239 -0
  43. package/dist/plan/remote-database.d.ts +124 -0
  44. package/dist/plan/remote-database.js +373 -0
  45. package/dist/plan/worker-app-database.d.ts +148 -0
  46. package/dist/plan/worker-app-database.js +424 -0
  47. package/dist/plan/worker-app.d.ts +320 -0
  48. package/dist/plan/worker-app.js +792 -0
  49. package/dist/plan/zitadel-machine-user.d.ts +13 -1
  50. package/dist/plan/zitadel-machine-user.js +80 -0
  51. package/dist/platform.d.ts +11 -1
  52. package/dist/platform.js +4 -1
  53. package/dist/release/decide.d.ts +95 -0
  54. package/dist/release/decide.js +242 -0
  55. package/dist/release/health.d.ts +48 -0
  56. package/dist/release/health.js +61 -0
  57. package/dist/release/run.d.ts +40 -0
  58. package/dist/release/run.js +56 -0
  59. package/dist/release/state.d.ts +64 -0
  60. package/dist/release/state.js +126 -0
  61. package/dist/release/types.d.ts +54 -0
  62. package/dist/release/types.js +4 -0
  63. package/dist/requested-changes.d.ts +12 -3
  64. package/dist/requested-changes.js +22 -0
  65. package/dist/store/postgres.d.ts +8 -0
  66. package/dist/store/postgres.js +67 -8
  67. package/dist/store/schema.d.ts +188 -0
  68. package/dist/store/schema.js +15 -0
  69. package/dist/worker-app-config.d.ts +151 -0
  70. package/dist/worker-app-config.js +438 -0
  71. package/package.json +1 -1
  72. package/versions.digests.json +2 -1
  73. package/versions.json +2 -1
@@ -8,6 +8,7 @@ import { StaleLeaseError, } from '../store.js';
8
8
  import { AdoptRefusedError, adoptCandidates, adoptedKeysUnreadable, adoptedScopeKey, parseAdoptSnapshot, planAdoptFrom, } from './adopt.js';
9
9
  import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
10
10
  import { NetworkMoveBackupMissingError, SigninDbMoveBackupMissingError, UpgradeBackupMissingError, platformDatabasesForService, requireFreshBackupForSigninMove, requireFreshBackupsForMove, requireFreshBackupsForUpgrades, } from './backup-gate.js';
11
+ import { ARCHON_REPO, PRIMARY_SERVER_SCOPE_KEY, archonSubject, entrySubject, } from './blue-green.js';
11
12
  import { readLiveBuilds } from './build-record.js';
12
13
  import { unconfirmedUpgrades } from './confirm-upgrade.js';
13
14
  import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
@@ -15,7 +16,7 @@ import { entryAiUndeliveredScopeKey } from './entry-ai-key.js';
15
16
  import { BOULE_EDGE_ENTRY_KEY, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
16
17
  import { FILES_ROOT_UNDELIVERED_SCOPE_KEY, entryFilesUndeliveredScopeKey, } from './entry-files-key.js';
17
18
  import { readLivePlatformResources, readLiveResources, } from './live.js';
18
- import { lockedHostnames } from './locked-hostnames.js';
19
+ import { hostingNames } from './locked-hostnames.js';
19
20
  import { agoraIssuerHostFor, loginHostServesAgora, readLoginHostDiscovery, } from './login-host.js';
20
21
  import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_BASE, OWN_MAIL_SMTP_PORT_MAX, OWN_MAIL_SMTP_PORT_SCOPE_KEY, PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, isOwnMailSmtpPort, mailRelayFingerprint, } from './mail-relay.js';
21
22
  import { findNameClashes, nameClashMessage } from './org-names.js';
@@ -25,6 +26,9 @@ import { PLATFORM_ARCHON_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_FILE
25
26
  import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesForOwn, agoraHostValuesFromPlatform, dnsBlockedReason, heldOwnKeyRefs, heldPlatformKeyRefs, orgPlatformReadinessFor, orgPlatformReadinessFrom, ownerChatFrom, ownerFilesFrom, ownerSignInFrom, platformOwnerUserIdFrom, serviceGitRefsOf, } from './platform-org.js';
26
27
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY, } from './platform-providers.js';
27
28
  import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, } from './proxy-labels.js';
29
+ import { runSubjectRelease } from './release-host.js';
30
+ import { remoteDatabaseFactsFrom } from './remote-database.js';
31
+ import { liveWorkerApps, workerAppTraceScopeKeys } from './worker-app.js';
28
32
  import { liveWorkers, workerTraceScopeKeys } from './worker.js';
29
33
  /** #441: the lease scope that serializes every mutation of one company, and the one for the shared platform. */
30
34
  const companyScope = (orgId) => `company:${orgId}`;
@@ -119,6 +123,9 @@ export class PlatformConfirmationRequiredError extends Error {
119
123
  /** The steps that read an entry's hostnames: `computeFreshPlan` drops them for an org whose hosting is invalid. */
120
124
  const HOSTNAME_STEPS = new Set([
121
125
  'coolify:app',
126
+ 'coolify:bluegreen',
127
+ 'coolify:bluegreen-lb',
128
+ 'coolify:bluegreen-connector',
122
129
  'coolify:broker-entry',
123
130
  'coolify:database',
124
131
  'cloudflare:tunnel',
@@ -128,6 +135,8 @@ const HOSTNAME_STEPS = new Set([
128
135
  // #318 (F8): the route is itself hostname-shaped, validated against the
129
136
  // org's own domain by `validateEntryWorker`.
130
137
  'cloudflare:worker',
138
+ // ADR 0002: its host is hostname-shaped and validated by `validateEntryWorkerApp`.
139
+ 'cloudflare:worker-app',
131
140
  ]);
132
141
  /**
133
142
  * The #19 implementation of {@link PlanApply}: computes a plan against live
@@ -152,8 +161,13 @@ export class FoundryPlanApply {
152
161
  loginHostBindings;
153
162
  loginHostDiscovery;
154
163
  zitadelIdTokenUserinfo;
164
+ zitadelMcp;
155
165
  broker;
156
166
  edgeWorkers;
167
+ workerApps;
168
+ workerAppKeysUrl;
169
+ workerAppDatabase;
170
+ workerAppRegistryAccountId;
157
171
  ownerInviteMail;
158
172
  hostFiles;
159
173
  hostAi;
@@ -167,6 +181,7 @@ export class FoundryPlanApply {
167
181
  backupGate;
168
182
  platformDeps;
169
183
  runner;
184
+ releaseStore;
170
185
  constructor(options) {
171
186
  this.store = options.store;
172
187
  this.readers = options.readers;
@@ -180,8 +195,13 @@ export class FoundryPlanApply {
180
195
  this.loginHostBindings = options.loginHostBindings ?? false;
181
196
  this.loginHostDiscovery = options.loginHostDiscovery;
182
197
  this.zitadelIdTokenUserinfo = options.zitadelIdTokenUserinfo ?? false;
198
+ this.zitadelMcp = options.zitadelMcp ?? false;
183
199
  this.broker = options.broker ?? false;
184
200
  this.edgeWorkers = options.edgeWorkers ?? false;
201
+ this.workerApps = options.workerApps ?? false;
202
+ this.workerAppKeysUrl = options.workerAppKeysUrl;
203
+ this.workerAppDatabase = options.workerAppDatabase ?? false;
204
+ this.workerAppRegistryAccountId = options.workerAppRegistryAccountId;
185
205
  this.ownerInviteMail = options.ownerInviteMail ?? false;
186
206
  this.hostFiles = options.filesOrganisation ?? false;
187
207
  this.hostAi = options.aiOrganisation ?? false;
@@ -195,6 +215,7 @@ export class FoundryPlanApply {
195
215
  this.backupGate = options.backupGate;
196
216
  this.platformDeps = options.platform;
197
217
  this.runner = options.runner;
218
+ this.releaseStore = options.releaseStore;
198
219
  }
199
220
  async plan(id) {
200
221
  const { plan } = await this.computeFreshPlan(id);
@@ -689,6 +710,12 @@ export class FoundryPlanApply {
689
710
  delete serviceIds[scopeKey];
690
711
  }
691
712
  }
713
+ if (target.system === 'cloudflare' && target.kind === 'worker-app') {
714
+ const entryKey = target.key.slice(id.length + 1);
715
+ for (const scopeKey of workerAppTraceScopeKeys(entryKey)) {
716
+ delete serviceIds[scopeKey];
717
+ }
718
+ }
692
719
  steps[current] = {
693
720
  step: descriptions[current],
694
721
  status: 'finished',
@@ -953,6 +980,13 @@ export class FoundryPlanApply {
953
980
  }
954
981
  checkpoints.push({ step: planned.step.description, completedAt: this.now().toISOString() });
955
982
  }
983
+ // The steps made the structure; the release runner then moves traffic on it, on the same live ids.
984
+ await this.runOrgReleases(org, {
985
+ recordedIds: context.recordedIds,
986
+ recordScopeId: context.recordScopeId,
987
+ forgetId: (key) => context.forgetId?.(key),
988
+ persist: context.persistScopeIds,
989
+ }, fence);
956
990
  }
957
991
  catch (e) {
958
992
  // #9 (resolved): "it stops at the checkpoint... there is no automatic
@@ -1012,6 +1046,135 @@ export class FoundryPlanApply {
1012
1046
  });
1013
1047
  return applied;
1014
1048
  }
1049
+ /**
1050
+ * ADR 0001: one release run for each blue/green entry whose structure is applied, writing into `context`
1051
+ * (the live ids of the run that holds the lease). A weight the runner changed reaches every server's balancer
1052
+ * before this returns.
1053
+ */
1054
+ async runOrgReleases(org, context, fence) {
1055
+ const store = this.releaseStore;
1056
+ const primaryUuid = context.recordedIds()[PRIMARY_SERVER_SCOPE_KEY];
1057
+ if (!store || !this.executor.releaseHost || !primaryUuid)
1058
+ return;
1059
+ for (const entry of org.hosting.entries) {
1060
+ const subject = entrySubject(org, entry, context.recordedIds());
1061
+ if (!subject)
1062
+ continue;
1063
+ const host = this.executor.releaseHost(org, subject, context, () => this.renewLease(fence));
1064
+ if (!host)
1065
+ continue;
1066
+ await runSubjectRelease({
1067
+ store,
1068
+ scopeOwnerId: org.id,
1069
+ subject,
1070
+ host,
1071
+ context,
1072
+ now: this.now(),
1073
+ primaryUuid,
1074
+ });
1075
+ }
1076
+ }
1077
+ /** One release run for platform-archon, on the platform's live ids; see {@link runOrgReleases}. */
1078
+ async runPlatformReleases(platform, context, fence) {
1079
+ const store = this.releaseStore;
1080
+ const executor = this.platformDeps?.executor;
1081
+ const subject = archonSubject(platform, ARCHON_REPO);
1082
+ const primaryUuid = context.recordedIds()[PRIMARY_SERVER_SCOPE_KEY];
1083
+ if (!store || !executor?.releaseHost || !subject || !primaryUuid)
1084
+ return;
1085
+ const host = executor.releaseHost(platform, subject, context, () => this.renewLease(fence));
1086
+ if (!host)
1087
+ return;
1088
+ await runSubjectRelease({
1089
+ store,
1090
+ scopeOwnerId: 'platform',
1091
+ subject,
1092
+ host,
1093
+ context,
1094
+ now: this.now(),
1095
+ primaryUuid,
1096
+ });
1097
+ }
1098
+ async runPlatformReleaseTick() {
1099
+ const deps = this.platformDeps;
1100
+ const platform = await deps?.store.get();
1101
+ if (!deps || !platform)
1102
+ return;
1103
+ const empty = { orgId: platform.ownerOrgId, steps: [], unmanaged: [] };
1104
+ if ((await this.authz.canApplyPlatform?.(this.actor)) !== true) {
1105
+ await this.refusePlatform(empty, platform.ownerOrgId, 'not authorized to run the release tick');
1106
+ throw new NotAuthorizedError(this.actor, platform.ownerOrgId);
1107
+ }
1108
+ await this.withMutationLease(PLATFORM_SCOPE, (reason) => this.refusePlatform(empty, platform.ownerOrgId, reason), async (fence) => {
1109
+ const serviceIds = { ...(await deps.store.getServiceIds()) };
1110
+ await this.runPlatformReleases(platform, {
1111
+ recordedIds: () => ({ ...serviceIds }),
1112
+ recordScopeId: (key, value) => {
1113
+ serviceIds[key] = value;
1114
+ },
1115
+ forgetId: (key) => {
1116
+ delete serviceIds[key];
1117
+ },
1118
+ persist: () => deps.store.putServiceIds({ ...serviceIds }, fence),
1119
+ }, fence);
1120
+ });
1121
+ }
1122
+ /**
1123
+ * The recurring release run (ADR 0001): for every declared org with a blue/green entry, and the platform's
1124
+ * archon when it declares one, takes the open requests, reads the health signal and moves traffic. Foundry has
1125
+ * no scheduler of its own, so the host calls this every 10 minutes. An org that cannot run (the actor may not
1126
+ * apply it, an apply holds its lease, a provider call fails) is reported by id and does not stop the others.
1127
+ * A refusal is audited; a run that does nothing writes no audit row.
1128
+ */
1129
+ async runReleaseTick() {
1130
+ const results = [];
1131
+ for (const org of await this.store.list()) {
1132
+ if (!org.hosting.entries.some((entry) => entry.blueGreen))
1133
+ continue;
1134
+ try {
1135
+ await this.runOrgReleaseTick(org.id);
1136
+ results.push({ target: org.id });
1137
+ }
1138
+ catch (e) {
1139
+ results.push({ target: org.id, error: e instanceof Error ? e.message : String(e) });
1140
+ }
1141
+ }
1142
+ const platform = await this.platformDeps?.store.get();
1143
+ if (platform?.archon.enabled && platform.archon.blueGreen) {
1144
+ try {
1145
+ await this.runPlatformReleaseTick();
1146
+ results.push({ target: 'platform' });
1147
+ }
1148
+ catch (e) {
1149
+ results.push({ target: 'platform', error: e instanceof Error ? e.message : String(e) });
1150
+ }
1151
+ }
1152
+ return results;
1153
+ }
1154
+ async runOrgReleaseTick(id) {
1155
+ const empty = { orgId: id, steps: [], unmanaged: [] };
1156
+ if (!(await this.authz.canApply(this.actor, id))) {
1157
+ await this.refuse(empty, 'not authorized to run the release tick');
1158
+ throw new NotAuthorizedError(this.actor, id);
1159
+ }
1160
+ await this.withMutationLease(companyScope(id), (reason) => this.refuse(empty, reason), async (fence) => {
1161
+ const org = await this.store.get(id);
1162
+ const previous = await this.store.getAppliedState(id);
1163
+ if (!org || !previous)
1164
+ return;
1165
+ const serviceIds = { ...previous.serviceIds };
1166
+ await this.runOrgReleases(org, {
1167
+ recordedIds: () => ({ ...serviceIds }),
1168
+ recordScopeId: (key, value) => {
1169
+ serviceIds[key] = value;
1170
+ },
1171
+ forgetId: (key) => {
1172
+ delete serviceIds[key];
1173
+ },
1174
+ persist: () => this.store.putAppliedState({ ...previous, serviceIds: { ...serviceIds } }, fence),
1175
+ }, fence);
1176
+ });
1177
+ }
1015
1178
  /** An own org with calls on: the host's answer about its slot's ports. A failure or a slow answer is "not open". */
1016
1179
  async readSfuPortsOpen(org) {
1017
1180
  const slot = org.services?.sfuSlot;
@@ -1048,14 +1211,23 @@ export class FoundryPlanApply {
1048
1211
  : false;
1049
1212
  const sfuPortsOpen = await this.readSfuPortsOpen(org);
1050
1213
  const resumable = await this.ownResumableRefs(org);
1214
+ // Read only for an org that declares it; a failed read plans none and says so (`planBlueGreen`).
1215
+ const blueGreenServers = org.hosting.entries.some((e) => e.blueGreen) && this.executor.blueGreenServers
1216
+ ? await this.executor.blueGreenServers().catch(() => undefined)
1217
+ : undefined;
1051
1218
  const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
1052
1219
  loginHostBindings: this.loginHostBindings,
1053
1220
  loginHostServesDiscovery,
1054
1221
  zitadelIdTokenUserinfo: this.zitadelIdTokenUserinfo,
1222
+ zitadelMcp: this.zitadelMcp,
1055
1223
  broker: ownsServices(org) && this.hostOwnServices && this.executor.ownBrokerPort
1056
1224
  ? await this.executor.ownBrokerPort(org, recorded)
1057
1225
  : this.broker,
1058
1226
  edgeWorkers: this.edgeWorkers,
1227
+ workerApps: this.workerApps,
1228
+ workerAppKeysUrl: this.workerAppKeysUrl,
1229
+ workerAppDatabase: this.workerAppDatabase,
1230
+ workerAppRegistryAccountId: this.workerAppRegistryAccountId,
1059
1231
  ownerInviteMail: this.ownerInviteMail,
1060
1232
  otherSlugs,
1061
1233
  now: () => this.now().getTime(),
@@ -1066,6 +1238,20 @@ export class FoundryPlanApply {
1066
1238
  oikosZoneReady: platformReadiness.oikosIngressApplied === true,
1067
1239
  ownMailKeyHeld: await this.ownMailKeyHeld(org),
1068
1240
  ownMailKeyMintable: this.executor.ownMailKeyMintable?.() ?? false,
1241
+ customersDirectory: this.executor.customersDirectoryAvailable?.() ?? false,
1242
+ bouleArchonCredential: this.executor.bouleArchonCredentialAvailable?.() ?? false,
1243
+ ...(blueGreenServers ? { blueGreenServers } : {}),
1244
+ ...(blueGreenServers && org.hosting.entries.some((e) => e.blueGreen && e.database)
1245
+ ? {
1246
+ remoteDatabase: remoteDatabaseFactsFrom({
1247
+ org,
1248
+ servers: blueGreenServers,
1249
+ recorded,
1250
+ access: !platformReadiness.dnsBlocked ||
1251
+ ['dns', 'zones', 'access', 'tunnels'].every((port) => platformReadiness.ownEdgePorts?.[port]),
1252
+ }),
1253
+ }
1254
+ : {}),
1069
1255
  ownSigninDbKeysPuttable: this.executor.ownSigninDbKeysPuttable?.() ?? true,
1070
1256
  ownSigninDbBackupConfigured: this.executor.ownSigninDbBackupConfigured?.() ?? true,
1071
1257
  ...(this.backupGate
@@ -1149,10 +1335,7 @@ export class FoundryPlanApply {
1149
1335
  * which only the plan can read.
1150
1336
  */
1151
1337
  async invalidHosting(org) {
1152
- return validateHosting(org.hosting, {
1153
- domain: org.domain,
1154
- locked: lockedHostnames(org, await this.platformServiceHostnames()),
1155
- });
1338
+ return validateHosting(org.hosting, hostingNames(org, await this.platformServiceHostnames()));
1156
1339
  }
1157
1340
  async platformServiceHostnames() {
1158
1341
  const platform = await this.platformDeps?.store.get();
@@ -1391,8 +1574,21 @@ export class FoundryPlanApply {
1391
1574
  .map((org) => org.slug);
1392
1575
  const mailRelay = platform.mail.relay === true ? await this.mailRelayInputs() : null;
1393
1576
  const owner = await this.platformOwnerInput(platform);
1577
+ const archonBlueGreen = platform.archon.enabled && platform.archon.blueGreen && executor.archonBlueGreen
1578
+ ? await executor.archonBlueGreen().catch(() => ({ servers: undefined }))
1579
+ : null;
1394
1580
  const detail = computePlatformPlanDetail(platform, live, {
1395
1581
  owner,
1582
+ ...(archonBlueGreen
1583
+ ? {
1584
+ archonBlueGreen: {
1585
+ servers: archonBlueGreen.servers,
1586
+ ...('blocked' in archonBlueGreen && archonBlueGreen.blocked
1587
+ ? { blocked: archonBlueGreen.blocked }
1588
+ : {}),
1589
+ },
1590
+ }
1591
+ : {}),
1396
1592
  jobsObservable: readers.coolify !== undefined,
1397
1593
  sfuBlocked,
1398
1594
  callsOrgSlugs,
@@ -1515,6 +1711,14 @@ export class FoundryPlanApply {
1515
1711
  serviceIds[`${system}:${kind}:${key}`] = result.resourceId;
1516
1712
  }
1517
1713
  }
1714
+ await this.runPlatformReleases(platform, {
1715
+ recordedIds: context.recordedIds,
1716
+ recordScopeId: context.recordScopeId,
1717
+ forgetId: (key) => {
1718
+ delete serviceIds[key];
1719
+ },
1720
+ persist: () => store.putServiceIds(serviceIds, fence),
1721
+ }, fence);
1518
1722
  }
1519
1723
  catch (e) {
1520
1724
  // Whatever completed stays recorded, as an org's apply does. A lost
@@ -1600,6 +1804,8 @@ function destroyTargets(serviceIds, orgId) {
1600
1804
  'cloudflare:gate:',
1601
1805
  // #318: an entry's Worker and its route.
1602
1806
  'cloudflare:worker:',
1807
+ // ADR 0002: a Worker app's route, record, containers, keys and script.
1808
+ 'cloudflare:worker-app:',
1603
1809
  'coolify:database:',
1604
1810
  'coolify:app:',
1605
1811
  // An own files host's storage key: the token id is the step's recorded resource id.
@@ -1630,6 +1836,21 @@ function destroyTargets(serviceIds, orgId) {
1630
1836
  }
1631
1837
  continue;
1632
1838
  }
1839
+ if (prefix === 'cloudflare:worker-app:') {
1840
+ for (const [entryKey, scriptName] of liveWorkerApps(orgId, serviceIds)) {
1841
+ targets.push({
1842
+ system: 'cloudflare',
1843
+ kind: 'worker-app',
1844
+ key: `${orgId}:${entryKey}`,
1845
+ id: scriptName,
1846
+ });
1847
+ }
1848
+ continue;
1849
+ }
1850
+ // Blue/green: the colors, balancers, stage, database sidecars and extra connectors, by recorded id, before
1851
+ // the main database they connect to. Their keys carry no ':' (scope ids), so no prefix above finds them.
1852
+ if (prefix === 'coolify:database:')
1853
+ targets.push(...blueGreenDestroyTargets(serviceIds));
1633
1854
  for (const [resourceKey, id] of Object.entries(serviceIds)) {
1634
1855
  if (!resourceKey.startsWith(prefix) || !id)
1635
1856
  continue;
@@ -1745,10 +1966,44 @@ function destroyStepDescriptions(targets) {
1745
1966
  'delete the declared org',
1746
1967
  ];
1747
1968
  }
1969
+ /** Scope keys of the Coolify apps blue/green made: `bg.<entry>.app.<server>.<part>`, a database sidecar, a connector on another server. */
1970
+ const BLUE_GREEN_APP_KEY = /^(bg\.[^.:]+\.app\.[^.:]+\.[a-z]+|bgdb\.[^.:]+\.sidecar\.[^.:]+|cloudflare_tunnel_connector_app\.[^.:]+)$/;
1971
+ const BLUE_GREEN_DATABASE_KEY = /^bg\.[^.:]+\.stagedb$/;
1972
+ /** The database route's Cloudflare parts: its Access app, its service token (also one minted and never delivered) and its DNS record (recorded as the route). */
1973
+ const BLUE_GREEN_EDGE_KEYS = [
1974
+ [/^bgdb\.[^.:]+\.access$/, 'bg-access'],
1975
+ [/^bgdb\.[^.:]+\.(token|undelivered)$/, 'bg-token'],
1976
+ [/^bgdb\.[^.:]+\.route$/, 'bg-dns'],
1977
+ ];
1978
+ /** Every recorded blue/green app (then stage's database), in key order. A destroy target's `key` is the scope key itself. */
1979
+ function blueGreenDestroyTargets(serviceIds) {
1980
+ const keys = Object.keys(serviceIds).sort();
1981
+ const targets = [];
1982
+ for (const key of keys.filter((k) => BLUE_GREEN_APP_KEY.test(k))) {
1983
+ const id = serviceIds[key];
1984
+ if (id)
1985
+ targets.push({ system: 'coolify', kind: 'bg-app', key, id });
1986
+ }
1987
+ for (const key of keys.filter((k) => BLUE_GREEN_DATABASE_KEY.test(k))) {
1988
+ const id = serviceIds[key];
1989
+ if (id)
1990
+ targets.push({ system: 'coolify', kind: 'bg-database', key, id });
1991
+ }
1992
+ for (const [pattern, kind] of BLUE_GREEN_EDGE_KEYS) {
1993
+ for (const key of keys.filter((k) => pattern.test(k))) {
1994
+ const id = serviceIds[key];
1995
+ if (id)
1996
+ targets.push({ system: 'cloudflare', kind, key, id });
1997
+ }
1998
+ }
1999
+ return targets;
2000
+ }
1748
2001
  /** The `AppliedState.serviceIds` key a completed {@link DestroyTarget} should be removed from. */
1749
2002
  function destroyServiceIdKey(target) {
1750
2003
  if (target.kind === 'project' || target.kind === 'org')
1751
2004
  return target.key;
2005
+ if (target.kind.startsWith('bg-'))
2006
+ return target.key;
1752
2007
  if (target.kind === 'own-files-r2' && target.key === 'undelivered') {
1753
2008
  return OWN_FILES_R2_UNDELIVERED_SCOPE_KEY;
1754
2009
  }
@@ -1764,6 +2019,9 @@ const STEP_FIELDS = {
1764
2019
  'zitadel:owner': 'owners',
1765
2020
  'zitadel:customers': 'hosting.entries.uses.auth',
1766
2021
  'zitadel:id-token-userinfo': 'hosting.entries.uses.auth',
2022
+ 'zitadel:mcp-project': 'hosting.entries.uses.auth.mcp',
2023
+ 'zitadel:mcp-access': 'hosting.entries.uses.auth.mcp',
2024
+ 'zitadel:access-token-lifetime': 'hosting.entries.uses.auth.mcp',
1767
2025
  'coolify:broker-entry': 'hosting.entries.uses.auth',
1768
2026
  'coolify:mail': 'mail',
1769
2027
  'coolify:chat': 'chat.usesDomain',
@@ -1781,6 +2039,9 @@ const STEP_FIELDS = {
1781
2039
  'ai:key': 'ai',
1782
2040
  'cms:site': 'site',
1783
2041
  'coolify:app': 'hosting.enabled',
2042
+ 'coolify:bluegreen': 'hosting.entries.blueGreen',
2043
+ 'coolify:bluegreen-lb': 'hosting.entries.blueGreen',
2044
+ 'coolify:bluegreen-connector': 'hosting.entries.blueGreen',
1784
2045
  'coolify:database': 'hosting.database',
1785
2046
  'cloudflare:tunnel': 'hosting.tunnel',
1786
2047
  'cloudflare:zone': 'hosting.usesDomain',
@@ -1788,6 +2049,7 @@ const STEP_FIELDS = {
1788
2049
  'cloudflare:gate': 'hosting.entries.gates',
1789
2050
  'cloudflare:ratelimit': 'hosting.usesDomain',
1790
2051
  'cloudflare:worker': 'hosting.entries.worker',
2052
+ 'cloudflare:worker-app': 'hosting.entries.workerApp',
1791
2053
  };
1792
2054
  function stepField(action) {
1793
2055
  // An entry's ai key (F6a) is keyed `<orgId>:<entry>`; the org's own is `<orgId>`.
@@ -1,6 +1,7 @@
1
1
  import type { DeclaredOrg, EnvSharedSecret, HostingEntry, OrgId } from '../org.js';
2
2
  import { type Plan, type PlanNote, type UnmanagedItem, type VersionChangeItem } from '../plan.js';
3
3
  import type { DeclaredPlatform } from '../platform.js';
4
+ import { type BlueGreenServer } from './blue-green.js';
4
5
  import type { PlannedStep } from './diff.js';
5
6
  import type { LiveResource } from './live.js';
6
7
  import { type MailRoute, PLATFORM_MAIL_RELAY_KEY } from './mail-relay.js';
@@ -1092,6 +1093,14 @@ export declare function uptimeRecorded(recordedIds: Readonly<Record<string, stri
1092
1093
  * the merge comment that named this shape (#189).
1093
1094
  */
1094
1095
  export interface PlanPlatformOptions {
1096
+ /**
1097
+ * What `platform.archon.blueGreen` needs from the host: every Coolify server (`undefined`: it cannot list them)
1098
+ * and why the executor cannot run it, if it cannot. Absent: nothing is planned for it.
1099
+ */
1100
+ archonBlueGreen?: {
1101
+ servers: readonly BlueGreenServer[] | undefined;
1102
+ blocked?: string;
1103
+ };
1095
1104
  /** Whether the host supplies a Coolify reader for the platform (default `true`). */
1096
1105
  jobsObservable?: boolean;
1097
1106
  /** Why the shared SFU is blocked, or `null` when it is not (default `null`). */
@@ -3,6 +3,7 @@ import { VERSIONS } from '../bootstrap/versions.js';
3
3
  import { APP_ENTRY_KEY } from '../org.js';
4
4
  import { heldEnvRef } from '../org.js';
5
5
  import { VERSION_CHANGE_KIND, } from '../plan.js';
6
+ import { ARCHON_REPO, planArchonBlueGreen } from './blue-green.js';
6
7
  import { buildDriftNotes } from './build-record.js';
7
8
  import { versionChangeNeedsConfirmation } from './confirm-upgrade.js';
8
9
  import { ACCESS_PROVIDER_RECORD_KEY } from './gates.js';
@@ -1268,7 +1269,7 @@ export function uptimeRecorded(recordedIds) {
1268
1269
  * unchanged).
1269
1270
  */
1270
1271
  export function planPlatform(platform, live, options = {}) {
1271
- const { jobsObservable = true, sfuBlocked = null, callsOrgSlugs, mailRoutes = [], mailRoutesBlocked = [], recordedIds = {}, owner, liveBuilds = {}, blocked = {}, drifted = {}, } = options;
1272
+ const { jobsObservable = true, sfuBlocked = null, callsOrgSlugs, mailRoutes = [], mailRoutesBlocked = [], recordedIds = {}, owner, liveBuilds = {}, blocked = {}, drifted = {}, archonBlueGreen, } = options;
1272
1273
  const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
1273
1274
  const claimed = new Set();
1274
1275
  const plannedSteps = [];
@@ -1386,6 +1387,27 @@ export function planPlatform(platform, live, options = {}) {
1386
1387
  const uptime = planUptime(platform, recordedIds, blocked.uptime, blocked.uptimeSecretsHeld ?? [], Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_ARCHON_KEY)]), blocked.uptimeHeld, blocked.uptimeChat);
1387
1388
  if (uptime.step)
1388
1389
  plannedSteps.push(uptime.step);
1390
+ // Archon's app and tunnel are recorded by an earlier apply, so this runs before a last-placed archon step.
1391
+ const archonBg = archonBlueGreen
1392
+ ? planArchonBlueGreen({
1393
+ platform,
1394
+ repo: ARCHON_REPO,
1395
+ recorded: recordedIds,
1396
+ servers: archonBlueGreen.servers,
1397
+ executorBlocked: archonBlueGreen.blocked,
1398
+ })
1399
+ : { step: null, unmanaged: [] };
1400
+ if (archonBg.step) {
1401
+ plannedSteps.push({
1402
+ action: {
1403
+ system: 'coolify',
1404
+ kind: 'archon-bluegreen',
1405
+ key: archonBg.step.key,
1406
+ desired: archonBg.step.desired,
1407
+ },
1408
+ step: { description: archonBg.step.description, destroys: false, migrates: false },
1409
+ });
1410
+ }
1389
1411
  if (archon.step && blocked.archonRunsInsideApp)
1390
1412
  plannedSteps.push(archon.step);
1391
1413
  // After Archon's own step: a repair that fails must not hold up the work before it.
@@ -1487,6 +1509,7 @@ export function planPlatform(platform, live, options = {}) {
1487
1509
  unmanaged.push({ description: archonOrg.blocked, key: 'archon-org' });
1488
1510
  if (archon.blocked)
1489
1511
  unmanaged.push({ description: archon.blocked, key: 'archon' });
1512
+ unmanaged.push(...archonBg.unmanaged);
1490
1513
  // #398: the old integrations key stays pinned until Archon holds the new
1491
1514
  // id; name that when Archon is what is stopping it.
1492
1515
  if (archon.blocked &&
@@ -2,6 +2,7 @@ import type { CloudflareClient } from '@wtfalch/providers/cloudflare';
2
2
  import { type DeclaredOrg } from '../org.js';
3
3
  import type { DeclaredPlatform } from '../platform.js';
4
4
  import type { DeclaredOrgStore, PlatformStore } from '../store.js';
5
+ import { type BlueGreenServer, type BlueGreenSubject } from './blue-green.js';
5
6
  import { type DeployHealthRuntime } from './deploy-health.js';
6
7
  import type { StepAction } from './diff.js';
7
8
  import type { AccessPort } from './edge-access.js';
@@ -10,6 +11,7 @@ import type { RateLimitPort } from './edge-ratelimits.js';
10
11
  import type { TunnelPort } from './edge-tunnels.js';
11
12
  import type { ZonePort } from './edge-zones.js';
12
13
  import type { CloudflarePort, CoolifyPort, FoundryStepExecutorPorts, ZitadelPort } from './executor.js';
14
+ import { type ReleaseContext, type ReleaseHost } from './release-host.js';
13
15
  export { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
14
16
  import type { LiveResource, PlatformCloudflareReader, PlatformCoolifyReader } from './live.js';
15
17
  import { type OikosIngressPorts } from './oikos-ingress.js';
@@ -349,6 +351,14 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
349
351
  private onPlatform;
350
352
  /** `deps` with its Coolify port placed on the platform destination, then limited by `profile`. */
351
353
  private limitedOnPlatform;
354
+ /** Every Coolify server and why platform-archon's blue/green cannot run here, for the planner. */
355
+ archonBlueGreen(): Promise<{
356
+ servers?: BlueGreenServer[];
357
+ blocked?: string;
358
+ }>;
359
+ /** The release runner's outside calls for platform-archon (`plan/release-host.ts`). The token is only read. */
360
+ releaseHost(platform: DeclaredPlatform, subject: BlueGreenSubject, context: ReleaseContext, renew: () => Promise<void>): ReleaseHost | null;
361
+ private archonBlueGreenStep;
352
362
  blockedSteps(platform: DeclaredPlatform, recordedIds?: Readonly<Record<string, string>>, owner?: PlatformOwnerInput): Promise<{
353
363
  auth?: string;
354
364
  authExecutorMissing?: boolean;
@@ -896,7 +906,9 @@ export declare function ownerInviteBlockedReason(ownerCompany: DeclaredOrg | nul
896
906
  * {@link PlatformStepExecutionContext}, bundled like {@link PlatformAuthDeps}.
897
907
  */
898
908
  export interface PlatformArchonDeps {
899
- coolify: Pick<CoolifyPort, 'findProjectByName' | 'listEnvironments' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerfileApplication' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw' | 'bulkUpsertEnvs' | 'listEnvs' | 'deleteEnv' | 'updateApplication' | 'deploy' | 'deployAndWait' | 'getDeployment'>;
909
+ coolify: Pick<CoolifyPort, 'findProjectByName' | 'listEnvironments' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerfileApplication' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw' | 'bulkUpsertEnvs' | 'listEnvs' | 'deleteEnv' | 'updateApplication' | 'deploy' | 'deployAndWait' | 'getDeployment' | 'listStorages' | 'createPersistentStorage' | 'createFileStorage' | 'updateFileStorage'> & {
910
+ listApplicationDeployments?: CoolifyPort['listApplicationDeployments'];
911
+ };
900
912
  /** F4.dns: every DNS record call, through edge at its recorded address (see {@link PlatformDnsFactory}). */
901
913
  dns: PlatformDnsFactory;
902
914
  /** F4.tunnels: every tunnel call, through edge at its recorded address (see {@link PlatformTunnelsFactory}). */
@@ -972,6 +984,8 @@ export interface PlatformArchonRuntime {
972
984
  readyTimeoutMs?: number;
973
985
  /** Pause between readiness polls (default 5 seconds). */
974
986
  readyIntervalMs?: number;
987
+ /** Called with every secret the blue/green steps read or mint (the health token, copied env), so the host can redact it. */
988
+ onSecret?: (value: string) => void;
975
989
  }
976
990
  /**
977
991
  * The name of foundry's own Cloudflare Access app in front of Archon. Same