@wtfalch/foundry 0.62.0 → 0.64.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 (91) hide show
  1. package/dist/apps/index.d.ts +1 -0
  2. package/dist/apps/index.js +1 -0
  3. package/dist/apps/server-entry.d.ts +43 -0
  4. package/dist/apps/server-entry.js +99 -0
  5. package/dist/bootstrap/declare.js +4 -1
  6. package/dist/bootstrap/versions.d.ts +2 -0
  7. package/dist/build-json.d.ts +25 -0
  8. package/dist/build-json.js +61 -0
  9. package/dist/catalogue/copy-en.js +10 -0
  10. package/dist/catalogue/integrations.js +10 -0
  11. package/dist/index.d.ts +8 -4
  12. package/dist/index.js +3 -2
  13. package/dist/migrations/0067_requested_changes_kosmos.sql +6 -0
  14. package/dist/migrations/0068_platform_registry.sql +17 -0
  15. package/dist/modules/customer-grants.d.ts +3 -0
  16. package/dist/modules/customer-grants.js +28 -0
  17. package/dist/modules/index.js +49 -1
  18. package/dist/modules/repository.d.ts +6 -0
  19. package/dist/modules/repository.js +40 -0
  20. package/dist/modules/types.d.ts +8 -0
  21. package/dist/native-build.d.ts +7 -27
  22. package/dist/native-build.js +7 -27
  23. package/dist/org.d.ts +19 -1
  24. package/dist/org.js +45 -3
  25. package/dist/plan/archon-blue-green.d.ts +5 -0
  26. package/dist/plan/archon-blue-green.js +2 -0
  27. package/dist/plan/blue-green-apply.d.ts +5 -0
  28. package/dist/plan/blue-green-apply.js +197 -22
  29. package/dist/plan/blue-green.d.ts +61 -2
  30. package/dist/plan/blue-green.js +80 -6
  31. package/dist/plan/boule.d.ts +1 -1
  32. package/dist/plan/boule.js +2 -0
  33. package/dist/plan/broker-entries.d.ts +9 -0
  34. package/dist/plan/broker-entries.js +29 -0
  35. package/dist/plan/diff.d.ts +5 -0
  36. package/dist/plan/diff.js +21 -2
  37. package/dist/plan/edge-access.d.ts +2 -3
  38. package/dist/plan/edge-access.js +7 -0
  39. package/dist/plan/executor.d.ts +71 -1
  40. package/dist/plan/executor.js +491 -56
  41. package/dist/plan/index.d.ts +2 -0
  42. package/dist/plan/index.js +1 -0
  43. package/dist/plan/jobs-scheduler-http.d.ts +2 -0
  44. package/dist/plan/jobs-scheduler-http.js +11 -0
  45. package/dist/plan/own-limits.d.ts +6 -0
  46. package/dist/plan/own-limits.js +1 -0
  47. package/dist/plan/plan-apply.d.ts +51 -1
  48. package/dist/plan/plan-apply.js +298 -25
  49. package/dist/plan/platform-diff.d.ts +23 -1
  50. package/dist/plan/platform-diff.js +96 -0
  51. package/dist/plan/platform-org.d.ts +2 -0
  52. package/dist/plan/platform-providers.d.ts +46 -1
  53. package/dist/plan/platform-providers.js +156 -1
  54. package/dist/plan/platform-registry-keys.d.ts +58 -0
  55. package/dist/plan/platform-registry-keys.js +69 -0
  56. package/dist/plan/platform-registry.d.ts +67 -0
  57. package/dist/plan/platform-registry.js +298 -0
  58. package/dist/plan/release-host.d.ts +31 -2
  59. package/dist/plan/release-host.js +114 -6
  60. package/dist/plan/stage-copy-host.d.ts +104 -0
  61. package/dist/plan/stage-copy-host.js +259 -0
  62. package/dist/plan/stage-copy.js +44 -0
  63. package/dist/plan/stage-env.d.ts +9 -0
  64. package/dist/plan/stage-env.js +38 -0
  65. package/dist/plan/stage-guards.d.ts +57 -1
  66. package/dist/plan/stage-guards.js +106 -0
  67. package/dist/plan/stage-isolation.js +56 -0
  68. package/dist/plan/stage-services.d.ts +93 -0
  69. package/dist/plan/stage-services.js +153 -0
  70. package/dist/plan/stage-signin.d.ts +152 -0
  71. package/dist/plan/stage-signin.js +133 -0
  72. package/dist/plan/stage-sink.d.ts +2 -0
  73. package/dist/plan/stage-sink.js +6 -1
  74. package/dist/platform.d.ts +7 -1
  75. package/dist/platform.js +1 -0
  76. package/dist/release/decide.d.ts +12 -0
  77. package/dist/release/decide.js +37 -2
  78. package/dist/release/run.d.ts +71 -1
  79. package/dist/release/run.js +92 -8
  80. package/dist/release/state.d.ts +14 -1
  81. package/dist/release/state.js +43 -0
  82. package/dist/release/types.d.ts +20 -0
  83. package/dist/release/types.js +2 -0
  84. package/dist/requested-changes.d.ts +11 -2
  85. package/dist/requested-changes.js +11 -0
  86. package/dist/store/postgres.js +8 -0
  87. package/dist/store/schema.d.ts +51 -0
  88. package/dist/store/schema.js +4 -0
  89. package/package.json +3 -2
  90. package/versions.digests.json +1 -0
  91. package/versions.json +1 -0
@@ -11,6 +11,7 @@ import { legacyAppHostname } from './hostnames.js';
11
11
  import { PLATFORM_MAIL_RELAY_KEY, planMailRelay } from './mail-relay.js';
12
12
  import { MAIL_DATABASE_SCOPE_KEY, mailStoreIsLegacy, mailStoreSwitchChange, mailStoreSwitchDescription, } from './mail-store.js';
13
13
  import { planOikosIngress } from './oikos-ingress.js';
14
+ import { PLATFORM_REGISTRY_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_REGISTRY_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_REGISTRY_KEY, PLATFORM_REGISTRY_LOGINS_APPLIED_SCOPE_KEY, PLATFORM_REGISTRY_LOGINS_KEY, PLATFORM_REGISTRY_PASSWORDS_SET_SCOPE_KEY, PLATFORM_REGISTRY_PASSWORDS_UNDELIVERED_SCOPE_KEY, registryLoginVersion, } from './platform-registry-keys.js';
14
15
  import { callsSfuAppliedFingerprint } from './platform-sfu.js';
