@wtfalch/foundry 0.61.0 → 0.63.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 (97) hide show
  1. package/dist/bootstrap/declare.js +4 -1
  2. package/dist/bootstrap/versions.d.ts +2 -0
  3. package/dist/index.d.ts +9 -1
  4. package/dist/index.js +3 -0
  5. package/dist/migrations/0065_native_build_runs.sql +32 -0
  6. package/dist/migrations/0066_org_kosmos.sql +10 -0
  7. package/dist/migrations/0067_requested_changes_kosmos.sql +6 -0
  8. package/dist/migrations/0068_platform_registry.sql +17 -0
  9. package/dist/modules/sign-in.js +1 -1
  10. package/dist/native-build.d.ts +50 -0
  11. package/dist/native-build.js +72 -0
  12. package/dist/org.d.ts +22 -3
  13. package/dist/org.js +25 -2
  14. package/dist/plan/agora.d.ts +1 -1
  15. package/dist/plan/agora.js +4 -0
  16. package/dist/plan/archon-blue-green.d.ts +5 -0
  17. package/dist/plan/archon-blue-green.js +2 -0
  18. package/dist/plan/blue-green-apply.d.ts +5 -0
  19. package/dist/plan/blue-green-apply.js +197 -22
  20. package/dist/plan/blue-green.d.ts +80 -9
  21. package/dist/plan/blue-green.js +115 -14
  22. package/dist/plan/boule.d.ts +1 -1
  23. package/dist/plan/boule.js +2 -0
  24. package/dist/plan/broker-entries.d.ts +9 -0
  25. package/dist/plan/broker-entries.js +29 -0
  26. package/dist/plan/deploy-health.js +9 -2
  27. package/dist/plan/desktop-callback.js +4 -1
  28. package/dist/plan/diff.d.ts +5 -0
  29. package/dist/plan/diff.js +184 -63
  30. package/dist/plan/edge-access.d.ts +2 -3
  31. package/dist/plan/edge-access.js +8 -0
  32. package/dist/plan/executor.d.ts +111 -19
  33. package/dist/plan/executor.js +578 -72
  34. package/dist/plan/index.d.ts +2 -0
  35. package/dist/plan/index.js +1 -0
  36. package/dist/plan/jobs-scheduler-http.d.ts +2 -0
  37. package/dist/plan/jobs-scheduler-http.js +11 -0
  38. package/dist/plan/kosmos.d.ts +56 -0
  39. package/dist/plan/kosmos.js +84 -0
  40. package/dist/plan/locked-hostnames.js +5 -2
  41. package/dist/plan/org-names.d.ts +3 -3
  42. package/dist/plan/org-names.js +3 -1
  43. package/dist/plan/org-postgres-migrate.js +1 -0
  44. package/dist/plan/own-limits.d.ts +12 -0
  45. package/dist/plan/own-limits.js +2 -0
  46. package/dist/plan/plan-apply.d.ts +51 -1
  47. package/dist/plan/plan-apply.js +298 -25
  48. package/dist/plan/platform-diff.d.ts +6 -0
  49. package/dist/plan/platform-diff.js +74 -0
  50. package/dist/plan/platform-org.d.ts +2 -0
  51. package/dist/plan/platform-providers.d.ts +46 -1
  52. package/dist/plan/platform-providers.js +162 -1
  53. package/dist/plan/platform-registry-keys.d.ts +58 -0
  54. package/dist/plan/platform-registry-keys.js +69 -0
  55. package/dist/plan/platform-registry.d.ts +67 -0
  56. package/dist/plan/platform-registry.js +298 -0
  57. package/dist/plan/post-logout-uris.d.ts +1 -1
  58. package/dist/plan/post-logout-uris.js +5 -2
  59. package/dist/plan/providers-readers.js +20 -1
  60. package/dist/plan/release-host.d.ts +31 -2
  61. package/dist/plan/release-host.js +114 -6
  62. package/dist/plan/stage-copy-host.d.ts +104 -0
  63. package/dist/plan/stage-copy-host.js +259 -0
  64. package/dist/plan/stage-copy.js +44 -0
  65. package/dist/plan/stage-env.d.ts +9 -0
  66. package/dist/plan/stage-env.js +38 -0
  67. package/dist/plan/stage-guards.d.ts +57 -1
  68. package/dist/plan/stage-guards.js +106 -0
  69. package/dist/plan/stage-isolation.js +56 -0
  70. package/dist/plan/stage-services.d.ts +93 -0
  71. package/dist/plan/stage-services.js +153 -0
  72. package/dist/plan/stage-signin.d.ts +152 -0
  73. package/dist/plan/stage-signin.js +133 -0
  74. package/dist/plan/stage-sink.d.ts +2 -0
  75. package/dist/plan/stage-sink.js +6 -1
  76. package/dist/platform.d.ts +7 -1
  77. package/dist/platform.js +1 -0
  78. package/dist/release/decide.d.ts +12 -0
  79. package/dist/release/decide.js +37 -2
  80. package/dist/release/migration-compatibility.d.ts +66 -0
  81. package/dist/release/migration-compatibility.js +340 -0
  82. package/dist/release/run.d.ts +71 -1
  83. package/dist/release/run.js +92 -8
  84. package/dist/release/state.d.ts +14 -1
  85. package/dist/release/state.js +43 -0
  86. package/dist/release/types.d.ts +20 -0
  87. package/dist/release/types.js +2 -0
  88. package/dist/requested-changes.d.ts +11 -2
  89. package/dist/requested-changes.js +13 -1
  90. package/dist/slug.d.ts +1 -1
  91. package/dist/slug.js +11 -2
  92. package/dist/store/postgres.js +21 -0
  93. package/dist/store/schema.d.ts +87 -0
  94. package/dist/store/schema.js +10 -0
  95. package/package.json +3 -2
  96. package/versions.digests.json +1 -0
  97. package/versions.json +1 -0
