@wtfalch/foundry 0.42.1 → 0.43.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 (50) hide show
  1. package/dist/bootstrap/versions.d.ts +2 -0
  2. package/dist/index.d.ts +5 -1
  3. package/dist/index.js +3 -1
  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/org.d.ts +13 -0
  7. package/dist/org.js +38 -1
  8. package/dist/plan/archon-blue-green.d.ts +54 -0
  9. package/dist/plan/archon-blue-green.js +72 -0
  10. package/dist/plan/blue-green-apply.d.ts +82 -0
  11. package/dist/plan/blue-green-apply.js +360 -0
  12. package/dist/plan/blue-green.d.ts +223 -0
  13. package/dist/plan/blue-green.js +444 -0
  14. package/dist/plan/cloudflared-connector.d.ts +26 -0
  15. package/dist/plan/cloudflared-connector.js +37 -0
  16. package/dist/plan/diff.d.ts +6 -0
  17. package/dist/plan/diff.js +31 -2
  18. package/dist/plan/executor.d.ts +34 -0
  19. package/dist/plan/executor.js +244 -9
  20. package/dist/plan/plan-apply.d.ts +51 -0
  21. package/dist/plan/plan-apply.js +222 -0
  22. package/dist/plan/platform-diff.d.ts +9 -0
  23. package/dist/plan/platform-diff.js +24 -1
  24. package/dist/plan/platform-providers.d.ts +15 -1
  25. package/dist/plan/platform-providers.js +90 -5
  26. package/dist/plan/proxy-labels.d.ts +2 -2
  27. package/dist/plan/proxy-labels.js +12 -4
  28. package/dist/plan/release-host.d.ts +59 -0
  29. package/dist/plan/release-host.js +239 -0
  30. package/dist/plan/remote-database.d.ts +124 -0
  31. package/dist/plan/remote-database.js +373 -0
  32. package/dist/platform.d.ts +11 -1
  33. package/dist/platform.js +4 -1
  34. package/dist/release/decide.d.ts +95 -0
  35. package/dist/release/decide.js +242 -0
  36. package/dist/release/health.d.ts +48 -0
  37. package/dist/release/health.js +61 -0
  38. package/dist/release/run.d.ts +40 -0
  39. package/dist/release/run.js +56 -0
  40. package/dist/release/state.d.ts +64 -0
  41. package/dist/release/state.js +126 -0
  42. package/dist/release/types.d.ts +54 -0
  43. package/dist/release/types.js +4 -0
  44. package/dist/store/postgres.d.ts +8 -0
  45. package/dist/store/postgres.js +56 -3
  46. package/dist/store/schema.d.ts +154 -0
  47. package/dist/store/schema.js +12 -0
  48. package/package.json +1 -1
  49. package/versions.digests.json +2 -1
  50. 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';
@@ -25,6 +26,8 @@ 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';
28
31
  import { liveWorkers, workerTraceScopeKeys } from './worker.js';
29
32
  /** #441: the lease scope that serializes every mutation of one company, and the one for the shared platform. */
30
33
  const companyScope = (orgId) => `company:${orgId}`;