15
16
  import { postLogoutUpdateOwed, postLogoutUrisFor } from './post-logout-uris.js';
16
17
  import { archonInAppHost } from './sign-in-archon.js';
@@ -811,6 +812,28 @@ export function appliedAppSettings(recorded, entryKey) {
811
812
  const marker = recorded[appSettingsAppliedScopeKey(entryKey)];
812
813
  return marker ? JSON.parse(marker) : DEFAULT_APP_SETTINGS;
813
814
  }
815
+ /**
816
+ * The image an entry's app last ran, as `repository@digest` (no secret). `planApp` plans the step again, with a
817
+ * redeploy, when the entry's `image` differs from it.
818
+ */
819
+ export function appImageAppliedScopeKey(entryKey) {
820
+ return `app_image_applied.${entryKey}`;
821
+ }
822
+ /** An entry image as one pull reference. */
823
+ export function entryImageRef(image) {
824
+ return `${image.repository}@${image.digest}`;
825
+ }
826
+ /**
827
+ * The two image fields Coolify accepts for an entry image. Its tag rule has no `@` or `:`, so the digest goes in
828
+ * the name as `<repository>@sha256` and the tag holds the bare 64 hex digits (Coolify pulls `<name>:<tag>`),
829
+ * the same way `coolifyImageFor` writes a pinned platform image.
830
+ */
831
+ export function entryImageFields(image) {
832
+ return {
833
+ docker_registry_image_name: `${image.repository}@sha256`,
834
+ docker_registry_image_tag: image.digest.replace(/^sha256:/, ''),
835
+ };
836
+ }
814
837
  /**
815
838
  * The URLs `coolifyApp` last put in the entry's Coolify `domains`
816
839
  * (`tunnelDomainList`), as a JSON array (`[]` for an internal entry). `planApp`
@@ -1418,6 +1441,12 @@ export function planPlatform(platform, live, options = {}) {
1418
1441
  const hc = planHc(platform, recordedIds, blocked.hc ?? dnsBlocked?.('heartbeat server'));
1419
1442
  if (hc.step)
1420
1443
  plannedSteps.push(hc.step);
1444
+ // The image registry: edge's DNS and tunnel like hc. The login step follows the app step.
1445
+ const registry = planRegistry(platform, recordedIds, blocked.registry ?? dnsBlocked?.('image registry'), drifted.registryLogins ?? false);
1446
+ if (registry.step)
1447
+ plannedSteps.push(registry.step);
1448
+ if (registry.loginsStep)
1449
+ plannedSteps.push(registry.loginsStep);
1421
1450
  // #540: the `authReady` pattern, for hc -- true once hc is either already
1422
1451
  // applied or about to run earlier in this very plan, so the heartbeats
1423
1452
  // reconcile below can be planned in the *same* round hc first comes up,
@@ -1494,6 +1523,7 @@ export function planPlatform(platform, live, options = {}) {
1494
1523
  recorded: recordedIds,
1495
1524
  servers: archonBlueGreen.servers,
1496
1525
  executorBlocked: archonBlueGreen.blocked,
1526
+ ...(archonBlueGreen.guards ? { guards: archonBlueGreen.guards } : {}),
1497
1527
  })
1498
1528
  : { step: null, unmanaged: [] };
1499
1529
  if (archonBg.step) {
@@ -1637,6 +1667,8 @@ export function planPlatform(platform, live, options = {}) {
1637
1667
  }
1638
1668
  if (hc.blocked)
1639
1669
  unmanaged.push({ description: hc.blocked, key: 'hc' });
1670
+ if (registry.blocked)
1671
+ unmanaged.push({ description: registry.blocked, key: 'registry' });
1640
1672
  if (archonOrg.blocked)
1641
1673
  unmanaged.push({ description: archonOrg.blocked, key: 'archon-org' });
1642
1674
  if (archon.blocked)
@@ -2887,6 +2919,70 @@ function planAccessProvider(platform, recordedIds, executorBlocked, ownerSignIn)
2887
2919
  * Blocked, not planned: `executorBlocked`, from `dnsBlocked` (edge neither
2888
2920
  * applied nor planned) or the host building no `hc` dependencies.
2889
2921
  */
