@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
@@ -2,6 +2,7 @@ import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/p
2
2
  import { type CallsSfuUrls } from '../calls-sfu.js';
3
3
  import type { DeclaredOrg, HostingBuildPack } from '../org.js';
4
4
  import type { DeclaredOrgStore } from '../store.js';
5
+ import { type BlueGreenServer, type BlueGreenSubject } from './blue-green.js';
5
6
  import { type BrokerPort } from './broker-entries.js';
6
7
  import { type DeployHealthRuntime } from './deploy-health.js';
7
8
  import { type StepAction } from './diff.js';
@@ -16,6 +17,7 @@ import { type OwnServicesPort } from './own-services.js';
16
17
  import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
17
18
  import type { EdgePortName } from './platform-org.js';
18
19
  import type { RequestHeaderPort } from './platform-providers.js';
20
+ import { type ReleaseContext, type ReleaseHost } from './release-host.js';
19
21
  export declare function coolifyProjectMark(org: Pick<DeclaredOrg, 'id'>): string;
20
22
  /**
21
23
  * The Coolify calls this executor makes, shaped to match
@@ -103,6 +105,8 @@ export interface CoolifyPort {
103
105
  */
104
106
  getApplication(uuid: string): Promise<{
105
107
  uuid: string;
108
+ /** Coolify's name for the app; a blue/green entry's recorded app is its blue copy only when this is the blue name. */
109
+ name?: string;
106
110
  status?: string;
107
111
  custom_labels?: string | null;
108
112
  settings?: {
@@ -2141,6 +2145,34 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2141
2145
  * actually be created on.
2142
2146
  */
2143
2147
  private resolveServer;
2148
+ /**
2149
+ * Contract C3: every usable, reachable Coolify server, the org's primary first. The primary is
2150
+ * {@link resolveServer}'s (databases and single-copy services stay on it, unchanged). A server that is
2151
+ * down is named in `down` and left out of `servers`; it is not an error.
2152
+ */
2153
+ resolveServers(org: DeclaredOrg, context: StepExecutionContext): Promise<{
2154
+ primary: {
2155
+ uuid: string;
2156
+ };
2157
+ servers: Array<{
2158
+ uuid: string;
2159
+ }>;
2160
+ down: string[];
2161
+ }>;
2162
+ /**
2163
+ * The release runner's outside calls for one blue/green entry (`plan/release-host.ts`), or `null` while its
2164
+ * blue copy is not recorded. The health token is only read here, never minted.
2165
+ */
2166
+ releaseHost(org: DeclaredOrg, subject: BlueGreenSubject, context: ReleaseContext, renew: () => Promise<void>): ReleaseHost | null;
2167
+ /** Every server Coolify lists with whether it is usable and reachable, for the planner. */
2168
+ blueGreenServers(): Promise<BlueGreenServer[]>;
2169
+ /**
2170
+ * The blue/green steps (`plan/blue-green.ts`): `bluegreen` makes the apps and the balancer on one server,
2171
+ * `bluegreen-lb` only writes the balancer's weights, `bluegreen-connector` runs the tunnel's connector on a
2172
+ * server. A server that went down since the plan is skipped by `bluegreen-connector`'s caller, not here:
2173
+ * a step for it fails, which the next plan reports instead.
2174
+ */
2175
+ private coolifyBlueGreen;
2144
2176
  private coolifyApp;
2145
2177
  /**
2146
2178
  * #75: mounts a persistent volume at {@link LOG_DIR} on the app, unless one
@@ -3009,6 +3041,8 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
3009
3041
  */
3010
3042
  private ownSfu;
3011
3043
  private jobsRegistration;
3044
+ /** The blue/green database route (`plan/remote-database.ts`). */
3045
+ private remoteDatabaseRoute;
3012
3046
  /** `deliverSecret`, tried up to {@link JOBS_DELIVERY_ATTEMPTS} times; true once one attempt succeeds. */
3013
3047
  private deliverWithRetry;
3014
3048
  private cloudflareTunnel;
@@ -7,6 +7,8 @@ import { resolveCallsSfuUrls } from '../calls-sfu.js';
7
7
  import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, heldEnvRef, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
8
8
  import { adoptedKeysUnreadable, parseAdoptSnapshot } from './adopt.js';
9
9
  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';
10
+ import { applyBlueGreenConnectors, applyBlueGreenServer, applyBlueGreenWeights, } from './blue-green-apply.js';
11
+ import { bgKey, colorAppName, entrySubject, healthTokenName, liveAppNameFor, splitServers, } from './blue-green.js';
10
12
  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';
11
13
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
12
14
  import { recordBuiltCommit } from './build-record.js';
@@ -44,6 +46,8 @@ import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY
44
46
  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';
45
47
  import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_MAIL_SERVER_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionDatabaseBackup, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
46
48
  import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, ENTRY_CONTAINER_LABELS, appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryProxyLabels, entryRoutes, entryRoutesMarker, ownsOikosName, routesWithoutToken, sameLabelSets, } from './proxy-labels.js';
49
+ import { createReleaseHost } from './release-host.js';
50
+ import { TUNNEL_HTTP_INGRESS_SCOPE_KEY, applyRemoteDatabase, mergeTcpIngress, recordedTcpRoutes, } from './remote-database.js';
47
51
  import { editSignInBindings } from './sign-in-bindings.js';
48
52
  import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
49
53
  import { SIGN_IN_BASE_PATH, SIGN_IN_ENV_LIVE_SCOPE_PREFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_RATE_LIMIT_DESCRIPTION, SIGN_IN_RATE_LIMIT_EXPRESSION, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, SIGN_IN_UPSTREAM_ENV, inAppBindingFor, inAppSignIn, inAppStateHosts, inAppStatePairs, inAppTargets, signInEnvLiveHosts, } from './sign-in.js';
@@ -883,7 +887,11 @@ function limitProfileOf(action) {
883
887
  case 'coolify:boule':
884
888
  case 'coolify:boule-broker-entry':
885
889
  return LIMITS.boule;
890
+ case 'coolify:bluegreen':
891
+ case 'coolify:bluegreen-lb':
892
+ return LIMITS.companyApp;
886
893
  case 'cloudflare:tunnel':
894
+ case 'coolify:bluegreen-connector':
887
895
  return LIMITS.connector;
888
896
  default:
889
897
  return action.system === 'coolify' ? LIMITS.companyOther : undefined;
@@ -1109,6 +1117,10 @@ export class FoundryStepExecutor {
1109
1117
  return this.coolifyApp(action, org, context);
1110
1118
  case 'coolify:network':
1111
1119
  return this.coolifyNetwork(action, org, context);
1120
+ case 'coolify:bluegreen':
1121
+ case 'coolify:bluegreen-lb':
1122
+ case 'coolify:bluegreen-connector':
1123
+ return this.coolifyBlueGreen(action, org, context);
1112
1124
  case 'coolify:broker-entry':
1113
1125
  return this.coolifyBrokerEntry(action, org, context);
1114
1126
  case 'coolify:database':
@@ -1169,6 +1181,8 @@ export class FoundryStepExecutor {
1169
1181
  return this.bouleBrokerEntry(org, context);
1170
1182
  case 'jobs:registration':
1171
1183
  return this.jobsRegistration(action, org, context);
1184
+ case 'cloudflare:db-route':
1185
+ return this.remoteDatabaseRoute(action, org, context);
1172
1186
  case 'coolify:own-auth':
1173
1187
  return this.ownAuth(action, org, context);
1174
1188
  case 'coolify:own-auth-db-move':
@@ -2501,6 +2515,140 @@ export class FoundryStepExecutor {
2501
2515
  context.recordScopeId(COOLIFY_SERVER_SCOPE_KEY, server.uuid);
2502
2516
  return server;
2503
2517
  }
2518
+ /**
2519
+ * Contract C3: every usable, reachable Coolify server, the org's primary first. The primary is
2520
+ * {@link resolveServer}'s (databases and single-copy services stay on it, unchanged). A server that is
2521
+ * down is named in `down` and left out of `servers`; it is not an error.
2522
+ */
2523
+ async resolveServers(org, context) {
2524
+ const primary = await this.resolveServer(org, context);
2525
+ const { up, down } = splitServers(await this.blueGreenServers());
2526
+ return {
2527
+ primary,
2528
+ servers: [primary, ...up.filter((uuid) => uuid !== primary.uuid).map((uuid) => ({ uuid }))],
2529
+ down,
2530
+ };
2531
+ }
2532
+ /**
2533
+ * The release runner's outside calls for one blue/green entry (`plan/release-host.ts`), or `null` while its
2534
+ * blue copy is not recorded. The health token is only read here, never minted.
2535
+ */
2536
+ releaseHost(org, subject, context, renew) {
2537
+ const recorded = context.recordedIds();
2538
+ const primaryBlueAppId = recorded[`coolify:app:${appStepKey(org, subject.id)}`];
2539
+ const primaryUuid = recorded[COOLIFY_SERVER_SCOPE_KEY];
2540
+ if (!primaryBlueAppId || !primaryUuid)
2541
+ return null;
2542
+ return createReleaseHost({
2543
+ subject,
2544
+ context,
2545
+ coolify: this.ports.coolify,
2546
+ healthToken: async () => (await this.ports.heldKeys?.resolve(sharedSecretRef(org.id, healthTokenName(subject.id)))) ?? null,
2547
+ secret: (value) => void this.handledSecrets.add(value),
2548
+ primaryUuid,
2549
+ primaryBlueAppId,
2550
+ runtime: this.ports.runtime ?? {},
2551
+ ...(this.ports.runtime?.fetch ? { fetch: this.ports.runtime.fetch } : {}),
2552
+ renew,
2553
+ label: `coolify:release:${subject.id}`,
2554
+ });
2555
+ }
2556
+ /** Every server Coolify lists with whether it is usable and reachable, for the planner. */
2557
+ async blueGreenServers() {
2558
+ return (await this.ports.coolify.listServers()).map((s) => ({
2559
+ uuid: s.uuid,
2560
+ usable: s.is_usable,
2561
+ reachable: s.is_reachable,
2562
+ }));
2563
+ }
2564
+ /**
2565
+ * The blue/green steps (`plan/blue-green.ts`): `bluegreen` makes the apps and the balancer on one server,
2566
+ * `bluegreen-lb` only writes the balancer's weights, `bluegreen-connector` runs the tunnel's connector on a
2567
+ * server. A server that went down since the plan is skipped by `bluegreen-connector`'s caller, not here:
2568
+ * a step for it fails, which the next plan reports instead.
2569
+ */
2570
+ async coolifyBlueGreen(action, org, context) {
2571
+ const recorded = () => context.recordedIds();
2572
+ const projectUuid = await this.ensureCoolifyProject(org, context);
2573
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
2574
+ const serverUuid = String(action.desired.server ?? '');
2575
+ const primaryUuid = String(action.desired.primary ?? '');
2576
+ const live = await this.resolveServers(org, context);
2577
+ if (!live.servers.some((s) => s.uuid === serverUuid)) {
2578
+ throw new Error(`${action.kind} for ${org.slug}: Coolify server ${serverUuid} is not usable and reachable`);
2579
+ }
2580
+ if (live.primary.uuid !== primaryUuid) {
2581
+ throw new Error(`${action.kind} for ${org.slug}: the plan's primary server is not the recorded one`);
2582
+ }
2583
+ if (action.kind === 'bluegreen-connector') {
2584
+ const tunnelId = recorded()[`cloudflare:tunnel:${org.id}`];
2585
+ if (!tunnelId)
2586
+ throw new Error(`bluegreen-connector for ${org.slug}: the tunnel is not recorded yet`);
2587
+ await applyBlueGreenConnectors({
2588
+ coolify: this.ports.coolify,
2589
+ tunnels: this.tunnelPort(),
2590
+ tunnelId,
2591
+ baseAppName: `${org.slug}-cloudflared`,
2592
+ baseScopeKey: CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY,
2593
+ primaryUuid,
2594
+ serverUuids: [serverUuid],
2595
+ projectUuid,
2596
+ environment,
2597
+ recorded,
2598
+ record: (key, id) => context.recordScopeId(key, id),
2599
+ persist: () => context.persistScopeIds(),
2600
+ });
2601
+ return undefined;
2602
+ }
2603
+ const entry = org.hosting.entries.find((e) => e.key === action.desired.entry);
2604
+ if (!entry || !entrySubject(org, entry)) {
2605
+ throw new Error(`${action.kind} for ${org.slug}: no blue/green entry '${String(action.desired.entry)}'`);
2606
+ }
2607
+ const primaryBlueAppId = recorded()[`coolify:app:${appStepKey(org, entry.key)}`];
2608
+ if (!primaryBlueAppId) {
2609
+ throw new Error(`${action.kind} for ${org.slug}: entry '${entry.key}' has no blue app recorded yet`);
2610
+ }
2611
+ // The blue app keeps the name it has: an app that ran before blue/green is adopted under its own name.
2612
+ if (!recorded()[bgKey.blueName(entry.key)]) {
2613
+ const blue = await this.ports.coolify.getApplication(primaryBlueAppId);
2614
+ if (blue.name) {
2615
+ context.recordScopeId(bgKey.blueName(entry.key), blue.name);
2616
+ await context.persistScopeIds();
2617
+ }
2618
+ }
2619
+ const subject = entrySubject(org, entry, recorded());
2620
+ const runtime = this.ports.runtime ?? {};
2621
+ const deps = {
2622
+ coolify: this.ports.coolify,
2623
+ recorded,
2624
+ record: (key, value) => context.recordScopeId(key, value),
2625
+ persist: () => context.persistScopeIds(),
2626
+ projectUuid,
2627
+ environment,
2628
+ healthToken: async () => {
2629
+ if (typeof this.ports.heldKeys?.put !== 'function') {
2630
+ throw new Error(`${action.kind} for ${org.slug}: the executor's heldKeys cannot put the health token`);
2631
+ }
2632
+ const token = await this.ensureSharedSecret(org, healthTokenName(entry.key), recorded());
2633
+ this.handledSecrets.add(token);
2634
+ return token;
2635
+ },
2636
+ secret: (value) => void this.handledSecrets.add(value),
2637
+ runtime,
2638
+ sleep: runtime.sleep ?? defaultSleep,
2639
+ label: `${action.system}:${action.kind}:${entry.key}`,
2640
+ primaryBlueAppId,
2641
+ logDir: LOG_DIR,
2642
+ isRunning: isRunningOrStarting,
2643
+ };
2644
+ if (action.kind === 'bluegreen-lb') {
2645
+ await applyBlueGreenWeights(deps, subject, { uuid: serverUuid, primaryUuid });
2646
+ }
2647
+ else {
2648
+ await applyBlueGreenServer(deps, subject, { uuid: serverUuid, primaryUuid });
2649
+ }
2650
+ return undefined;
2651
+ }
2504
2652
  async coolifyApp(action, org, context) {
2505
2653
  const recordedAppId = context.recordedIds()[`coolify:app:${action.key}`];
2506
2654
  if (action.desired.enabled === false) {
@@ -2538,6 +2686,11 @@ export class FoundryStepExecutor {
2538
2686
  }
2539
2687
  const projectUuid = await this.ensureCoolifyProject(org, context);
2540
2688
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
2689
+ // A blue/green entry's app is its blue copy. An app that ran before blue/green keeps its own name and is
2690
+ // adopted as blue; an entry with no app yet gets `<base>-blue`. The name is recorded once decided.
2691
+ const blueNameKey = bgKey.blueName(entry.key);
2692
+ const blueNameRecorded = Boolean(context.recordedIds()[blueNameKey]);
2693
+ let appName = liveAppNameFor(org, entry, context.recordedIds());
2541
2694
  // The recorded id first; the name when nothing is recorded, or when the
2542
2695
  // recorded app is gone.
2543
2696
  let existing = null;
@@ -2546,17 +2699,35 @@ export class FoundryStepExecutor {
2546
2699
  try {
2547
2700
  live = await this.ports.coolify.getApplication(recordedAppId);
2548
2701
  existing = { uuid: recordedAppId };
2702
+ if (entry.blueGreen &&
2703
+ !blueNameRecorded &&
2704
+ live.name === colorAppName(appNameFor(org, entry), 'blue')) {
2705
+ appName = live.name;
2706
+ }
2549
2707
  }
2550
2708
  catch (error) {
2551
2709
  if (!isNotFoundError(error))
2552
2710
  throw error;
2553
2711
  }
2554
2712
  }
2713
+ if (!existing && entry.blueGreen && !blueNameRecorded) {
2714
+ const own = await this.ports.coolify.findApplicationByName(appNameFor(org, entry), {
2715
+ environmentIds: [environment.id],
2716
+ });
2717
+ if (own) {
2718
+ existing = own;
2719
+ appName = appNameFor(org, entry);
2720
+ }
2721
+ }
2555
2722
  if (!existing) {
2556
- existing = await this.ports.coolify.findApplicationByName(appNameFor(org, entry), {
2723
+ existing = await this.ports.coolify.findApplicationByName(appName, {
2557
2724
  environmentIds: [environment.id],
2558
2725
  });
2559
2726
  }
2727
+ if (entry.blueGreen && !blueNameRecorded) {
2728
+ context.recordScopeId(blueNameKey, appName);
2729
+ await context.persistScopeIds();
2730
+ }
2560
2731
  // No entry gets Coolify `domains`: Coolify would add a Traefik router for them. The company's
2561
2732
  // tunnel routes an entry's names straight to its container (also `<slug>.oikos.host`, through a
2562
2733
  // record in the platform's oikos.host zone), which every entry reaches as `<slug>-<key>` (its
@@ -2602,7 +2773,7 @@ export class FoundryStepExecutor {
2602
2773
  git_repository: appRepo,
2603
2774
  git_branch: entry.branch,
2604
2775
  build_pack: entry.buildPack,
2605
- name: appNameFor(org, entry),
2776
+ name: appName,
2606
2777
  autogenerate_domain: false,
2607
2778
  // A static site is served by Coolify's own web server on port 80.
2608
2779
  ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
@@ -2655,7 +2826,7 @@ export class FoundryStepExecutor {
2655
2826
  patch.domains = '';
2656
2827
  }
2657
2828
  if (!existing || patch.domains !== undefined) {
2658
- patch.custom_internal_name = appNameFor(org, entry);
2829
+ patch.custom_internal_name = appName;
2659
2830
  }
2660
2831
  // A `*` entry's proxy labels: Coolify takes `custom_labels` base64 encoded
2661
2832
  // and uses them instead of the labels it generates from `domains`. Rule 4:
@@ -2709,8 +2880,8 @@ export class FoundryStepExecutor {
2709
2880
  // `ensureLogVolume` whether this app was already running before this
2710
2881
  // call -- see its own doc comment for why that decides whether to
2711
2882
  // restart it.
2712
- await this.ensureLogVolume(appId, org, Boolean(existing), appNameFor(org, entry));
2713
- await this.ensureEntryVolumes(appId, entry, Boolean(existing), appNameFor(org, entry));
2883
+ await this.ensureLogVolume(appId, org, Boolean(existing), appName);
2884
+ await this.ensureEntryVolumes(appId, entry, Boolean(existing), appName);
2714
2885
  // The app receives whatever it needs to sign in (gap P2). Unconditional,
2715
2886
  // unlike the database push above: every app has a hostname, and an
2716
2887
  // upsert of the same value on a later apply is a no-op, not a change.
@@ -2741,8 +2912,10 @@ export class FoundryStepExecutor {
2741
2912
  // The public name answers from the container only once the tunnel step has written the
2742
2913
  // direct route; before that (a first apply, or the one that moves it) the deployment is the check.
2743
2914
  hostname &&
2915
+ // The public name goes through the balancer, which may answer from the other color.
2916
+ !entry.blueGreen &&
2744
2917
  (context.recordedIds()[CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY] ?? '[]') ===
2745
- entryRoutesMarker(org)
2918
+ entryRoutesMarker(org, context.recordedIds())
2746
2919
  ? publicHealthCheck(org, context.recordedIds(), hostname, healthPath)
2747
2920
  : { kind: 'deployment-only' }, runtime, runtime.sleep ?? defaultSleep, `coolify:app:${entry.key}`, `${org.slug}'s ${entry.key === APP_ENTRY_KEY ? 'app' : `${entry.key} app`} stays at its previous deploy`);
2748
2921
  await recordBuiltCommit(this.ports.coolify, { appId: appId, deploymentUuid: built.deploymentUuid, name: entry.key }, (key, value) => context.recordScopeId(key, value));
@@ -6258,6 +6431,42 @@ export class FoundryStepExecutor {
6258
6431
  }
6259
6432
  return { resourceId: registered.id };
6260
6433
  }
6434
+ /** The blue/green database route (`plan/remote-database.ts`). */
6435
+ async remoteDatabaseRoute(action, org, context) {
6436
+ const projectUuid = await this.ensureCoolifyProject(org, context);
6437
+ const desired = action.desired;
6438
+ // The tunnel's TCP rule names the database by the host the apps' own `DATABASE_URL` uses.
6439
+ const db = await this.ports.coolify.getDatabase(desired.database);
6440
+ if (db.internal_db_url)
6441
+ this.handledSecrets.add(db.internal_db_url);
6442
+ let databaseHost = '';
6443
+ try {
6444
+ databaseHost = new URL(db.internal_db_url ?? '').hostname;
6445
+ }
6446
+ catch {
6447
+ // Fixed text below: the URL holds the database password.
6448
+ }
6449
+ if (!databaseHost) {
6450
+ throw new Error(`cloudflare:db-route for ${org.slug}: the database reports no usable address`);
6451
+ }
6452
+ await applyRemoteDatabase({
6453
+ tunnels: this.tunnelPort(),
6454
+ access: this.accessPort(),
6455
+ dns: this.dnsPort(),
6456
+ zones: this.zonePort(),
6457
+ coolify: this.ports.coolify,
6458
+ secret: (value) => void this.handledSecrets.add(value),
6459
+ }, {
6460
+ org,
6461
+ desired,
6462
+ databaseHost,
6463
+ projectUuid,
6464
+ recorded: () => context.recordedIds(),
6465
+ record: (key, value) => context.recordScopeId(key, value),
6466
+ persist: () => context.persistScopeIds(),
6467
+ });
6468
+ return {};
6469
+ }
6261
6470
  /** `deliverSecret`, tried up to {@link JOBS_DELIVERY_ATTEMPTS} times; true once one attempt succeeds. */
6262
6471
  async deliverWithRetry(ref, value) {
6263
6472
  for (let attempt = 1; attempt <= JOBS_DELIVERY_ATTEMPTS; attempt++) {
@@ -6337,17 +6546,19 @@ export class FoundryStepExecutor {
6337
6546
  const loginHost = action.desired.loginHost ?? null;
6338
6547
  // A hosting entry's names (and its wildcard) go straight to its container on the company network:
6339
6548
  // the container carries `traefik.enable=false`, so the proxy has no route for it.
6340
- const entryOrigins = new Map(entryRoutes(org).map((r) => [r.hostname, r.service]));
6549
+ const entryOrigins = new Map(entryRoutes(org, context.recordedIds()).map((r) => [r.hostname, r.service]));
6341
6550
  const ingress = tunnelIngressHostnames(org, loginHost).map((hostname) => ({
6342
6551
  hostname,
6343
6552
  service: hostname === loginHost
6344
6553
  ? `http://${PLATFORM_AUTH_LOGIN_INTERNAL_NAME}:${AUTH_LOGIN_PORT}`
6345
6554
  : (entryOrigins.get(hostname) ?? COOLIFY_PROXY_ORIGIN),
6346
6555
  }));
6556
+ const httpIngress = [...ingress, { service: 'http_status:404' }];
6347
6557
  await this.tunnelPort().putTunnelConfig(tunnel.id, {
6348
- ingress: [...ingress, { service: 'http_status:404' }],
6558
+ ingress: mergeTcpIngress(httpIngress, recordedTcpRoutes(context.recordedIds())),
6349
6559
  });
6350
- context.recordScopeId(CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, entryRoutesMarker(org));
6560
+ context.recordScopeId(TUNNEL_HTTP_INGRESS_SCOPE_KEY, JSON.stringify(httpIngress));
6561
+ context.recordScopeId(CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, entryRoutesMarker(org, context.recordedIds()));
6351
6562
  // #235: a connector for this org's own tunnel, the same call every
6352
6563
  // platform tunnel step already makes (Decided, William, 2026-10-01:
6353
6564
  // one connector per org tunnel, never a shared tunnel with appended
@@ -8114,8 +8325,32 @@ export class FoundryStepExecutor {
8114
8325
  return;
8115
8326
  }
8116
8327
  case 'coolify:app':
8328
+ case 'coolify:bg-app':
8117
8329
  await this.ports.coolify.deleteApplication(target.id);
8118
8330
  return;
8331
+ case 'cloudflare:bg-access':
8332
+ await this.accessPort().deleteAccessApp(target.id);
8333
+ return;
8334
+ case 'cloudflare:bg-token':
8335
+ await this.accessPort().removeServiceToken(target.id);
8336
+ return;
8337
+ case 'cloudflare:bg-dns': {
8338
+ // `target.id` is the recorded route ({ hostname, service }); its name lives in the org's zone.
8339
+ const hostname = JSON.parse(target.id).hostname;
8340
+ const zone = org.domain ? await this.zonePort().findZoneByApex(org.domain) : undefined;
8341
+ if (typeof hostname !== 'string' || !zone)
8342
+ return;
8343
+ const dns = this.dnsPort();
8344
+ for (const record of await dns.findDnsRecord(zone.name, hostname, 'CNAME')) {
8345
+ if (record.content.endsWith('.cfargotunnel.com')) {
8346
+ await dns.deleteDnsRecord(zone.name, record.id);
8347
+ }
8348
+ }
8349
+ return;
8350
+ }
8351
+ case 'coolify:bg-database':
8352
+ await this.ports.coolify.deleteDatabase(target.id);
8353
+ return;
8119
8354
  case 'coolify:database':
8120
8355
  await this.ports.coolify.deleteDatabase(target.id);
8121
8356
  return;
@@ -2,18 +2,21 @@ import type { DestroyProgress, PlanApply } from '../apply.js';
2
2
  import type { DeclaredOrg, OrgId } from '../org.js';
3
3
  import type { Plan, PlanStep } from '../plan.js';
4
4
  import type { DeclaredPlatform } from '../platform.js';
5
+ import type { ReleaseStore } from '../release/state.js';
5
6
  import type { JobRunner } from '../runner/types.js';
6
7
  import type { AppliedState } from '../state.js';
7
8
  import { type AppliedVersionsStore, type DeclaredOrgStore, type PlatformStore } from '../store.js';
8
9
  import { type AdoptCandidate, type AdoptInput, type AdoptPlan } from './adopt.js';
9
10
  import { type AgoraHostPort } from './agora.js';
10
11
  import { type MoveGatePorts } from './backup-gate.js';
12
+ import { type BlueGreenServer, type BlueGreenSubject } from './blue-green.js';
11
13
  import { type UnconfirmedUpgrade } from './confirm-upgrade.js';
12
14
  import { type StepAction } from './diff.js';
13
15
  import { type LiveReaders, type LiveResource, type PlatformLiveReaders } from './live.js';
14
16
  import { type LoginHostDiscoveryPort } from './login-host.js';
15
17
  import { type EdgePortName, type HeldKeyResolver, type PlatformOwnerInput } from './platform-org.js';
16
18
  import type { CallsSfuConnectionPort } from './platform-sfu.js';
19
+ import { type ReleaseContext, type ReleaseHost } from './release-host.js';
17
20
  /** Who did it, for `@wtfalch/audit`'s "who, when, the plan, the result" (#9, #19). */
18
21
  export interface AuditActor {
19
22
  readonly id: string;
@@ -205,6 +208,17 @@ export interface StepExecutor {
205
208
  }>;
206
209
  /** Whether the executor can mint an own org's Lettermint key: a Lettermint port and a reader for the held ref. */
207
210
  ownMailKeyMintable?(): boolean;
211
+ /** Every Coolify server with whether it is usable and reachable; blue/green plans copies on each. Optional: an executor without it plans no blue/green. */
212
+ blueGreenServers?(): Promise<Array<{
213
+ uuid: string;
214
+ usable: boolean;
215
+ reachable: boolean;
216
+ }>>;
217
+ /**
218
+ * The release runner's outside calls for one blue/green entry (`plan/release-host.ts`); `null` while its blue
219
+ * copy is not recorded. Optional: an executor without it runs no release.
220
+ */
221
+ releaseHost?(org: DeclaredOrg, subject: BlueGreenSubject, context: ReleaseContext, renew: () => Promise<void>): ReleaseHost | null;
208
222
  /** Whether the executor's held keys can store the sign-in database's CA and certificate (#746). Absent: true. */
209
223
  ownSigninDbKeysPuttable?(): boolean;
210
224
  /** Whether the host supplies the backup bucket the sign-in database dumps into (#746). Absent: true. */
@@ -325,6 +339,16 @@ export interface PlatformStepExecutor {
325
339
  * one), or `null` when nothing changed.
326
340
  */
327
341
  refreshFilesShare?(platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>): Promise<Record<string, string> | null>;
342
+ /**
343
+ * The Coolify servers and the reason platform-archon's blue/green cannot run (the executor cannot hold the
344
+ * health token), for the plan. Optional: an executor without it plans no blue/green for archon.
345
+ */
346
+ archonBlueGreen?(): Promise<{
347
+ servers?: BlueGreenServer[];
348
+ blocked?: string;
349
+ }>;
350
+ /** The release runner's outside calls for platform-archon (`plan/release-host.ts`); `null` while its app is not recorded. */
351
+ releaseHost?(platform: DeclaredPlatform, subject: BlueGreenSubject, context: ReleaseContext, renew: () => Promise<void>): ReleaseHost | null;
328
352
  execute(action: StepAction, context: PlatformStepExecutionContext): Promise<{
329
353
  resourceId?: string;
330
354
  } | undefined>;
@@ -510,6 +534,11 @@ export interface FoundryPlanApplyOptions {
510
534
  step(action: StepAction): boolean;
511
535
  blocked(key: string): boolean;
512
536
  };
537
+ /**
538
+ * The release requests (Archon's promote, roll back, clear held and decide-at-cap clicks), consumed once each by
539
+ * the release runner. Without it no release runs, in an apply or in {@link FoundryPlanApply.runReleaseTick}.
540
+ */
541
+ releaseStore?: ReleaseStore;
513
542
  /** Overridable for tests; defaults to `() => new Date()`. */
514
543
  now?: () => Date;
515
544
  /** #441: how long a mutation lease lasts before another caller may take it over. Defaults to 30 minutes. */
@@ -605,6 +634,7 @@ export declare class FoundryPlanApply implements PlanApply {
605
634
  private readonly backupGate;
606
635
  private readonly platformDeps;
607
636
  private readonly runner;
637
+ private readonly releaseStore;
608
638
  constructor(options: FoundryPlanApplyOptions);
609
639
  plan(id: OrgId): Promise<Plan>;
610
640
  /**
@@ -741,6 +771,27 @@ export declare class FoundryPlanApply implements PlanApply {
741
771
  private refusePlatform;
742
772
  private refuseDestroy;
743
773
  private runSteps;
774
+ /**
775
+ * ADR 0001: one release run for each blue/green entry whose structure is applied, writing into `context`
776
+ * (the live ids of the run that holds the lease). A weight the runner changed reaches every server's balancer
777
+ * before this returns.
778
+ */
779
+ private runOrgReleases;
780
+ /** One release run for platform-archon, on the platform's live ids; see {@link runOrgReleases}. */
781
+ private runPlatformReleases;
782
+ private runPlatformReleaseTick;
783
+ /**
784
+ * The recurring release run (ADR 0001): for every declared org with a blue/green entry, and the platform's
785
+ * archon when it declares one, takes the open requests, reads the health signal and moves traffic. Foundry has
786
+ * no scheduler of its own, so the host calls this every 10 minutes. An org that cannot run (the actor may not
787
+ * apply it, an apply holds its lease, a provider call fails) is reported by id and does not stop the others.
788
+ * A refusal is audited; a run that does nothing writes no audit row.
789
+ */
790
+ runReleaseTick(): Promise<Array<{
791
+ target: string;
792
+ error?: string;
793
+ }>>;
794
+ private runOrgReleaseTick;
744
795
  /** An own org with calls on: the host's answer about its slot's ports. A failure or a slow answer is "not open". */
745
796
  private readSfuPortsOpen;
746
797
  private computeFreshPlan;