@@ -119,6 +122,9 @@ export class PlatformConfirmationRequiredError extends Error {
119
122
  /** The steps that read an entry's hostnames: `computeFreshPlan` drops them for an org whose hosting is invalid. */
120
123
  const HOSTNAME_STEPS = new Set([
121
124
  'coolify:app',
125
+ 'coolify:bluegreen',
126
+ 'coolify:bluegreen-lb',
127
+ 'coolify:bluegreen-connector',
122
128
  'coolify:broker-entry',
123
129
  'coolify:database',
124
130
  'cloudflare:tunnel',
@@ -167,6 +173,7 @@ export class FoundryPlanApply {
167
173
  backupGate;
168
174
  platformDeps;
169
175
  runner;
176
+ releaseStore;
170
177
  constructor(options) {
171
178
  this.store = options.store;
172
179
  this.readers = options.readers;
@@ -195,6 +202,7 @@ export class FoundryPlanApply {
195
202
  this.backupGate = options.backupGate;
196
203
  this.platformDeps = options.platform;
197
204
  this.runner = options.runner;
205
+ this.releaseStore = options.releaseStore;
198
206
  }
199
207
  async plan(id) {
200
208
  const { plan } = await this.computeFreshPlan(id);
@@ -953,6 +961,13 @@ export class FoundryPlanApply {
953
961
  }
954
962
  checkpoints.push({ step: planned.step.description, completedAt: this.now().toISOString() });
955
963
  }
964
+ // The steps made the structure; the release runner then moves traffic on it, on the same live ids.
965
+ await this.runOrgReleases(org, {
966
+ recordedIds: context.recordedIds,
967
+ recordScopeId: context.recordScopeId,
968
+ forgetId: (key) => context.forgetId?.(key),
969
+ persist: context.persistScopeIds,
970
+ }, fence);
956
971
  }
957
972
  catch (e) {
958
973
  // #9 (resolved): "it stops at the checkpoint... there is no automatic
@@ -1012,6 +1027,135 @@ export class FoundryPlanApply {
1012
1027
  });
1013
1028
  return applied;
1014
1029
  }
1030
+ /**
1031
+ * ADR 0001: one release run for each blue/green entry whose structure is applied, writing into `context`
1032
+ * (the live ids of the run that holds the lease). A weight the runner changed reaches every server's balancer
1033
+ * before this returns.
1034
+ */
1035
+ async runOrgReleases(org, context, fence) {
1036
+ const store = this.releaseStore;
1037
+ const primaryUuid = context.recordedIds()[PRIMARY_SERVER_SCOPE_KEY];
1038
+ if (!store || !this.executor.releaseHost || !primaryUuid)
1039
+ return;
1040
+ for (const entry of org.hosting.entries) {
1041
+ const subject = entrySubject(org, entry, context.recordedIds());
1042
+ if (!subject)
1043
+ continue;
1044
+ const host = this.executor.releaseHost(org, subject, context, () => this.renewLease(fence));
1045
+ if (!host)
1046
+ continue;
1047
+ await runSubjectRelease({
1048
+ store,
1049
+ scopeOwnerId: org.id,
1050
+ subject,
1051
+ host,
1052
+ context,
1053
+ now: this.now(),
1054
+ primaryUuid,
1055
+ });
1056
+ }
1057
+ }
1058
+ /** One release run for platform-archon, on the platform's live ids; see {@link runOrgReleases}. */
1059
+ async runPlatformReleases(platform, context, fence) {
1060
+ const store = this.releaseStore;
1061
+ const executor = this.platformDeps?.executor;
1062
+ const subject = archonSubject(platform, ARCHON_REPO);
1063
+ const primaryUuid = context.recordedIds()[PRIMARY_SERVER_SCOPE_KEY];
1064
+ if (!store || !executor?.releaseHost || !subject || !primaryUuid)
1065
+ return;
1066
+ const host = executor.releaseHost(platform, subject, context, () => this.renewLease(fence));
1067
+ if (!host)
1068
+ return;
1069
+ await runSubjectRelease({
1070
+ store,
1071
+ scopeOwnerId: 'platform',
1072
+ subject,
1073
+ host,
1074
+ context,
1075
+ now: this.now(),
1076
+ primaryUuid,
1077
+ });
1078
+ }
1079
+ async runPlatformReleaseTick() {
1080
+ const deps = this.platformDeps;
1081
+ const platform = await deps?.store.get();
1082
+ if (!deps || !platform)
1083
+ return;
1084
+ const empty = { orgId: platform.ownerOrgId, steps: [], unmanaged: [] };
1085
+ if ((await this.authz.canApplyPlatform?.(this.actor)) !== true) {
1086
+ await this.refusePlatform(empty, platform.ownerOrgId, 'not authorized to run the release tick');
1087
+ throw new NotAuthorizedError(this.actor, platform.ownerOrgId);
1088
+ }
1089
+ await this.withMutationLease(PLATFORM_SCOPE, (reason) => this.refusePlatform(empty, platform.ownerOrgId, reason), async (fence) => {
1090
+ const serviceIds = { ...(await deps.store.getServiceIds()) };
1091
+ await this.runPlatformReleases(platform, {
1092
+ recordedIds: () => ({ ...serviceIds }),
1093
+ recordScopeId: (key, value) => {
1094
+ serviceIds[key] = value;
1095
+ },
1096
+ forgetId: (key) => {
1097
+ delete serviceIds[key];
1098
+ },
1099
+ persist: () => deps.store.putServiceIds({ ...serviceIds }, fence),
1100
+ }, fence);
1101
+ });
1102
+ }
1103
+ /**
1104
+ * The recurring release run (ADR 0001): for every declared org with a blue/green entry, and the platform's
1105
+ * archon when it declares one, takes the open requests, reads the health signal and moves traffic. Foundry has
1106
+ * no scheduler of its own, so the host calls this every 10 minutes. An org that cannot run (the actor may not
1107
+ * apply it, an apply holds its lease, a provider call fails) is reported by id and does not stop the others.
1108
+ * A refusal is audited; a run that does nothing writes no audit row.
1109
+ */
1110
+ async runReleaseTick() {
1111
+ const results = [];
1112
+ for (const org of await this.store.list()) {
1113
+ if (!org.hosting.entries.some((entry) => entry.blueGreen))
1114
+ continue;
1115
+ try {
1116
+ await this.runOrgReleaseTick(org.id);
1117
+ results.push({ target: org.id });
1118
+ }
1119
+ catch (e) {
1120
+ results.push({ target: org.id, error: e instanceof Error ? e.message : String(e) });
1121
+ }
1122
+ }
1123
+ const platform = await this.platformDeps?.store.get();
1124
+ if (platform?.archon.enabled && platform.archon.blueGreen) {
1125
+ try {
1126
+ await this.runPlatformReleaseTick();
1127
+ results.push({ target: 'platform' });
1128
+ }
1129
+ catch (e) {
1130
+ results.push({ target: 'platform', error: e instanceof Error ? e.message : String(e) });
1131
+ }
1132
+ }
1133
+ return results;
1134
+ }
1135
+ async runOrgReleaseTick(id) {
1136
+ const empty = { orgId: id, steps: [], unmanaged: [] };
1137
+ if (!(await this.authz.canApply(this.actor, id))) {
1138
+ await this.refuse(empty, 'not authorized to run the release tick');
1139
+ throw new NotAuthorizedError(this.actor, id);
1140
+ }
1141
+ await this.withMutationLease(companyScope(id), (reason) => this.refuse(empty, reason), async (fence) => {
1142
+ const org = await this.store.get(id);
1143
+ const previous = await this.store.getAppliedState(id);
1144
+ if (!org || !previous)
1145
+ return;
1146
+ const serviceIds = { ...previous.serviceIds };
1147
+ await this.runOrgReleases(org, {
1148
+ recordedIds: () => ({ ...serviceIds }),
1149
+ recordScopeId: (key, value) => {
1150
+ serviceIds[key] = value;
1151
+ },
1152
+ forgetId: (key) => {
1153
+ delete serviceIds[key];
1154
+ },
1155
+ persist: () => this.store.putAppliedState({ ...previous, serviceIds: { ...serviceIds } }, fence),
1156
+ }, fence);
1157
+ });
1158
+ }
1015
1159
  /** 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
1160
  async readSfuPortsOpen(org) {
1017
1161
  const slot = org.services?.sfuSlot;
@@ -1048,6 +1192,10 @@ export class FoundryPlanApply {
1048
1192
  : false;
1049
1193
  const sfuPortsOpen = await this.readSfuPortsOpen(org);
1050
1194
  const resumable = await this.ownResumableRefs(org);
1195
+ // Read only for an org that declares it; a failed read plans none and says so (`planBlueGreen`).
1196
+ const blueGreenServers = org.hosting.entries.some((e) => e.blueGreen) && this.executor.blueGreenServers
1197
+ ? await this.executor.blueGreenServers().catch(() => undefined)
1198
+ : undefined;
1051
1199
  const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
1052
1200
  loginHostBindings: this.loginHostBindings,
1053
1201
  loginHostServesDiscovery,
@@ -1066,6 +1214,18 @@ export class FoundryPlanApply {
1066
1214
  oikosZoneReady: platformReadiness.oikosIngressApplied === true,
1067
1215
  ownMailKeyHeld: await this.ownMailKeyHeld(org),
1068
1216
  ownMailKeyMintable: this.executor.ownMailKeyMintable?.() ?? false,
1217
+ ...(blueGreenServers ? { blueGreenServers } : {}),
1218
+ ...(blueGreenServers && org.hosting.entries.some((e) => e.blueGreen && e.database)
1219
+ ? {
1220
+ remoteDatabase: remoteDatabaseFactsFrom({
1221
+ org,
1222
+ servers: blueGreenServers,
1223
+ recorded,
1224
+ access: !platformReadiness.dnsBlocked ||
1225
+ ['dns', 'zones', 'access', 'tunnels'].every((port) => platformReadiness.ownEdgePorts?.[port]),
1226
+ }),
1227
+ }
1228
+ : {}),
1069
1229
  ownSigninDbKeysPuttable: this.executor.ownSigninDbKeysPuttable?.() ?? true,
1070
1230
  ownSigninDbBackupConfigured: this.executor.ownSigninDbBackupConfigured?.() ?? true,
1071
1231
  ...(this.backupGate
@@ -1391,8 +1551,21 @@ export class FoundryPlanApply {
1391
1551
  .map((org) => org.slug);
1392
1552
  const mailRelay = platform.mail.relay === true ? await this.mailRelayInputs() : null;
1393
1553
  const owner = await this.platformOwnerInput(platform);
1554
+ const archonBlueGreen = platform.archon.enabled && platform.archon.blueGreen && executor.archonBlueGreen
1555
+ ? await executor.archonBlueGreen().catch(() => ({ servers: undefined }))
1556
+ : null;
1394
1557
  const detail = computePlatformPlanDetail(platform, live, {
1395
1558
  owner,
1559
+ ...(archonBlueGreen
1560
+ ? {
1561
+ archonBlueGreen: {
1562
+ servers: archonBlueGreen.servers,
1563
+ ...('blocked' in archonBlueGreen && archonBlueGreen.blocked
1564
+ ? { blocked: archonBlueGreen.blocked }
1565
+ : {}),
1566
+ },
1567
+ }
1568
+ : {}),
1396
1569
  jobsObservable: readers.coolify !== undefined,
1397
1570
  sfuBlocked,
1398
1571
  callsOrgSlugs,
@@ -1515,6 +1688,14 @@ export class FoundryPlanApply {
1515
1688
  serviceIds[`${system}:${kind}:${key}`] = result.resourceId;
1516
1689
  }
1517
1690
  }
1691
+ await this.runPlatformReleases(platform, {
1692
+ recordedIds: context.recordedIds,
1693
+ recordScopeId: context.recordScopeId,
1694
+ forgetId: (key) => {
1695
+ delete serviceIds[key];
1696
+ },
1697
+ persist: () => store.putServiceIds(serviceIds, fence),
1698
+ }, fence);
1518
1699
  }
1519
1700
  catch (e) {
1520
1701
  // Whatever completed stays recorded, as an org's apply does. A lost
@@ -1630,6 +1811,10 @@ function destroyTargets(serviceIds, orgId) {
1630
1811
  }
1631
1812
  continue;
1632
1813
  }
1814
+ // Blue/green: the colors, balancers, stage, database sidecars and extra connectors, by recorded id, before
1815
+ // the main database they connect to. Their keys carry no ':' (scope ids), so no prefix above finds them.
1816
+ if (prefix === 'coolify:database:')
1817
+ targets.push(...blueGreenDestroyTargets(serviceIds));
1633
1818
  for (const [resourceKey, id] of Object.entries(serviceIds)) {
1634
1819
  if (!resourceKey.startsWith(prefix) || !id)
1635
1820
  continue;
@@ -1745,10 +1930,44 @@ function destroyStepDescriptions(targets) {
1745
1930
  'delete the declared org',
1746
1931
  ];
1747
1932
  }
1933
+ /** Scope keys of the Coolify apps blue/green made: `bg.<entry>.app.<server>.<part>`, a database sidecar, a connector on another server. */
1934
+ const BLUE_GREEN_APP_KEY = /^(bg\.[^.:]+\.app\.[^.:]+\.[a-z]+|bgdb\.[^.:]+\.sidecar\.[^.:]+|cloudflare_tunnel_connector_app\.[^.:]+)$/;
1935
+ const BLUE_GREEN_DATABASE_KEY = /^bg\.[^.:]+\.stagedb$/;
1936
+ /** 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). */
1937
+ const BLUE_GREEN_EDGE_KEYS = [
1938
+ [/^bgdb\.[^.:]+\.access$/, 'bg-access'],
1939
+ [/^bgdb\.[^.:]+\.(token|undelivered)$/, 'bg-token'],
1940
+ [/^bgdb\.[^.:]+\.route$/, 'bg-dns'],
1941
+ ];
1942
+ /** Every recorded blue/green app (then stage's database), in key order. A destroy target's `key` is the scope key itself. */
1943
+ function blueGreenDestroyTargets(serviceIds) {
1944
+ const keys = Object.keys(serviceIds).sort();
1945
+ const targets = [];
1946
+ for (const key of keys.filter((k) => BLUE_GREEN_APP_KEY.test(k))) {
1947
+ const id = serviceIds[key];
1948
+ if (id)
1949
+ targets.push({ system: 'coolify', kind: 'bg-app', key, id });
1950
+ }
1951
+ for (const key of keys.filter((k) => BLUE_GREEN_DATABASE_KEY.test(k))) {
1952
+ const id = serviceIds[key];
1953
+ if (id)
1954
+ targets.push({ system: 'coolify', kind: 'bg-database', key, id });
1955
+ }
1956
+ for (const [pattern, kind] of BLUE_GREEN_EDGE_KEYS) {
1957
+ for (const key of keys.filter((k) => pattern.test(k))) {
1958
+ const id = serviceIds[key];
1959
+ if (id)
1960
+ targets.push({ system: 'cloudflare', kind, key, id });
1961
+ }
1962
+ }
1963
+ return targets;
1964
+ }
1748
1965
  /** The `AppliedState.serviceIds` key a completed {@link DestroyTarget} should be removed from. */
1749
1966
  function destroyServiceIdKey(target) {
1750
1967
  if (target.kind === 'project' || target.kind === 'org')
1751
1968
  return target.key;
1969
+ if (target.kind.startsWith('bg-'))
1970
+ return target.key;
1752
1971
  if (target.kind === 'own-files-r2' && target.key === 'undelivered') {
1753
1972
  return OWN_FILES_R2_UNDELIVERED_SCOPE_KEY;
1754
1973
  }
@@ -1781,6 +2000,9 @@ const STEP_FIELDS = {
1781
2000
  'ai:key': 'ai',
1782
2001
  'cms:site': 'site',
1783
2002
  'coolify:app': 'hosting.enabled',
2003
+ 'coolify:bluegreen': 'hosting.entries.blueGreen',
2004
+ 'coolify:bluegreen-lb': 'hosting.entries.blueGreen',
2005
+ 'coolify:bluegreen-connector': 'hosting.entries.blueGreen',
1784
2006
  'coolify:database': 'hosting.database',
1785
2007
  'cloudflare:tunnel': 'hosting.tunnel',
1786
2008
  'cloudflare:zone': 'hosting.usesDomain',
@@ -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
@@ -3,18 +3,21 @@ import { isIPv4 } from 'node:net';
3
3
  import { applyObjects, openInstance, platformMailSettingsObjects } from '@wtfalch/email/postmaster';
4
4
  import { VERSIONS, coolifyImageFor, floatingTagBlocked, pinnedRef } from '../bootstrap/versions.js';
5
5
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
6
- import { ownerMailboxes } from '../org.js';
6
+ import { ownerMailboxes, sharedSecretRef, } from '../org.js';
7
+ import { applyArchonBlueGreen } from './archon-blue-green.js';
8
+ import { ARCHON_REPO, archonSubject, healthTokenName, tunnelOriginFor, } from './blue-green.js';
7
9
  import { BOULE_BRIDGE_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_PEER_ROLE, bouleHostnameFor, } from './boule.js';
8
10
  import { recordBuiltCommit } from './build-record.js';
9
11
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
10
12
  import { previewsOn } from './coolify-previews.js';
11
13
  import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
12
14
  import { placedOnPlatform } from './destinations.js';
13
- import { ACCESS_POLICY_NAME, coolifyProjectMark, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
15
+ import { ACCESS_POLICY_NAME, LOG_DIR, coolifyProjectMark, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
14
16
  import { ACCESS_PROVIDER_RECORD_KEY, GATE_CLAIM_NAME } from './gates.js';
15
17
  import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, tcpProbe, } from './health-check-settings.js';
16
18
  import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
17
19
  import { LIMITS, limited } from './own-limits.js';
20
+ import { createReleaseHost } from './release-host.js';
18
21
  export { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
19
22
  import { coolifyDomains, coolifyTunnelDomains, filesShareHostname } from './hostnames.js';
20
23
  import { MAIL_RELAY_BLOCKED, provisionMailRelay } from './mail-relay.js';
@@ -173,6 +176,86 @@ export class FoundryPlatformStepExecutor {
173
176
  limitedOnPlatform(deps, profile) {
174
177
  return { ...deps, coolify: limited(this.onPlatform(deps.coolify), profile) };
175
178
  }
179
+ /** Every Coolify server and why platform-archon's blue/green cannot run here, for the planner. */
180
+ async archonBlueGreen() {
181
+ const archon = this.archon;
182
+ if (!archon)
183
+ return { blocked: 'the host built the platform executor without archon dependencies' };
184
+ if (typeof archon.heldKeys?.put !== 'function') {
185
+ return { blocked: 'the host cannot hold the health token (its held keys cannot put)' };
186
+ }
187
+ const servers = await archon.coolify.listServers();
188
+ return {
189
+ servers: servers.map((s) => ({
190
+ uuid: s.uuid,
191
+ usable: s.is_usable,
192
+ reachable: s.is_reachable,
193
+ })),
194
+ };
195
+ }
196
+ /** The release runner's outside calls for platform-archon (`plan/release-host.ts`). The token is only read. */
197
+ releaseHost(platform, subject, context, renew) {
198
+ const archon = this.archon;
199
+ const recorded = context.recordedIds();
200
+ const primaryBlueAppId = recorded[`coolify:app:${PLATFORM_ARCHON_KEY}`];
201
+ const primaryUuid = recorded.coolify_server;
202
+ if (!archon || !primaryBlueAppId || !primaryUuid)
203
+ return null;
204
+ return createReleaseHost({
205
+ subject,
206
+ context,
207
+ coolify: archon.coolify,
208
+ healthToken: async () => (await archon.heldKeys?.resolve(sharedSecretRef(platform.ownerOrgId, healthTokenName(subject.id)))) ?? null,
209
+ secret: (value) => archon.runtime?.onSecret?.(value),
210
+ primaryUuid,
211
+ primaryBlueAppId,
212
+ runtime: this.runtime,
213
+ ...(archon.runtime?.fetch ? { fetch: archon.runtime.fetch } : {}),
214
+ renew,
215
+ label: `coolify:release:${subject.id}`,
216
+ });
217
+ }
218
+ async archonBlueGreenStep(action, context) {
219
+ if (!this.archon) {
220
+ throw new Error('platform:archon needs FoundryPlatformStepExecutor to be constructed with its archon dependencies (#175)');
221
+ }
222
+ const deps = this.limitedOnPlatform(this.archon, LIMITS.platformArchon);
223
+ const platform = context.platform;
224
+ const subject = archonSubject(platform, ARCHON_REPO);
225
+ const recorded = context.recordedIds();
226
+ const blueAppId = recorded[`coolify:app:${PLATFORM_ARCHON_KEY}`];
227
+ const projectUuid = recorded[PLATFORM_ARCHON_PROJECT_SCOPE_KEY];
228
+ const tunnelId = recorded[PLATFORM_ARCHON_TUNNEL_SCOPE_KEY];
229
+ if (!subject || !blueAppId || !projectUuid || !tunnelId) {
230
+ throw new Error('coolify:archon-bluegreen: platform-archon is not applied yet');
231
+ }
232
+ if (typeof deps.heldKeys?.put !== 'function') {
233
+ throw new Error('coolify:archon-bluegreen: the host cannot hold the health token');
234
+ }
235
+ await applyArchonBlueGreen({
236
+ subject,
237
+ ownerOrgId: platform.ownerOrgId,
238
+ desired: { primary: String(action.desired.primary ?? '') },
239
+ recorded: () => context.recordedIds(),
240
+ record: (key, value) => context.recordScopeId(key, value),
241
+ persist: async () => {
242
+ await context.persist?.();
243
+ },
244
+ coolify: deps.coolify,
245
+ heldKeys: { resolve: deps.heldKeys.resolve, put: deps.heldKeys.put },
246
+ secret: (value) => deps.runtime?.onSecret?.(value),
247
+ projectUuid,
248
+ blueAppId,
249
+ tunnelId,
250
+ hostname: platform.archon.hostname,
251
+ tunnels: await platformTunnels(deps, context.recordedIds(), 'coolify:archon-bluegreen'),
252
+ runtime: deps.runtime ?? {},
253
+ sleep: deps.runtime?.sleep ?? defaultSleep,
254
+ logDir: LOG_DIR,
255
+ isRunning: isRunningOrStarting,
256
+ });
257
+ return undefined;
258
+ }
176
259
  async blockedSteps(platform, recordedIds = {}, owner = {}) {
177
260
  const blocked = {};
178
261
  if (platform.auth.enabled && !this.auth) {
@@ -724,6 +807,9 @@ export class FoundryPlatformStepExecutor {
724
807
  }
725
808
  return provisionPlatformEdge(context, this.limitedOnPlatform(this.edge, LIMITS.platformEdge));
726
809
  }
810
+ if (action.system === 'coolify' && action.kind === 'archon-bluegreen') {
811
+ return this.archonBlueGreenStep(action, context);
812
+ }
727
813
  if (action.system === 'coolify' &&
728
814
  action.kind === 'app' &&
729
815
  action.key === PLATFORM_ARCHON_KEY) {
@@ -3602,8 +3688,6 @@ const PLATFORM_ARCHON_INTERNAL_NAME = 'platform-archon';
3602
3688
  const PLATFORM_ARCHON_TUNNEL_NAME = 'platform-archon-tunnel';
3603
3689
  /** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_ARCHON_TUNNEL_NAME}. */
3604
3690
  const PLATFORM_ARCHON_CLOUDFLARED_APP_NAME = 'platform-archon-cloudflared';
3605
- /** https://github.com/wtfalch/archon: one Dockerfile at its root, `runner` stage on port 3000. */
3606
- const ARCHON_REPO = 'wtfalch/archon';
3607
3691
  const ARCHON_PORT = '3000';
3608
3692
  /**
3609
3693
  * #480: Archon's own health route (`@wtfalch/reporting`'s `healthHandler`,
@@ -4506,7 +4590,8 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
4506
4590
  ingress: [
4507
4591
  {
4508
4592
  hostname,
4509
- service: `http://${PLATFORM_ARCHON_INTERNAL_NAME}:${ARCHON_PORT}`,
4593
+ // Through the balancer once platform-archon runs blue/green and its balancer is written.
4594
+ service: tunnelOriginFor(archonSubject(platform, ARCHON_REPO), recorded, recorded.coolify_server, `http://${PLATFORM_ARCHON_INTERNAL_NAME}:${ARCHON_PORT}`),
4510
4595
  },
4511
4596
  { service: 'http_status:404' },
4512
4597
  ],
@@ -47,12 +47,12 @@ export declare function entryLabelSpec(org: DeclaredOrg, entry: HostingEntry): E
47
47
  * without a domain too: its `<slug>.oikos.host` name is routed by the company's own tunnel
48
48
  * ({@link entryOikosHostnames}), not by the platform's shared ingress.
49
49
  */
50
- export declare function entryRoutes(org: DeclaredOrg): Array<{
50
+ export declare function entryRoutes(org: DeclaredOrg, recorded?: Readonly<Record<string, string>>): Array<{
51
51
  hostname: string;
52
52
  service: string;
53
53
  }>;
54
54
  /** The scope value recording the routes the tunnel step last wrote; the planner compares it to {@link entryRoutes}. */
55
- export declare function entryRoutesMarker(org: DeclaredOrg): string;
55
+ export declare function entryRoutesMarker(org: DeclaredOrg, recorded?: Readonly<Record<string, string>>): string;
56
56
  /**
57
57
  * A short digest of the spec; the same spec always gives the same hash. The token is left out: the
58
58
  * planner has none, and a changed token reaches a live app through the comparison of its live labels.
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { PRIMARY_SERVER_SCOPE_KEY, balancerApplied, lbAppName, liveAppNameFor, } from './blue-green.js';
2
3
  import { entryHostnames, wildcardHostname } from './hostnames.js';
3
4
  import { NO_DOMAIN_ZONE } from './own-hostnames.js';
4
5
  import { appNameFor } from './platform-diff.js';
@@ -56,13 +57,20 @@ export function entryLabelSpec(org, entry) {
56
57
  * without a domain too: its `<slug>.oikos.host` name is routed by the company's own tunnel
57
58
  * ({@link entryOikosHostnames}), not by the platform's shared ingress.
58
59
  */
59
- export function entryRoutes(org) {
60
+ export function entryRoutes(org, recorded = {}) {
60
61
  if (!org.hosting.enabled)
61
62
  return [];
62
63
  const routes = [];
63
64
  let wildcard;
64
65
  for (const entry of org.hosting.entries) {
65
- const service = `http://${appNameFor(org, entry)}:${entryLabelSpec(org, entry).port}`;
66
+ // A blue/green entry is reached through its balancer, which each server's connector finds on its own
67
+ // network, once the balancer exists on the primary. Until then it keeps the origin it had.
68
+ const target = !entry.blueGreen
69
+ ? appNameFor(org, entry)
70
+ : balancerApplied(entry.key, recorded, recorded[PRIMARY_SERVER_SCOPE_KEY])
71
+ ? lbAppName(appNameFor(org, entry))
72
+ : liveAppNameFor(org, entry, recorded);
73
+ const service = `http://${target}:${entryLabelSpec(org, entry).port}`;
66
74
  for (const hostname of entryHostnames(org, entry)) {
67
75
  if (!routes.some((r) => r.hostname === hostname))
68
76
  routes.push({ hostname, service });
@@ -76,8 +84,8 @@ export function entryRoutes(org) {
76
84
  return routes;
77
85
  }
78
86
  /** The scope value recording the routes the tunnel step last wrote; the planner compares it to {@link entryRoutes}. */
79
- export function entryRoutesMarker(org) {
80
- return JSON.stringify(entryRoutes(org));
87
+ export function entryRoutesMarker(org, recorded = {}) {
88
+ return JSON.stringify(entryRoutes(org, recorded));
81
89
  }
82
90
  /**
83
91
  * A short digest of the spec; the same spec always gives the same hash. The token is left out: the