2922
+ /**
2923
+ * The platform's image registry (C5): `coolify:app` keyed {@link PLATFORM_REGISTRY_KEY}, then
2924
+ * `coolify:registry-logins` keyed {@link PLATFORM_REGISTRY_LOGINS_KEY}, which logs every Coolify
2925
+ * server in as `pull`. The logins are planned while the host and passwords they last ran with
2926
+ * differ from the current ones, and again when a server has no login (`drifted`).
2927
+ */
2928
+ function planRegistry(platform, recordedIds, executorBlocked, loginsDrifted) {
2929
+ const none = { step: null, loginsStep: null, blocked: null };
2930
+ if (!platform.registry.enabled)
2931
+ return none;
2932
+ const { hostname, gitRef } = platform.registry;
2933
+ const undelivered = recordedIds[PLATFORM_REGISTRY_PASSWORDS_UNDELIVERED_SCOPE_KEY];
2934
+ if (undelivered && !recordedIds[PLATFORM_REGISTRY_PASSWORDS_SET_SCOPE_KEY]) {
2935
+ return {
2936
+ ...none,
2937
+ blocked: `the image registry is blocked, not planned: its passwords (${undelivered}) were made but never confirmed delivered and cannot be shown again; remove the recorded id ${PLATFORM_REGISTRY_PASSWORDS_UNDELIVERED_SCOPE_KEY} to make new ones`,
2938
+ };
2939
+ }
2940
+ const applied = Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_REGISTRY_KEY)]) &&
2941
+ Boolean(recordedIds[PLATFORM_REGISTRY_PASSWORDS_SET_SCOPE_KEY]) &&
2942
+ recordedIds[PLATFORM_REGISTRY_APPLIED_HOSTNAME_SCOPE_KEY] === hostname &&
2943
+ recordedIds[PLATFORM_REGISTRY_APPLIED_GIT_REF_SCOPE_KEY] === gitRef;
2944
+ if (executorBlocked && !applied)
2945
+ return { ...none, blocked: executorBlocked };
2946
+ const step = applied
2947
+ ? null
2948
+ : {
2949
+ action: {
2950
+ system: 'coolify',
2951
+ kind: 'app',
2952
+ key: PLATFORM_REGISTRY_KEY,
2953
+ desired: { hostname, gitRef },
2954
+ },
2955
+ step: {
2956
+ description: `provision the image registry at ${hostname}: the build workflow pushes company app images there and the company servers pull them`,
2957
+ destroys: false,
2958
+ migrates: false,
2959
+ },
2960
+ };
2961
+ const loginsCurrent = recordedIds[PLATFORM_REGISTRY_LOGINS_APPLIED_SCOPE_KEY] ===
2962
+ registryLoginVersion(hostname, recordedIds[PLATFORM_REGISTRY_PASSWORDS_SET_SCOPE_KEY]) &&
2963
+ Boolean(recordedIds[PLATFORM_REGISTRY_PASSWORDS_SET_SCOPE_KEY]);
2964
+ const loginsStep = (loginsCurrent && !loginsDrifted) || executorBlocked
2965
+ ? null
2966
+ : {
2967
+ action: {
2968
+ system: 'coolify',
2969
+ kind: 'registry-logins',
2970
+ key: PLATFORM_REGISTRY_LOGINS_KEY,
2971
+ desired: { hostname },
2972
+ },
2973
+ step: {
2974
+ description: `log every Coolify server in to the image registry at ${hostname} so it can pull company app images`,
2975
+ destroys: false,
2976
+ migrates: false,
2977
+ },
2978
+ };
2979
+ const loginsNeeded = !loginsCurrent || loginsDrifted;
2980
+ return {
2981
+ step,
2982
+ loginsStep,
2983
+ blocked: executorBlocked && !step && loginsNeeded ? executorBlocked : null,
2984
+ };
2985
+ }
2890
2986
  function planHc(platform, recordedIds, executorBlocked) {
2891
2987
  if (!platform.hc.enabled)
2892
2988
  return { step: null, blocked: null };
@@ -373,6 +373,8 @@ export interface PlatformOwnerInput {
373
373
  signIn?: OwnerSignIn | null;
374
374
  files?: OwnerFiles | null;
375
375
  chat?: OwnerChat | null;
376
+ /** The owner company's owners' emails: who passes the Access gate over Archon's stage. */
377
+ emails?: readonly string[];
376
378
  }
377
379
  /**
378
380
  * The owner's sign-in from its declared org and the ids its own steps recorded.
@@ -12,6 +12,8 @@ import type { TunnelPort } from './edge-tunnels.js';
12
12
  import type { ZonePort } from './edge-zones.js';
13
13
  import type { CloudflarePort, CoolifyPort, FoundryStepExecutorPorts, ZitadelPort } from './executor.js';
14
14
  import { type ReleaseContext, type ReleaseHost } from './release-host.js';
15
+ import { type StageCopyHostConfig } from './stage-copy-host.js';
16
+ import { type ReleasePreviewMembers, type StageProbePort } from './stage-guards.js';
15
17
  export { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
16
18
  import type { LiveResource, PlatformCloudflareReader, PlatformCoolifyReader } from './live.js';
17
19
  import { type OikosIngressPorts } from './oikos-ingress.js';
@@ -19,6 +21,7 @@ import { type FilesKeysMinted, type ProjectSource, type ServiceNetwork, type Ser
19
21
  import type { PlatformDriftedSteps, PlatformStepExecutionContext, PlatformStepExecutor } from './plan-apply.js';
20
22
  import { type BrokerDeployRecord, PLATFORM_ZITADEL_ORG_SCOPE_KEY } from './platform-diff.js';
21
23
  import type { PlatformOwnerInput } from './platform-org.js';
24
+ import { type PlatformRegistryDeps } from './platform-registry.js';
22
25
  import type { SigninDbBackupPort } from './signin-db-backup.js';
23
26
  import { type PlatformZitadelMailBranding } from './signin-mail.js';
24
27
  export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
@@ -100,7 +103,7 @@ export declare function platformRequestHeaders(deps: {
100
103
  */
101
104
  export type PlatformAccessFactory = (edgeUrl: string) => PlatformAccessPort | Promise<PlatformAccessPort>;
102
105
  /** The Access calls a platform step makes. */
103
- export type PlatformAccessPort = Pick<AccessPort, 'listAccessApps' | 'createAccessApp' | 'ensureOneTimePinProvider' | 'listAccessPolicies' | 'createAccessPolicy' | 'deleteAccessApp'>;
106
+ export type PlatformAccessPort = Pick<AccessPort, 'listAccessApps' | 'createAccessApp' | 'ensureOneTimePinProvider' | 'listAccessPolicies' | 'createAccessPolicy' | 'deleteAccessApp'> & Partial<Pick<AccessPort, 'setGatePeople'>>;
104
107
  /**
105
108
  * The SMTP call `provisionPlatformAuth` makes on the instance's ZITADEL.
106
109
  * `@wtfalch/providers` gains it in 0.7.2; stated here so foundry compiles
@@ -309,6 +312,8 @@ export interface FoundryPlatformStepExecutorOptions {
309
312
  * `platform.hc.enabled` without it gets the step reported as blocked.
310
313
  */
311
314
  hc?: PlatformHcDeps;
315
+ /** C5: dependencies for the image registry steps; a host that declares `platform.registry.enabled` without it gets them reported as blocked. */
316
+ registry?: PlatformRegistryDeps;
312
317
  /**
313
318
  * #540: dependencies for the `heartbeats:reconcile` step -- optional for
314
319
  * the same reason as `auth`; `hc` applied with this unset gets the
@@ -354,6 +359,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
354
359
  private readonly archon?;
355
360
  private readonly backup?;
356
361
  private readonly hc?;
362
+ private readonly registry?;
357
363
  private readonly heartbeats?;
358
364
  private readonly uptime?;
359
365
  private readonly accessProvider?;
@@ -365,14 +371,32 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
365
371
  private onPlatform;
366
372
  /** `deps` with its Coolify port placed on the platform destination, then limited by `profile`. */
367
373
  private limitedOnPlatform;
374
+ /**
375
+ * Why the image registry cannot be planned, as fixed text, or `undefined`. The held `pull`
376
+ * password is read only to know it is there; its value goes nowhere.
377
+ */
378
+ private registryBlockedReason;
368
379
  /** Every Coolify server and why platform-archon's blue/green cannot run here, for the planner. */
369
380
  archonBlueGreen(): Promise<{
370
381
  servers?: BlueGreenServer[];
371
382
  blocked?: string;
372
383
  }>;
384
+ /**
385
+ * The stage guards that can be made for platform-archon now. The planner puts the names in the applied hash
386
+ * and the step builds the same guards from the same facts, so a guard that becomes possible later plans the
387
+ * structure step once more.
388
+ */
389
+ archonStageGuardNames(platform: DeclaredPlatform, recorded: Readonly<Record<string, string>>, owner: PlatformOwnerInput): string[];
373
390
  /** The release runner's outside calls for platform-archon (`plan/release-host.ts`). The token is only read. */
374
391
  releaseHost(platform: DeclaredPlatform, subject: BlueGreenSubject, context: ReleaseContext, renew: () => Promise<void>): ReleaseHost | null;
392
+ /** Archon's stage gate follows the people holding `releases:preview` (orgId `platform`); the platform's owners stay. */
393
+ syncStageGateMembers(_platform: DeclaredPlatform, subject: BlueGreenSubject, context: ReleaseContext): Promise<void>;
375
394
  private archonBlueGreenStep;
395
+ /**
396
+ * Archon's stage guards: the probe port, and the Access gate over the stage host, offered only when the
397
+ * same facts {@link archonStageGuardNames} counts are all in hand.
398
+ */
399
+ private archonStageGuards;
376
400
  blockedSteps(platform: DeclaredPlatform, recordedIds?: Readonly<Record<string, string>>, owner?: PlatformOwnerInput): Promise<{
377
401
  auth?: string;
378
402
  authExecutorMissing?: boolean;
@@ -389,6 +413,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
389
413
  oikosIngress?: string;
390
414
  backupExecutorMissing?: boolean;
391
415
  hc?: string;
416
+ registry?: string;
392
417
  heartbeatsExecutorMissing?: boolean;
393
418
  heartbeatsApiKeyMissing?: boolean;
394
419
  uptime?: string;
@@ -955,14 +980,24 @@ export declare function ownerInviteBlockedReason(ownerCompany: DeclaredOrg | nul
955
980
  */
956
981
  export interface PlatformArchonDeps {
957
982
  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'> & {
983
+ deleteDatabase?: CoolifyPort['deleteDatabase'];
984
+ listDatabaseBackups?: CoolifyPort['listDatabaseBackups'];
958
985
  listApplicationDeployments?: CoolifyPort['listApplicationDeployments'];
986
+ listDatabaseBackupExecutions?: CoolifyPort['listDatabaseBackupExecutions'];
959
987
  };
988
+ /**
989
+ * The stage copy of platform-archon (run by the release tick). Same shape as `FoundryStepExecutorPorts.stageCopy`.
990
+ * Without it a copy request is refused with `no-copy-port`.
991
+ */
992
+ stageCopy?: StageCopyHostConfig;
960
993
  /** F4.dns: every DNS record call, through edge at its recorded address (see {@link PlatformDnsFactory}). */
961
994
  dns: PlatformDnsFactory;
962
995
  /** F4.tunnels: every tunnel call, through edge at its recorded address (see {@link PlatformTunnelsFactory}). */
963
996
  tunnels: PlatformTunnelsFactory;
964
997
  /** F4.access: every Access app call, through edge at its recorded address (see {@link PlatformAccessFactory}). */
965
998
  access: PlatformAccessFactory;
999
+ /** The people holding `releases:preview` for Archon's own stage (orgId `platform`); see FoundryStepExecutorPorts.releasePreviewMembers. */
1000
+ releasePreviewMembers?: ReleasePreviewMembers;
966
1001
  cloudflare: Pick<CloudflarePort, 'findZoneByApex'>;
967
1002
  /** Must target the platform's own ZITADEL instance, or `zitadelForInstance` must be set. */
968
1003
  zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'findOrCreateProject' | 'createOidcApp' | 'listApps' | 'deleteApp' | 'updateOidcAppIdTokenUserinfoAssertion'> & Partial<Pick<ZitadelPort, 'ensureTrustedDomain' | 'deleteTrustedDomain' | 'updateOidcAppLoginBaseUri' | 'allowPerAppLoginBaseUri' | 'updateOidcAppPostLogoutRedirectUris' | 'getOidcAppRedirectUris' | 'updateOidcAppRedirectUris'>>;
@@ -979,6 +1014,16 @@ export interface PlatformArchonDeps {
979
1014
  * Access app nobody can pass.
980
1015
  */
981
1016
  accessEmails: readonly string[];
1017
+ /**
1018
+ * Archon's stage guards, the same two an entry's stage gets (`plan/stage-guards.ts`): the probe port, and the
1019
+ * Access calls that make the gate over `stage-platform-archon.<apex>`. A missing part leaves that guard
1020
+ * unrecorded ("not run"), so the stage copy stays refused.
1021
+ */
1022
+ stageGuards?: {
1023
+ probes?: StageProbePort;
1024
+ /** Edge's Access calls at its recorded address, like `access`. */
1025
+ gate?: (edgeUrl: string) => Pick<AccessPort, 'createGate'> | Promise<Pick<AccessPort, 'createGate'>>;
1026
+ };
982
1027
  /**
983
1028
  * True when the host process runs inside the Coolify app this step
984
1029
  * redeploys (Archon's console is `platform:archon`). A deploy that waits
@@ -5,7 +5,7 @@ import { VERSIONS, coolifyImageFor, floatingTagBlocked, pinnedRef } from '../boo
5
5
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
6
6
  import { ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
7
7
  import { applyArchonBlueGreen } from './archon-blue-green.js';
8
- import { ARCHON_REPO, archonSubject, healthTokenName, pinKeyName, tunnelOriginFor, } from './blue-green.js';
8
+ import { ARCHON_REPO, archonSubject, healthTokenName, parseGuards, pinKeyName, stageGuardInputs, tunnelOriginFor, } from './blue-green.js';
9
9
  import { BOULE_BRIDGE_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_PEER_ROLE, bouleHostnameFor, } from './boule.js';
10
10
  import { recordBuiltCommit } from './build-record.js';
11
11
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
@@ -20,8 +20,11 @@ import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_
20
20
  import { LIMITS, limited } from './own-limits.js';
21
21
  import { postLogoutAppliedValue, postLogoutUrisFor } from './post-logout-uris.js';
22
22
  import { createReleaseHost } from './release-host.js';
23
+ import { buildStageCopyHostPorts } from './stage-copy-host.js';
24
+ import { stageGateKey, stageHostname, syncStageGateMembers, } from './stage-guards.js';
23
25
  export { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
24
26
  import { BROKER_DB_NAME, BROKER_DB_USER, ensureBrokerMcpEnv } from './broker-mcp.js';
27
+ import { withTimeout } from './call-timeout.js';
25
28
  import { SIGN_IN_BIND_KEY_ENV, SIGN_IN_BIND_KEY_MIN_LENGTH, SIGN_IN_BIND_KEY_SCOPE_KEY, } from './cookie-domain.js';
26
29
  import { coolifyDomains, coolifyTunnelDomains, filesShareHostname } from './hostnames.js';
27
30
  import { MAIL_RELAY_BLOCKED, provisionMailRelay } from './mail-relay.js';
@@ -30,6 +33,8 @@ import { OIKOS_INGRESS_BLOCKED, PLATFORM_OIKOS_INGRESS_KEY, oikosIngressApplied,
30
33
  import { recordOwnDnsHostname } from './own-dns.js';
31
34
  import { platformNetwork, platformTarget, scopeFromPlatform, } from './own-services.js';
32
35
  import { AUTH_TUNNEL_ROUTES_VERSION, AUTH_ZITADEL_RUN_OPTIONS, GITHUB_RELEASE_HOOK_DEPS_MISSING, GITHUB_RELEASE_WEBHOOK_SECRET_ENV, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY, PLATFORM_ACCESS_ZITADEL_APP_SCOPE_KEY, PLATFORM_ACCESS_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_TUNNEL_SCOPE_KEY, PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_CI_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FILES_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FIRST_OWNER_LINK_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_APP_KEY_ORG_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_DESKTOP_CALLBACK_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_ARCHON_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_ARCHON_ORG_DOMAIN_SCOPE_KEY, PLATFORM_ARCHON_ORG_POLICY_SCOPE_KEY, PLATFORM_ARCHON_ORG_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY, PLATFORM_ARCHON_POST_LOGOUT_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY, PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_WEBHOOK_SECRET_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_MCP_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_GITHUB_RELEASE_HOOK_KEY, PLATFORM_HC_ACCESS_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_HEARTBEATS_HOSTED_API_KEY_REF, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_HOST_PORTS_DIRECT, PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_RELAY_KEY, PLATFORM_MAIL_ROLE_CHECK_SCOPE_KEY, PLATFORM_MAIL_ROLE_ENSURED_SCOPE_KEY, PLATFORM_MAIL_ROLE_GRANTED_SCOPE_KEY, PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED, PLATFORM_MAIL_USER_ROLE, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY, PLATFORM_OWNER_ORG_SCOPE_KEY, PLATFORM_SFU_APPLIED_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, PLATFORM_ZITADEL_ORG_SCOPE_KEY, archonFilesMarker, archonFirstOwnerLink, archonOrgDomain, archonOrgName, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, orgDeclaresMcp, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailHostPorts, platformMailMxHost, uptimeAccessTokenConflict, uptimeEffectiveVars, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
36
+ import { PLATFORM_REGISTRY_KEY, PLATFORM_REGISTRY_LOGINS_KEY, PLATFORM_REGISTRY_PASSWORDS_SET_SCOPE_KEY, PLATFORM_REGISTRY_PULL_PASSWORD_REF, registryImageTagBlocked, } from './platform-registry-keys.js';
37
+ import { ensureRegistryLogins, provisionPlatformRegistry, registryLoginsDrifted, } from './platform-registry.js';
33
38
  import { PLATFORM_ARCHON_IN_APP_SCOPE_KEY, archonInAppBack, archonInAppBind, archonInAppHost, archonInAppPoint, archonInAppUnbind, } from './sign-in-archon.js';
34
39
  import { LOGIN_HOST_BINDINGS_ENV } from './sign-in-bindings.js';
35
40
  import { ensureSignInOrg, inviteOwnerWithLink, signInOrgDomain } from './sign-in-org.js';
@@ -143,6 +148,7 @@ export class FoundryPlatformStepExecutor {
143
148
  archon;
144
149
  backup;
145
150
  hc;
151
+ registry;
146
152
  heartbeats;
147
153
  uptime;
148
154
  accessProvider;
@@ -164,6 +170,7 @@ export class FoundryPlatformStepExecutor {
164
170
  this.archon = options.archon;
165
171
  this.backup = options.backup;
166
172
  this.hc = options.hc;
173
+ this.registry = options.registry;
167
174
  this.heartbeats = options.heartbeats;
168
175
  this.uptime = options.uptime;
169
176
  this.accessProvider = options.accessProvider;
@@ -184,6 +191,35 @@ export class FoundryPlatformStepExecutor {
184
191
  limitedOnPlatform(deps, profile) {
185
192
  return { ...deps, coolify: limited(this.onPlatform(deps.coolify), profile) };
186
193
  }
194
+ /**
195
+ * Why the image registry cannot be planned, as fixed text, or `undefined`. The held `pull`
196
+ * password is read only to know it is there; its value goes nowhere.
197
+ */
198
+ async registryBlockedReason(platform, recordedIds) {
199
+ if (!this.registry) {
200
+ return 'the image registry is blocked, not planned: the host built the platform executor without its registry dependencies';
201
+ }
202
+ const tagBlocked = registryImageTagBlocked(platform.registry.gitRef);
203
+ if (tagBlocked)
204
+ return tagBlocked;
205
+ const heldKeys = this.registry.heldKeys;
206
+ if (!heldKeys) {
207
+ return `the image registry is blocked, not planned: the host cannot read held keys, so the pull password at ${PLATFORM_REGISTRY_PULL_PASSWORD_REF} cannot be read`;
208
+ }
209
+ if (recordedIds[PLATFORM_REGISTRY_PASSWORDS_SET_SCOPE_KEY]) {
210
+ let held = null;
211
+ try {
212
+ held = await withTimeout('the registry pull password read', 30_000, () => heldKeys.resolve(PLATFORM_REGISTRY_PULL_PASSWORD_REF));
213
+ }
214
+ catch {
215
+ return `the image registry logins are blocked, not planned: the pull password at ${PLATFORM_REGISTRY_PULL_PASSWORD_REF} could not be read`;
216
+ }
217
+ if (!held) {
218
+ return `the image registry logins are blocked, not planned: no password is held at ${PLATFORM_REGISTRY_PULL_PASSWORD_REF}`;
219
+ }
220
+ }
221
+ return undefined;
222
+ }
187
223
  /** Every Coolify server and why platform-archon's blue/green cannot run here, for the planner. */
188
224
  async archonBlueGreen() {
189
225
  const archon = this.archon;
@@ -201,6 +237,23 @@ export class FoundryPlatformStepExecutor {
201
237
  })),
202
238
  };
203
239
  }
240
+ /**
241
+ * The stage guards that can be made for platform-archon now. The planner puts the names in the applied hash
242
+ * and the step builds the same guards from the same facts, so a guard that becomes possible later plans the
243
+ * structure step once more.
244
+ */
245
+ archonStageGuardNames(platform, recorded, owner) {
246
+ const guards = this.archon?.stageGuards;
247
+ return stageGuardInputs({
248
+ probesPort: Boolean(guards?.probes),
249
+ accessPort: Boolean(guards?.gate),
250
+ providerId: recorded[ACCESS_PROVIDER_RECORD_KEY],
251
+ domain: platform.apex,
252
+ claimValue: owner.signIn?.organisationId,
253
+ emails: owner.emails?.length ?? 0,
254
+ hostRuled: false,
255
+ });
256
+ }
204
257
  /** The release runner's outside calls for platform-archon (`plan/release-host.ts`). The token is only read. */
205
258
  releaseHost(platform, subject, context, renew) {
206
259
  const archon = this.archon;
@@ -223,6 +276,48 @@ export class FoundryPlatformStepExecutor {
223
276
  ...(archon.runtime?.fetch ? { fetch: archon.runtime.fetch } : {}),
224
277
  renew,
225
278
  label: `coolify:release:${subject.id}`,
279
+ ...(() => {
280
+ // The copy needs the two database calls the other release calls do not; a fixture without them has no copy.
281
+ const ready = archon.coolify.deleteDatabase && archon.coolify.listDatabaseBackups;
282
+ const ports = buildStageCopyHostPorts({
283
+ host: ready ? archon.stageCopy : undefined,
284
+ coolify: archon.coolify,
285
+ context,
286
+ org: { slug: 'platform' },
287
+ secret: (value) => archon.runtime?.onSecret?.(value),
288
+ databaseKey: 'archon',
289
+ databaseScopeKey: PLATFORM_ARCHON_DB_SCOPE_KEY,
290
+ backupScopeKey: null,
291
+ projectScopeKey: PLATFORM_ARCHON_PROJECT_SCOPE_KEY,
292
+ serverScopeKey: 'coolify_server',
293
+ });
294
+ return ports ? { stageCopy: ports } : {};
295
+ })(),
296
+ });
297
+ }
298
+ /** Archon's stage gate follows the people holding `releases:preview` (orgId `platform`); the platform's owners stay. */
299
+ async syncStageGateMembers(_platform, subject, context) {
300
+ const archon = this.archon;
301
+ if (!archon)
302
+ return;
303
+ await syncStageGateMembers({
304
+ label: `coolify:stage-gate:${subject.id}`,
305
+ subjectId: subject.id,
306
+ orgId: 'platform',
307
+ owners: archon.accessEmails,
308
+ read: archon.releasePreviewMembers,
309
+ recorded: context.recordedIds(),
310
+ record: (key, value) => context.recordScopeId(key, value),
311
+ persist: () => context.persist(),
312
+ setPeople: async (gateId, list) => {
313
+ const access = await platformAccess(archon, context.recordedIds(), 'platform:archon');
314
+ if (!access.setGatePeople) {
315
+ const error = new Error('the Access port cannot change a gate');
316
+ error.name = 'NoGatePeoplePort';
317
+ throw error;
318
+ }
319
+ await access.setGatePeople(gateId, list);
320
+ },
226
321
  });
227
322
  }