@@ -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)');
@@ -3905,6 +4060,9 @@ async function waitForMailInstance(url, runtime, sleep) {
3905
4060
  for (;;) {
3906
4061
  try {
3907
4062
  const response = await fetchImpl(url);
4063
+ // #760: an unread body left undici's parser paused when the socket
4064
+ // ended, throwing an uncaught assertion in the host.
4065
+ await response.body?.cancel();
3908
4066
  if (response.ok)
3909
4067
  return;
3910
4068
  }
@@ -7035,6 +7193,9 @@ async function waitForInstance(url, runtime, sleep, label = 'platform:auth') {
7035
7193
  const response = await fetchImpl(url, {
7036
7194
  signal: AbortSignal.timeout(runtime.readyRequestTimeoutMs ?? 10_000),
7037
7195
  });
7196
+ // #760: an unread body left undici's parser paused when the socket
7197
+ // ended, throwing an uncaught assertion in the host.
7198
+ await response.body?.cancel();
7038
7199
  if (response.ok)
7039
7200
  return;
7040
7201
  }
@@ -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
+ }
@@ -0,0 +1,67 @@
1
+ import type { DeclaredPlatform } from '../platform.js';
2
+ import type { CoolifyPort } from './executor.js';
3
+ import type { PlatformStepExecutionContext } from './plan-apply.js';
4
+ import { type PlatformDnsFactory, type PlatformTunnelsFactory } from './platform-providers.js';
5
+ export interface PlatformRegistryRuntime {
6
+ fetch?: typeof fetch;
7
+ sleep?: (ms: number) => Promise<void>;
8
+ now?: () => number;
9
+ readyTimeoutMs?: number;
10
+ readyIntervalMs?: number;
11
+ /** Tries at handing each password to the host (default 3). */
12
+ deliverAttempts?: number;
13
+ /** Told of every password this step makes or reads, so the host can scrub it from logs and audit rows. */
14
+ onSecret?: (value: string) => void;
15
+ }
16
+ /**
17
+ * Everything the registry steps need beyond {@link PlatformStepExecutionContext}. `deliverSecret`
18
+ * and `heldKeys` are the platform's held-key writes and reads (edge's pattern); `listServers` and
19
+ * `raw` reach every server Coolify manages, not only the platform's.
20
+ */
21
+ export interface PlatformRegistryDeps {
22
+ coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'createDockerImageApplication' | 'createDockerfileApplication' | 'listStorages' | 'createPersistentStorage' | 'createFileStorage' | 'updateFileStorage' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy' | 'deployAndWait' | 'raw'>;
23
+ /** F4.dns: every DNS record call, through edge at its recorded address. */
24
+ dns: PlatformDnsFactory;
25
+ /** F4.tunnels: every tunnel call, through edge at its recorded address. */
26
+ tunnels: PlatformTunnelsFactory;
27
+ deliverSecret: (ref: string, secret: string) => Promise<void>;
28
+ /** Reads a held key back. The login step reads the `pull` password this way. */
29
+ heldKeys?: {
30
+ resolve(ref: string): Promise<string | null>;
31
+ };
32
+ runtime?: PlatformRegistryRuntime;
33
+ }
34
+ /** One htpasswd line (`user:bcrypt hash`). Docker's `distribution` accepts bcrypt only. */
35
+ export declare function htpasswdLine(user: string, password: string): string;
36
+ /**
37
+ * The registry app: `registry:3` (by digest), the data volume, the htpasswd file, the tunnel and
38
+ * the DNS record. Safe to run again at any point:
39
+ * - The two passwords are made once. The htpasswd file is written before they are recorded as
40
+ * undelivered, so the only window that blocks a re-run is the one the other platform steps
41
+ * have (CLAUDE.md, rule 1).
42
+ * - The app is deployed when it was created, is not running, its env changed, the declared
43
+ * hostname or tag changed, or the passwords were made in this run.
44
+ * - The applied hostname and tag are recorded last.
45
+ */
46
+ export declare function provisionPlatformRegistry(context: PlatformStepExecutionContext, deps: PlatformRegistryDeps): Promise<{
47
+ resourceId?: string;
48
+ }>;
49
+ /**
50
+ * Logs every Coolify server in to the registry as `pull` (`POST /servers/{uuid}/registries`,
51
+ * which runs `docker login` on the server and replaces an earlier login to the same host).
52
+ * - A server already recorded as logged in to this host with these passwords is left alone.
53
+ * - A server Coolify marks unusable or unreachable is skipped and named under
54
+ * {@link PLATFORM_REGISTRY_LOGINS_SKIPPED_SCOPE_KEY}; `driftedSteps` plans the step again
55
+ * once it is reachable.
56
+ * - A login that fails is counted; the others still run, each success is recorded at once, and
57
+ * the step then throws a text naming the server ids, so the next apply tries only those again.
58
+ * - Every Coolify call has a timeout. The password is read from the held keys and goes only
59
+ * into the request body.
60
+ */
61
+ export declare function ensureRegistryLogins(context: PlatformStepExecutionContext, deps: PlatformRegistryDeps): Promise<Record<string, never>>;
62
+ /**
63
+ * True when a usable, reachable server has no login recorded for the current registry host and
64
+ * passwords: a server added, or back up, since the last login run. A read that fails counts as
65
+ * not drifted, like the Archon drift reads.
66
+ */
67
+ export declare function registryLoginsDrifted(deps: Pick<PlatformRegistryDeps, 'coolify'>, platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>): Promise<boolean>;