228
323
  async archonBlueGreenStep(action, context) {
@@ -263,9 +358,43 @@ export class FoundryPlatformStepExecutor {
263
358
  sleep: deps.runtime?.sleep ?? defaultSleep,
264
359
  logDir: LOG_DIR,
265
360
  isRunning: isRunningOrStarting,
361
+ stageGuards: this.archonStageGuards(platform, subject.id, recorded, context.owner ?? {}, deps),
362
+ guardInputs: parseGuards(action.desired.guards),
266
363
  });
267
364
  return undefined;
268
365
  }
366
+ /**
367
+ * Archon's stage guards: the probe port, and the Access gate over the stage host, offered only when the
368
+ * same facts {@link archonStageGuardNames} counts are all in hand.
369
+ */
370
+ archonStageGuards(platform, id, recorded, owner, deps) {
371
+ const configured = deps.stageGuards;
372
+ const guards = configured?.probes ? { probes: configured.probes } : {};
373
+ const gateFor = configured?.gate;
374
+ const providerId = recorded[ACCESS_PROVIDER_RECORD_KEY];
375
+ const claimValue = owner.signIn?.organisationId;
376
+ const emails = owner.emails ?? [];
377
+ if (!gateFor || !providerId || !claimValue || emails.length === 0)
378
+ return guards;
379
+ const hostname = stageHostname(platform.apex, id);
380
+ guards.gate = {
381
+ hostname,
382
+ create: async () => {
383
+ const access = await platformAccess({ access: gateFor }, recorded, 'coolify:archon-bluegreen');
384
+ return access.createGate({
385
+ organisationId: platform.ownerOrgId,
386
+ key: stageGateKey(id),
387
+ entry: id,
388
+ hostnames: [hostname],
389
+ providerId,
390
+ claimName: GATE_CLAIM_NAME,
391
+ claimValue,
392
+ emails: [...emails],
393
+ });
394
+ },
395
+ };
396
+ return guards;
397
+ }
269
398
  async blockedSteps(platform, recordedIds = {}, owner = {}) {
270
399
  const blocked = {};
271
400
  if (platform.auth.enabled && !this.auth) {
@@ -449,6 +578,11 @@ export class FoundryPlatformStepExecutor {
449
578
  'the heartbeat server is blocked, not planned: the host names no email the Cloudflare Access app in front of it may let through';
450
579
  }
451
580
  }
581
+ if (platform.registry.enabled) {
582
+ const registryBlocked = await this.registryBlockedReason(platform, recordedIds);
583
+ if (registryBlocked)
584
+ blocked.registry = registryBlocked;
585
+ }
452
586
  // foundry#544: `'hosted'` needs none of `hc`'s own applied state -- only
453
587
  // the self-hosted provider's standing note depends on `hc` being
454
588
  // recorded applied at all. `planHeartbeats` itself already withholds the
@@ -668,6 +802,11 @@ export class FoundryPlatformStepExecutor {
668
802
  */
669
803
  async driftedSteps(platform, recordedIds = {}, owner = {}) {
670
804
  const result = {};
805
+ if (platform.registry.enabled && this.registry) {
806
+ if (await registryLoginsDrifted(this.registry, platform, recordedIds)) {
807
+ result.registryLogins = true;
808
+ }
809
+ }
671
810
  if (platform.archon.enabled &&
672
811
  this.archon &&
673
812
  recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`]) {
@@ -899,6 +1038,22 @@ export class FoundryPlatformStepExecutor {
899
1038
  }
900
1039
  return provisionPlatformMailRelay(action, context, this.limitedOnPlatform(this.mail, LIMITS.platformMailRelay));
901
1040
  }
1041
+ if (action.system === 'coolify' &&
1042
+ action.kind === 'app' &&
1043
+ action.key === PLATFORM_REGISTRY_KEY) {
1044
+ if (!this.registry) {
1045
+ throw new Error('platform:registry needs FoundryPlatformStepExecutor to be constructed with its registry dependencies');
1046
+ }
1047
+ return provisionPlatformRegistry(context, this.limitedOnPlatform(this.registry, LIMITS.platformRegistry));
1048
+ }
1049
+ if (action.system === 'coolify' &&
1050
+ action.kind === 'registry-logins' &&
1051
+ action.key === PLATFORM_REGISTRY_LOGINS_KEY) {
1052
+ if (!this.registry) {
1053
+ throw new Error('platform:registry-logins needs FoundryPlatformStepExecutor to be constructed with its registry dependencies');
1054
+ }
1055
+ return ensureRegistryLogins(context, this.registry);
1056
+ }
902
1057
  if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_HC_KEY) {
903
1058
  if (!this.hc) {
904
1059
  throw new Error('platform:hc needs FoundryPlatformStepExecutor to be constructed with its hc dependencies (#540)');
@@ -0,0 +1,58 @@
1
+ import type { DeclaredPlatform } from '../platform.js';
2
+ /**
3
+ * C5 of the Lokes Smie through Boule campaign: the platform's own image registry. The build
4
+ * workflow (`wtfalch/build`) pushes company app images here; the Coolify servers pull them.
5
+ * Docker's `registry:3` (`distribution`) on the platform server, behind the platform tunnel at
6
+ * `platform.registry.hostname`, with htpasswd sign-in and two users, `push` and `pull`. Their
7
+ * passwords are made here, held through `deliverSecret` and never leave it (CLAUDE.md, platform
8
+ * steps, rule 6).
9
+ *
10
+ * htpasswd tells users apart only by who they are, not by what they may do: `pull` can push too.
11
+ * What keeps one company's server from putting an image under another company's name is that
12
+ * the app is deployed by digest (`build.json`), and `REGISTRY_STORAGE_DELETE_ENABLED` is off, so a
13
+ * pushed digest never changes. Read-only `pull` needs token auth and is not part of C5.
14
+ */
15
+ /** The step keys `planRegistry` plans under and the executor dispatches on. */
16
+ export declare const PLATFORM_REGISTRY_KEY = "platform:registry";
17
+ export declare const PLATFORM_REGISTRY_LOGINS_KEY = "platform:registry-logins";
18
+ /** The two htpasswd users. */
19
+ export declare const PLATFORM_REGISTRY_PUSH_USER = "push";
20
+ export declare const PLATFORM_REGISTRY_PULL_USER = "pull";
21
+ /**
22
+ * The held-key refs (`deliverSecret`'s `<service>:<scope>:<name>` convention) of the two
23
+ * passwords. Archon reads `PLATFORM_REGISTRY_PUSH_PASSWORD_REF` to put the push credential into
24
+ * `wtfalch/build`'s repo secrets; foundry never writes to GitHub.
25
+ */
26
+ export declare const PLATFORM_REGISTRY_PUSH_PASSWORD_REF = "registry:platform:pushPassword";
27
+ export declare const PLATFORM_REGISTRY_PULL_PASSWORD_REF = "registry:platform:pullPassword";
28
+ /**
29
+ * What a consumer needs to push to the registry: the host (the `REGISTRY_HOST` secret), the user
30
+ * (`REGISTRY_PUSH_USER`) and the held ref the password (`REGISTRY_PUSH_PASSWORD`) is read from.
31
+ * Names only, never the password.
32
+ */
33
+ export declare function platformRegistryPushCredential(platform: DeclaredPlatform): {
34
+ host: string;
35
+ user: string;
36
+ passwordRef: string;
37
+ };
38
+ /** Scope ids (colon-free, CLAUDE.md platform steps rule 1). */
39
+ export declare const PLATFORM_REGISTRY_PASSWORDS_UNDELIVERED_SCOPE_KEY = "registry_passwords_undelivered";
40
+ /** sha256 of both passwords, recorded after delivery. Never the passwords. Also the login version. */
41
+ export declare const PLATFORM_REGISTRY_PASSWORDS_SET_SCOPE_KEY = "registry_passwords_set";
42
+ export declare const PLATFORM_REGISTRY_APPLIED_HOSTNAME_SCOPE_KEY = "registry_applied_hostname";
43
+ export declare const PLATFORM_REGISTRY_APPLIED_GIT_REF_SCOPE_KEY = "registry_applied_git_ref";
44
+ export declare const PLATFORM_REGISTRY_INTERNAL_URL_SCOPE_KEY = "platform_registry_internal_url";
45
+ /** `<hostname>|<passwords set>` once every reachable server is logged in to that host with that `pull` password. */
46
+ export declare const PLATFORM_REGISTRY_LOGINS_APPLIED_SCOPE_KEY = "registry_logins_applied";
47
+ /** The servers the last login run left out because Coolify could not reach them. */
48
+ export declare const PLATFORM_REGISTRY_LOGINS_SKIPPED_SCOPE_KEY = "registry_logins_skipped";
49
+ export declare const PLATFORM_REGISTRY_PROJECT_SCOPE_KEY = "coolify_project_platform_registry";
50
+ export declare const PLATFORM_REGISTRY_APP_SCOPE_KEY = "coolify_platform_registry_app";
51
+ export declare const PLATFORM_REGISTRY_TUNNEL_SCOPE_KEY = "cloudflare_platform_registry_tunnel";
52
+ export declare const PLATFORM_REGISTRY_CONNECTOR_APP_SCOPE_KEY = "coolify_platform_registry_connector_app";
53
+ /** The scope id that records one server's login. Its value is the same as {@link registryLoginVersion}. */
54
+ export declare function registryServerLoginScopeKey(serverUuid: string): string;
55
+ /** What a finished login to `hostname` with the passwords in `set` is recorded as. */
56
+ export declare function registryLoginVersion(hostname: string, set: string | undefined): string;
57
+ /** Why the registry image tag cannot be used, as plan text, or `null`. */
58
+ export declare function registryImageTagBlocked(tag: string): string | null;
@@ -0,0 +1,69 @@
1
+ import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
+ /**
3
+ * C5 of the Lokes Smie through Boule campaign: the platform's own image registry. The build
4
+ * workflow (`wtfalch/build`) pushes company app images here; the Coolify servers pull them.
5
+ * Docker's `registry:3` (`distribution`) on the platform server, behind the platform tunnel at
6
+ * `platform.registry.hostname`, with htpasswd sign-in and two users, `push` and `pull`. Their
7
+ * passwords are made here, held through `deliverSecret` and never leave it (CLAUDE.md, platform
8
+ * steps, rule 6).
9
+ *
10
+ * htpasswd tells users apart only by who they are, not by what they may do: `pull` can push too.
11
+ * What keeps one company's server from putting an image under another company's name is that
12
+ * the app is deployed by digest (`build.json`), and `REGISTRY_STORAGE_DELETE_ENABLED` is off, so a
13
+ * pushed digest never changes. Read-only `pull` needs token auth and is not part of C5.
14
+ */
15
+ /** The step keys `planRegistry` plans under and the executor dispatches on. */
16
+ export const PLATFORM_REGISTRY_KEY = 'platform:registry';
17
+ export const PLATFORM_REGISTRY_LOGINS_KEY = 'platform:registry-logins';
18
+ /** The two htpasswd users. */
19
+ export const PLATFORM_REGISTRY_PUSH_USER = 'push';
20
+ export const PLATFORM_REGISTRY_PULL_USER = 'pull';
21
+ /**
22
+ * The held-key refs (`deliverSecret`'s `<service>:<scope>:<name>` convention) of the two
23
+ * passwords. Archon reads `PLATFORM_REGISTRY_PUSH_PASSWORD_REF` to put the push credential into
24
+ * `wtfalch/build`'s repo secrets; foundry never writes to GitHub.
25
+ */
26
+ export const PLATFORM_REGISTRY_PUSH_PASSWORD_REF = 'registry:platform:pushPassword';
27
+ export const PLATFORM_REGISTRY_PULL_PASSWORD_REF = 'registry:platform:pullPassword';
28
+ /**
29
+ * What a consumer needs to push to the registry: the host (the `REGISTRY_HOST` secret), the user
30
+ * (`REGISTRY_PUSH_USER`) and the held ref the password (`REGISTRY_PUSH_PASSWORD`) is read from.
31
+ * Names only, never the password.
32
+ */
33
+ export function platformRegistryPushCredential(platform) {
34
+ return {
35
+ host: platform.registry.hostname,
36
+ user: PLATFORM_REGISTRY_PUSH_USER,
37
+ passwordRef: PLATFORM_REGISTRY_PUSH_PASSWORD_REF,
38
+ };
39
+ }
40
+ /** Scope ids (colon-free, CLAUDE.md platform steps rule 1). */
41
+ export const PLATFORM_REGISTRY_PASSWORDS_UNDELIVERED_SCOPE_KEY = 'registry_passwords_undelivered';
42
+ /** sha256 of both passwords, recorded after delivery. Never the passwords. Also the login version. */
43
+ export const PLATFORM_REGISTRY_PASSWORDS_SET_SCOPE_KEY = 'registry_passwords_set';
44
+ export const PLATFORM_REGISTRY_APPLIED_HOSTNAME_SCOPE_KEY = 'registry_applied_hostname';
45
+ export const PLATFORM_REGISTRY_APPLIED_GIT_REF_SCOPE_KEY = 'registry_applied_git_ref';
46
+ export const PLATFORM_REGISTRY_INTERNAL_URL_SCOPE_KEY = 'platform_registry_internal_url';
47
+ /** `<hostname>|<passwords set>` once every reachable server is logged in to that host with that `pull` password. */
48
+ export const PLATFORM_REGISTRY_LOGINS_APPLIED_SCOPE_KEY = 'registry_logins_applied';
49
+ /** The servers the last login run left out because Coolify could not reach them. */
50
+ export const PLATFORM_REGISTRY_LOGINS_SKIPPED_SCOPE_KEY = 'registry_logins_skipped';
51
+ export const PLATFORM_REGISTRY_PROJECT_SCOPE_KEY = 'coolify_project_platform_registry';
52
+ export const PLATFORM_REGISTRY_APP_SCOPE_KEY = 'coolify_platform_registry_app';
53
+ export const PLATFORM_REGISTRY_TUNNEL_SCOPE_KEY = 'cloudflare_platform_registry_tunnel';
54
+ export const PLATFORM_REGISTRY_CONNECTOR_APP_SCOPE_KEY = 'coolify_platform_registry_connector_app';
55
+ /** The scope id that records one server's login. Its value is the same as {@link registryLoginVersion}. */
56
+ export function registryServerLoginScopeKey(serverUuid) {
57
+ return `registry_login_${serverUuid}`;
58
+ }
59
+ /** What a finished login to `hostname` with the passwords in `set` is recorded as. */
60
+ export function registryLoginVersion(hostname, set) {
61
+ return `${hostname}|${set ?? ''}`;
62
+ }
63
+ /** Why the registry image tag cannot be used, as plan text, or `null`. */
64
+ export function registryImageTagBlocked(tag) {
65
+ const reason = floatingTagBlocked('registry', tag, VERSIONS.registry);
66
+ return reason
67
+ ? `the image registry is blocked, not planned: ${reason} as the registry gitRef`
68
+ : null;
69
+ }