@wtfalch/foundry 0.48.0 → 0.50.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 (38) hide show
  1. package/dist/bootstrap/declare.js +1 -0
  2. package/dist/index.d.ts +8 -3
  3. package/dist/index.js +5 -2
  4. package/dist/migrations/0060_hosting_repos.sql +7 -0
  5. package/dist/org.d.ts +9 -1
  6. package/dist/org.js +27 -0
  7. package/dist/plan/archon-blue-green.js +1 -0
  8. package/dist/plan/blue-green-apply.d.ts +9 -0
  9. package/dist/plan/blue-green-apply.js +86 -2
  10. package/dist/plan/blue-green.d.ts +8 -0
  11. package/dist/plan/blue-green.js +18 -1
  12. package/dist/plan/call-timeout.d.ts +6 -0
  13. package/dist/plan/call-timeout.js +27 -0
  14. package/dist/plan/diff.d.ts +2 -0
  15. package/dist/plan/diff.js +1 -0
  16. package/dist/plan/executor.d.ts +2 -0
  17. package/dist/plan/executor.js +25 -1
  18. package/dist/plan/github-release-hook.d.ts +81 -0
  19. package/dist/plan/github-release-hook.js +159 -0
  20. package/dist/plan/index.d.ts +7 -1
  21. package/dist/plan/index.js +4 -1
  22. package/dist/plan/live.d.ts +1 -1
  23. package/dist/plan/plan-apply.d.ts +2 -0
  24. package/dist/plan/plan-apply.js +8 -3
  25. package/dist/plan/platform-diff.d.ts +59 -0
  26. package/dist/plan/platform-diff.js +98 -1
  27. package/dist/plan/platform-providers.d.ts +10 -0
  28. package/dist/plan/platform-providers.js +71 -1
  29. package/dist/plan/stage-copy-port.d.ts +62 -0
  30. package/dist/plan/stage-copy-port.js +208 -0
  31. package/dist/plan/stage-copy.d.ts +140 -0
  32. package/dist/plan/stage-copy.js +538 -0
  33. package/dist/plan/stage-sink.d.ts +37 -0
  34. package/dist/plan/stage-sink.js +33 -0
  35. package/dist/store/postgres.js +13 -2
  36. package/dist/store/schema.d.ts +19 -0
  37. package/dist/store/schema.js +2 -0
  38. package/package.json +1 -1
@@ -28,7 +28,7 @@ import { MAIL_APP_SCOPE_KEY, MAIL_DATABASE_NAME, MAIL_DATABASE_PASSWORD_ENV, MAI
28
28
  import { OIKOS_INGRESS_BLOCKED, PLATFORM_OIKOS_INGRESS_KEY, oikosIngressApplied, oikosIngressZoneBlocked, provisionOikosIngress, } from './oikos-ingress.js';
29
29
  import { recordOwnDnsHostname } from './own-dns.js';
30
30
  import { platformNetwork, platformTarget, scopeFromPlatform, } from './own-services.js';
31
- import { AUTH_ZITADEL_RUN_OPTIONS, 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_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_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_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_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, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailHostPorts, platformMailMxHost, uptimeAccessTokenConflict, uptimeEffectiveVars, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
31
+ import { AUTH_ZITADEL_RUN_OPTIONS, 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_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_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, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailHostPorts, platformMailMxHost, uptimeAccessTokenConflict, uptimeEffectiveVars, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
32
32
  import { PLATFORM_ARCHON_IN_APP_SCOPE_KEY, archonInAppBack, archonInAppBind, archonInAppHost, archonInAppPoint, archonInAppUnbind, } from './sign-in-archon.js';
33
33
  import { LOGIN_HOST_BINDINGS_ENV } from './sign-in-bindings.js';
34
34
  import { ensureSignInOrg, inviteOwnerWithLink, signInOrgDomain } from './sign-in-org.js';
@@ -36,6 +36,7 @@ import { SIGN_IN_BASE_PATH, SIGN_IN_BUILD_ARG_ENV, SIGN_IN_LOGIN_APP_SCOPE_KEY,
36
36
  import { SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY } from './signin-db-tls.js';
37
37
  import { SIGNIN_DB_APP_SCOPE_KEY, SIGNIN_DB_TLS_SCOPE_KEY, dockerfileAppCreator, ensureSigninDbCaFile, provisionSigninDb, waitHealthy, withCallTimeout, } from './signin-db.js';
38
38
  export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
39
+ import { githubReleaseHookPlanInputs, provisionGithubReleaseHook, } from './github-release-hook.js';
39
40
  import { applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
40
41
  import { heldUptimeValues, provisionPlatformUptime, } from './uptime-worker.js';
41
42
  import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, waitForSystemApi, } from './zitadel-system-api.js';
@@ -144,6 +145,7 @@ export class FoundryPlatformStepExecutor {
144
145
  uptime;
145
146
  accessProvider;
146
147
  oikosIngress;
148
+ githubReleaseHook;
147
149
  constructor(cloudflare, declaredOrgStore, options = {}) {
148
150
  this.cloudflare = cloudflare;
149
151
  this.declaredOrgStore = declaredOrgStore;
@@ -164,6 +166,7 @@ export class FoundryPlatformStepExecutor {
164
166
  this.uptime = options.uptime;
165
167
  this.accessProvider = options.accessProvider;
166
168
  this.oikosIngress = options.oikosIngress;
169
+ this.githubReleaseHook = options.githubReleaseHook;
167
170
  }
168
171
  platformPorts = new WeakMap();
169
172
  /** The port placed on the platform's destination, made once per port so a server is listed once. */
@@ -470,6 +473,15 @@ export class FoundryPlatformStepExecutor {
470
473
  blocked.heartbeatsApiKeyMissing = true;
471
474
  }
472
475
  await this.uptimeBlocked(platform, recordedIds, blocked, owner);
476
+ if (this.githubReleaseHook && platform.archon.enabled) {
477
+ const inputs = await githubReleaseHookPlanInputs(this.githubReleaseHook, platform.archon.hostname, recordedIds);
478
+ if (inputs.blocked)
479
+ blocked.githubReleaseHook = inputs.blocked;
480
+ if (inputs.ready)
481
+ blocked.githubReleaseHookReady = true;
482
+ if (inputs.hash)
483
+ blocked.githubReleaseHookHash = inputs.hash;
484
+ }
473
485
  // #604: only once edge and sign-in are applied (the planner reports
474
486
  // that case itself) and no provider is recorded yet.
475
487
  if (platform.edge.enabled &&
@@ -693,12 +705,15 @@ export class FoundryPlatformStepExecutor {
693
705
  const redeployFailed = this.archon.runsInsideApp === true &&
694
706
  Boolean(deployment) &&
695
707
  (await archonDeploymentState(this.archon.coolify, deployment)) === 'failed';
708
+ const webhookSecretDrifted = !rollingUpdateDrifted &&
709
+ (await archonWebhookSecretDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`], recordedIds));
696
710
  if (accessDrifted ||
697
711
  brokerDrifted ||
698
712
  authOrgDrifted ||
699
713
  authzDrifted ||
700
714
  bridgeDrifted ||
701
715
  rollingUpdateDrifted ||
716
+ webhookSecretDrifted ||
702
717
  redeployFailed)
703
718
  result.archon = true;
704
719
  }
@@ -923,6 +938,28 @@ export class FoundryPlatformStepExecutor {
923
938
  }
924
939
  return provisionPlatformAccessProvider(context, this.accessProvider);
925
940
  }
941
+ if (action.system === 'github' &&
942
+ action.kind === 'release-hook' &&
943
+ action.key === PLATFORM_GITHUB_RELEASE_HOOK_KEY) {
944
+ if (!this.githubReleaseHook) {
945
+ throw new Error('platform:github-release-hook needs FoundryPlatformStepExecutor to be constructed with its github release hook dependencies');
946
+ }
947
+ const hookDeps = this.githubReleaseHook;
948
+ return provisionGithubReleaseHook(context, hookDeps, async (appId) => {
949
+ const minted = await ensureGeneratedEnv(hookDeps.coolify, appId, GITHUB_RELEASE_WEBHOOK_SECRET_ENV, () => randomBytes(32).toString('hex'));
950
+ if (minted) {
951
+ await hookDeps.coolify.bulkUpsertEnvs(appId, [
952
+ { key: GITHUB_RELEASE_WEBHOOK_SECRET_ENV, value: minted },
953
+ ]);
954
+ return minted;
955
+ }
956
+ const live = await currentEnvValue(hookDeps.coolify, appId, GITHUB_RELEASE_WEBHOOK_SECRET_ENV);
957
+ if (!live) {
958
+ throw new Error(`platform:github-release-hook: Archon lost its ${GITHUB_RELEASE_WEBHOOK_SECRET_ENV} between two reads`);
959
+ }
960
+ return live;
961
+ });
962
+ }
926
963
  if (action.system === 'heartbeats' && action.kind === 'reconcile') {
927
964
  if (!this.heartbeats) {
928
965
  throw new Error('heartbeats:reconcile needs FoundryPlatformStepExecutor to be constructed with its heartbeats dependencies (#540)');
@@ -4458,6 +4495,10 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
4458
4495
  const cookieSecret = await ensureGeneratedEnv(deps.coolify, appId, 'AUTH_COOKIE_SECRET', () => randomBytes(32).toString('base64'));
4459
4496
  if (cookieSecret)
4460
4497
  envs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
4498
+ // The secret of the organisation's release webhook: minted once, never rotated, a hand-set value kept.
4499
+ const webhookSecret = await ensureGeneratedEnv(deps.coolify, appId, GITHUB_RELEASE_WEBHOOK_SECRET_ENV, () => randomBytes(32).toString('hex'));
4500
+ if (webhookSecret)
4501
+ envs.push({ key: GITHUB_RELEASE_WEBHOOK_SECRET_ENV, value: webhookSecret });
4461
4502
  const runtimeUrl = await ensureArchonRuntimeUrl(deps.coolify, appId, db.internal_db_url);
4462
4503
  if (runtimeUrl)
4463
4504
  envs.push({ key: 'DATABASE_URL', value: runtimeUrl });
@@ -4494,6 +4535,12 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
4494
4535
  if (effectiveCookieSecret) {
4495
4536
  archonEnvHashOverrides.set('AUTH_COOKIE_SECRET', effectiveCookieSecret);
4496
4537
  }
4538
+ const effectiveWebhookSecret = webhookSecret ??
4539
+ (await currentEnvValue(deps.coolify, appId, GITHUB_RELEASE_WEBHOOK_SECRET_ENV));
4540
+ if (effectiveWebhookSecret) {
4541
+ archonEnvHashOverrides.set(GITHUB_RELEASE_WEBHOOK_SECRET_ENV, effectiveWebhookSecret);
4542
+ runtime.onSecret?.(effectiveWebhookSecret);
4543
+ }
4497
4544
  const effectiveRuntimeUrl = runtimeUrl ?? (await currentEnvValue(deps.coolify, appId, 'DATABASE_URL'));
4498
4545
  if (effectiveRuntimeUrl) {
4499
4546
  archonEnvHashOverrides.set('DATABASE_URL', effectiveRuntimeUrl);
@@ -4941,6 +4988,9 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
4941
4988
  // when no deploy was needed this run, left exactly as it already was --
4942
4989
  // `envsHashChanged` false means it already equals `envsHash`).
4943
4990
  await record(PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, envsHash);
4991
+ if (effectiveWebhookSecret) {
4992
+ await record(PLATFORM_ARCHON_WEBHOOK_SECRET_DEPLOYED_SCOPE_KEY, createHash('sha256').update(effectiveWebhookSecret).digest('hex'));
4993
+ }
4944
4994
  // Last, after every write the applied values depend on, so a run that failed
4945
4995
  // halfway plans again.
4946
4996
  await record(PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
@@ -5726,6 +5776,26 @@ async function authAppsNotRunning(coolify, recordedIds) {
5726
5776
  }
5727
5777
  return false;
5728
5778
  }
5779
+ /**
5780
+ * Archon's live release webhook secret is missing, or is not the one its last deploy recorded
5781
+ * (`PLATFORM_ARCHON_WEBHOOK_SECRET_DEPLOYED_SCOPE_KEY`): the release hook step wrote it after Archon's
5782
+ * last deploy, so the running Archon does not have it. A Coolify read error counts as not drifted.
5783
+ */
5784
+ async function archonWebhookSecretDrifted(coolify, appId, recorded) {
5785
+ if (!appId)
5786
+ return false;
5787
+ try {
5788
+ const live = await currentEnvValue(coolify, appId, GITHUB_RELEASE_WEBHOOK_SECRET_ENV);
5789
+ if (!live)
5790
+ return true;
5791
+ return (recorded[PLATFORM_ARCHON_WEBHOOK_SECRET_DEPLOYED_SCOPE_KEY] !==
5792
+ createHash('sha256').update(live).digest('hex'));
5793
+ }
5794
+ catch (error) {
5795
+ console.error(`platform:archon: could not read Archon's webhook secret from Coolify (${error instanceof Error ? error.name : 'error'})`);
5796
+ return false;
5797
+ }
5798
+ }
5729
5799
  async function archonRollingUpdateDrifted(coolify, appId) {
5730
5800
  if (!appId)
5731
5801
  return false;
@@ -0,0 +1,62 @@
1
+ import type { DatabaseDump } from './database-dump.js';
2
+ import { type StageCopyPort } from './stage-copy.js';
3
+ /** Thrown before anything is run or fetched. Fixed text: never the URL it refused. */
4
+ export declare class StageRestoreRefusedError extends Error {
5
+ constructor(reason: string);
6
+ }
7
+ /** Thrown when a step of the restore failed. Fixed text only: the cause can echo a connection URL. */
8
+ export declare class StageRestoreError extends Error {
9
+ constructor(step: string);
10
+ }
11
+ export interface RunResult {
12
+ exitCode: number;
13
+ stdout: string;
14
+ }
15
+ /** Runs a program with exactly `env` (nothing inherited) and kills it after `timeoutMs`. */
16
+ export type Runner = (command: string, args: readonly string[], options: {
17
+ env: Readonly<Record<string, string>>;
18
+ timeoutMs: number;
19
+ /** Kills the program when aborted. */
20
+ signal?: AbortSignal;
21
+ }) => Promise<RunResult>;
22
+ /** The default {@link Runner}: `execFile`, stderr dropped, no shell. */
23
+ export declare const execRunner: Runner;
24
+ export interface StageCopyPortTimeouts {
25
+ /** Saving the dump from the backup bucket. Default 10 minutes. */
26
+ fetchMs?: number;
27
+ /** Reading the dump's table of contents. Default 2 minutes. */
28
+ listMs?: number;
29
+ /** `pg_restore`. Default 30 minutes. */
30
+ restoreMs?: number;
31
+ /** Creating or syncing the runtime role. Default 2 minutes. */
32
+ roleMs?: number;
33
+ }
34
+ export interface StageCopyPortDeps {
35
+ /** Every connection URL production's database answers at. A target on the same host, port and database is refused. */
36
+ productionUrls: () => Promise<readonly string[]>;
37
+ /** Saves the dump from the backup bucket to a local file; `remove` deletes it. */
38
+ fetchDump: (location: DatabaseDump['location'], signal?: AbortSignal) => Promise<{
39
+ path: string;
40
+ remove: () => Promise<void>;
41
+ }>;
42
+ run?: Runner;
43
+ /** Default: `@wtfalch/db/runtime-role`'s `ensureRuntimeRole`. */
44
+ ensureRole?: (options: {
45
+ ownerUrl: string;
46
+ runtimeUrl: string;
47
+ log: () => void;
48
+ }) => Promise<unknown>;
49
+ timeouts?: StageCopyPortTimeouts;
50
+ }
51
+ /** The table of contents of a dump without the data of the excluded tables. */
52
+ export declare function filterToc(toc: string, patterns: readonly string[]): {
53
+ list: string;
54
+ excluded: number;
55
+ };
56
+ /**
57
+ * The concrete {@link StageCopyPort}. It restores a dump into a stage database and sets up that database's runtime
58
+ * role, and refuses any target that is not a stage database or that is production's. Connection settings reach
59
+ * the programs as `PG*` env, never as arguments. Every outside call has a timeout, and an error carries fixed text
60
+ * only.
61
+ */
62
+ export declare function createStageCopyPort(deps: StageCopyPortDeps): StageCopyPort;
@@ -0,0 +1,208 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { ensureRuntimeRole } from '@wtfalch/db/runtime-role';
6
+ import { runtimeDatabaseUrl } from './blue-green-apply.js';
7
+ import { withTimeout } from './call-timeout.js';
8
+ import { tableExcluded } from './stage-copy.js';
9
+ /** Thrown before anything is run or fetched. Fixed text: never the URL it refused. */
10
+ export class StageRestoreRefusedError extends Error {
11
+ constructor(reason) {
12
+ super(`stage restore refused: ${reason}`);
13
+ this.name = 'StageRestoreRefusedError';
14
+ }
15
+ }
16
+ /** Thrown when a step of the restore failed. Fixed text only: the cause can echo a connection URL. */
17
+ export class StageRestoreError extends Error {
18
+ constructor(step) {
19
+ super(`stage restore: ${step} failed`);
20
+ this.name = 'StageRestoreError';
21
+ }
22
+ }
23
+ /** The default {@link Runner}: `execFile`, stderr dropped, no shell. */
24
+ export const execRunner = (command, args, { env, timeoutMs, signal }) => new Promise((resolve, reject) => {
25
+ execFile(command, [...args], {
26
+ env: { ...env },
27
+ timeout: timeoutMs,
28
+ maxBuffer: 64 * 1024 * 1024,
29
+ encoding: 'utf8',
30
+ ...(signal ? { signal } : {}),
31
+ }, (error, stdout) => {
32
+ if (!error)
33
+ return resolve({ exitCode: 0, stdout });
34
+ const code = error.code;
35
+ if (typeof code === 'number')
36
+ return resolve({ exitCode: code, stdout });
37
+ reject(new StageRestoreError('the program'));
38
+ });
39
+ });
40
+ function parseTarget(url) {
41
+ let parsed;
42
+ try {
43
+ parsed = new URL(url);
44
+ }
45
+ catch {
46
+ throw new StageRestoreRefusedError('the database URL is not a URL');
47
+ }
48
+ if (parsed.protocol !== 'postgres:' && parsed.protocol !== 'postgresql:') {
49
+ throw new StageRestoreRefusedError('the database URL is not a postgres URL');
50
+ }
51
+ const database = decodeURIComponent(parsed.pathname.replace(/^\//, ''));
52
+ if (!parsed.hostname || !database) {
53
+ throw new StageRestoreRefusedError('the database URL names no host or database');
54
+ }
55
+ return {
56
+ host: parsed.hostname.toLowerCase(),
57
+ port: parsed.port || '5432',
58
+ user: decodeURIComponent(parsed.username),
59
+ password: decodeURIComponent(parsed.password),
60
+ database,
61
+ };
62
+ }
63
+ const sameDatabase = (a, b) => a.host === b.host && a.port === b.port && a.database === b.database;
64
+ const IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
65
+ const STAGE_DATABASE = /_stage_db$/;
66
+ const pgEnv = (t) => ({
67
+ PATH: process.env.PATH ?? '',
68
+ PGHOST: t.host,
69
+ PGPORT: t.port,
70
+ PGUSER: t.user,
71
+ PGPASSWORD: t.password,
72
+ PGDATABASE: t.database,
73
+ });
74
+ /** `4213; 0 16405 TABLE DATA public sessions owner` */
75
+ const TABLE_DATA = /^\d+;\s+\d+\s+\d+\s+TABLE DATA\s+\S+\s+(\S+)\s+\S+\s*$/;
76
+ /** Any table data entry, parsed or not. */
77
+ const TABLE_DATA_ENTRY = /^\d+;.*\bTABLE DATA\b/;
78
+ /** The table of contents of a dump without the data of the excluded tables. */
79
+ export function filterToc(toc, patterns) {
80
+ let excluded = 0;
81
+ const kept = toc.split('\n').filter((line) => {
82
+ const table = TABLE_DATA.exec(line)?.[1];
83
+ // A name the parser cannot read (quoted, with a space) could be an excluded table: refuse the restore.
84
+ if ((table === undefined && TABLE_DATA_ENTRY.test(line)) || table?.includes('"')) {
85
+ throw new StageRestoreRefusedError('the dump list has a table entry that cannot be read');
86
+ }
87
+ if (table !== undefined && tableExcluded(table, patterns)) {
88
+ excluded += 1;
89
+ return false;
90
+ }
91
+ return true;
92
+ });
93
+ return { list: kept.join('\n'), excluded };
94
+ }
95
+ /**
96
+ * The concrete {@link StageCopyPort}. It restores a dump into a stage database and sets up that database's runtime
97
+ * role, and refuses any target that is not a stage database or that is production's. Connection settings reach
98
+ * the programs as `PG*` env, never as arguments. Every outside call has a timeout, and an error carries fixed text
99
+ * only.
100
+ */
101
+ export function createStageCopyPort(deps) {
102
+ const run = deps.run ?? execRunner;
103
+ const t = deps.timeouts ?? {};
104
+ const fetchMs = t.fetchMs ?? 600_000;
105
+ const listMs = t.listMs ?? 120_000;
106
+ const restoreMs = t.restoreMs ?? 1_800_000;
107
+ const roleMs = t.roleMs ?? 120_000;
108
+ for (const [name, value] of Object.entries({ fetchMs, listMs, restoreMs, roleMs })) {
109
+ if (!(value > 0))
110
+ throw new TypeError(`stage copy port: ${name} must be positive`);
111
+ }
112
+ /** Parses the target and refuses it unless it is a stage database that production does not answer at. */
113
+ async function checkTarget(url, role) {
114
+ const target = parseTarget(url);
115
+ if (!IDENTIFIER.test(role))
116
+ throw new StageRestoreRefusedError('the role name is not valid');
117
+ if (!STAGE_DATABASE.test(target.database)) {
118
+ throw new StageRestoreRefusedError('the target is not a stage database');
119
+ }
120
+ // The pg client reads host, port and options from the query string: only `sslmode` is allowed.
121
+ for (const key of new URL(url).searchParams.keys()) {
122
+ if (key !== 'sslmode') {
123
+ throw new StageRestoreRefusedError('the database URL has query parameters');
124
+ }
125
+ }
126
+ const production = await withTimeout('stage restore: production URLs', roleMs, deps.productionUrls);
127
+ if (production.length === 0) {
128
+ throw new StageRestoreRefusedError('production database is not known');
129
+ }
130
+ for (const url of production) {
131
+ if (sameDatabase(target, parseTarget(url))) {
132
+ throw new StageRestoreRefusedError('the target is the production database');
133
+ }
134
+ }
135
+ return target;
136
+ }
137
+ return {
138
+ async runRestore({ location, targetDatabaseUrl, ownerRole, excludeTableData, signal }) {
139
+ const target = await checkTarget(targetDatabaseUrl, ownerRole);
140
+ if (target.database !== ownerRole) {
141
+ throw new StageRestoreRefusedError('the owner role is not the stage database owner');
142
+ }
143
+ const fetching = deps.fetchDump(location, signal);
144
+ let dump;
145
+ try {
146
+ dump = await withTimeout('stage restore: fetch the dump', fetchMs, () => fetching);
147
+ }
148
+ catch (error) {
149
+ // The fetch may still finish later; its file goes as soon as it does.
150
+ fetching.then((late) => late.remove().catch(() => undefined), () => undefined);
151
+ throw error;
152
+ }
153
+ let dir;
154
+ try {
155
+ const env = pgEnv(target);
156
+ const toc = await withTimeout('stage restore: list the dump', listMs, () => run('pg_restore', ['--list', dump.path], { env, timeoutMs: listMs, signal }));
157
+ if (toc.exitCode !== 0)
158
+ return { exitCode: toc.exitCode };
159
+ dir = await mkdtemp(join(tmpdir(), 'stage-restore-'));
160
+ const listFile = join(dir, 'toc.list');
161
+ await writeFile(listFile, filterToc(toc.stdout, excludeTableData).list, 'utf8');
162
+ const restored = await withTimeout('stage restore: pg_restore', restoreMs, () => run('pg_restore', [
163
+ '--no-owner',
164
+ '--no-privileges',
165
+ '--exit-on-error',
166
+ `--role=${ownerRole}`,
167
+ '--use-list',
168
+ listFile,
169
+ '--dbname',
170
+ target.database,
171
+ dump.path,
172
+ ], { env, timeoutMs: restoreMs, signal }));
173
+ return { exitCode: restored.exitCode };
174
+ }
175
+ catch (error) {
176
+ if (error instanceof StageRestoreRefusedError)
177
+ throw error;
178
+ console.error(`stage restore: failed (${error instanceof Error ? error.name : 'unknown'})`);
179
+ throw new StageRestoreError('the restore');
180
+ }
181
+ finally {
182
+ await dump.remove().catch(() => {
183
+ console.error('stage restore: removing the dump file failed');
184
+ });
185
+ if (dir)
186
+ await rm(dir, { recursive: true, force: true }).catch(() => undefined);
187
+ }
188
+ },
189
+ async ensureRuntimeRole({ targetDatabaseUrl, runtimeRole, runtimePassword }) {
190
+ const target = await checkTarget(targetDatabaseUrl, runtimeRole);
191
+ const runtimeUrl = runtimeDatabaseUrl(targetDatabaseUrl, runtimeRole, runtimePassword);
192
+ if (!runtimeUrl)
193
+ throw new StageRestoreRefusedError('the database URL is not a postgres URL');
194
+ if (!runtimeRole.endsWith('_rt') || runtimeRole === target.user) {
195
+ throw new StageRestoreRefusedError('the runtime role is not a separate runtime role');
196
+ }
197
+ try {
198
+ const ensure = deps.ensureRole ?? ((options) => ensureRuntimeRole(options));
199
+ await withTimeout('stage restore: runtime role', roleMs, () => ensure({ ownerUrl: targetDatabaseUrl, runtimeUrl, log: () => undefined }));
200
+ return { exitCode: 0 };
201
+ }
202
+ catch (error) {
203
+ console.error(`stage restore: runtime role failed (${error instanceof Error ? error.name : 'unknown'})`);
204
+ return { exitCode: 1 };
205
+ }
206
+ },
207
+ };
208
+ }
@@ -0,0 +1,140 @@
1
+ import { type BlueGreenSubject } from './blue-green.js';
2
+ import { type DatabaseDump } from './database-dump.js';
3
+ import type { CoolifyPort } from './executor.js';
4
+ /**
5
+ * Table names whose rows the exact copy leaves out (SQL structure is still restored). `*` matches any run of
6
+ * characters. Sessions and tokens are dropped so a copied sign-in or API token is useless on stage; the keystore
7
+ * and held-key tables hold ciphertext that stage must never be able to open (stage gets no production key).
8
+ */
9
+ export declare const STAGE_COPY_EXCLUDED_TABLES: readonly string[];
10
+ /** Whether `table` is on {@link STAGE_COPY_EXCLUDED_TABLES}. Case-insensitive, like Postgres folds names. */
11
+ export declare function tableExcluded(table: string, patterns?: readonly string[]): boolean;
12
+ export type StageCopyRefusalReason = 'guards' | 'sink' | 'stage-sink-env' | 'stage-isolation' | 'no-production-database' | 'no-stage-database' | 'no-stage-app' | 'no-coolify-project' | 'no-kept-database';
13
+ /** Thrown before anything is touched. Fixed text and names only. */
14
+ export declare class StageCopyRefusedError extends Error {
15
+ readonly reason: StageCopyRefusalReason;
16
+ readonly missing: readonly string[];
17
+ constructor(reason: StageCopyRefusalReason, missing?: readonly string[]);
18
+ }
19
+ export interface StageCopyStepRecord {
20
+ step: string;
21
+ outcome: 'ok' | 'failed';
22
+ /** Fixed text only. */
23
+ detail?: string;
24
+ }
25
+ /** What a run did. Fixed text, ids and times only: never a URL, a password or a Coolify message. */
26
+ export interface StageCopyRunRecord {
27
+ requestedBy: string;
28
+ at: string;
29
+ /** The Coolify database stage runs on when the call ended. */
30
+ liveDatabaseId: string;
31
+ dumpExecutionId?: string;
32
+ steps: readonly StageCopyStepRecord[];
33
+ }
34
+ /** Thrown once a run started; carries what happened so far. */
35
+ export declare class StageCopyFailedError extends Error {
36
+ readonly record: StageCopyRunRecord;
37
+ constructor(message: string, record: StageCopyRunRecord);
38
+ }
39
+ /** The restore and the runtime role, run by the host (`stage-copy-port.ts` is the concrete one). */
40
+ export interface StageCopyPort {
41
+ /**
42
+ * Restores the dump at `location` into `targetDatabaseUrl` and nothing else, leaving out the data of every
43
+ * table matching `excludeTableData` (see {@link tableExcluded}).
44
+ */
45
+ runRestore(input: {
46
+ location: DatabaseDump['location'];
47
+ targetDatabaseUrl: string;
48
+ ownerRole: string;
49
+ excludeTableData: readonly string[];
50
+ /** Aborted when the caller gives up: the restore must stop writing and remove the dump file. */
51
+ signal?: AbortSignal;
52
+ }): Promise<{
53
+ exitCode: number;
54
+ }>;
55
+ /** Creates or syncs `runtimeRole` with `runtimePassword` on the database `targetDatabaseUrl` names. */
56
+ ensureRuntimeRole(input: {
57
+ targetDatabaseUrl: string;
58
+ runtimeRole: string;
59
+ runtimePassword: string;
60
+ }): Promise<{
61
+ exitCode: number;
62
+ }>;
63
+ }
64
+ export type StageCopyCoolify = Pick<CoolifyPort, 'listDatabases' | 'getDatabase' | 'createPostgresDatabase' | 'deleteDatabase' | 'getApplication' | 'listEnvs' | 'deleteEnv' | 'deployAndWait' | 'bulkUpsertEnvs' | 'findOrCreateEnvironment' | 'listDatabaseBackups' | 'raw'> & Partial<Pick<CoolifyPort, 'listDatabaseBackupExecutions'>>;
65
+ /** Timeouts of every outside call; a run has no call without one. */
66
+ export interface StageCopyTimeouts {
67
+ /** One Coolify call. Default 30 s. */
68
+ callMs?: number;
69
+ /** The dump of production. Default: `dumpNow`'s (10 minutes). */
70
+ dumpMs?: number;
71
+ /**
72
+ * The whole `runRestore` call: the port's fetch (10 minutes), table list (2) and `pg_restore` (30), plus a
73
+ * margin. Default 44 minutes.
74
+ */
75
+ restoreMs?: number;
76
+ /** Creating or syncing the runtime role. Default 2 minutes. */
77
+ roleMs?: number;
78
+ /** Waiting for a new database to start. Default 3 minutes. */
79
+ databaseStartMs?: number;
80
+ /** The stage app's new deployment. Default 16 minutes. */
81
+ deployMs?: number;
82
+ /** Waiting for the stage app to be healthy after its deployment, and the poll between reads. Default 2 minutes, 2 s. */
83
+ appRunningMs?: number;
84
+ appPollMs?: number;
85
+ }
86
+ /** The production database to dump, resolved by the host (it differs by app). `null`: none is recorded. */
87
+ export interface StageCopyProduction {
88
+ /** The database's name in the backup keys, e.g. `app.shop`. Goes into dump errors. */
89
+ databaseKey: string;
90
+ databaseUuid: string;
91
+ /** The uuid of the schedule that saves it to the backup bucket. */
92
+ backupUuid: string;
93
+ storageUuid: string;
94
+ }
95
+ interface StageCopyBase {
96
+ subject: Pick<BlueGreenSubject, 'id' | 'base' | 'database'>;
97
+ recorded: () => Readonly<Record<string, string>>;
98
+ record: (key: string, value: string) => void;
99
+ persist: () => Promise<void>;
100
+ coolify: StageCopyCoolify;
101
+ /** Registers a value for redaction before it is written anywhere. */
102
+ secret?: (value: string) => void;
103
+ /** Who asked: a person or operator id. Goes into the run record. */
104
+ requestedBy: string;
105
+ now?: () => number;
106
+ timeouts?: StageCopyTimeouts;
107
+ }
108
+ export interface StageCopyInput extends StageCopyBase {
109
+ /** The company whose sink the stage mail env must name. */
110
+ org: {
111
+ slug: string;
112
+ };
113
+ production: StageCopyProduction | null;
114
+ restore: StageCopyPort;
115
+ projectUuid: string | undefined;
116
+ serverUuid: string | undefined;
117
+ }
118
+ export interface UndoStageCopyInput extends StageCopyBase {
119
+ restore: Pick<StageCopyPort, 'ensureRuntimeRole'>;
120
+ }
121
+ /**
122
+ * Copies production's database into stage, exact except for {@link STAGE_COPY_EXCLUDED_TABLES}, on request. It
123
+ * refuses until the stage guards and the sink are in place, dumps production through Coolify (it never holds a
124
+ * production URL), restores into a new database beside stage's and points stage at it. Stage's database is
125
+ * recorded only once stage runs on the new one.
126
+ *
127
+ * What a failure leaves behind, by point: refusal or dump failure: nothing changed. New database made, restore
128
+ * or role failed: the new database is deleted, stage untouched. Switch failed: stage is pointed back at its own
129
+ * database and the new one deleted; if putting it back also failed the run says so (`recovered: false`) and the
130
+ * new database is left for the next run's sweep. Recording: the three records are written in memory and
131
+ * persisted once, so a crash leaves either all or none.
132
+ */
133
+ export declare function stageCopy(input: StageCopyInput): Promise<StageCopyRunRecord>;
134
+ /**
135
+ * Puts the database stage ran on before the last copy back, and deletes the one the copy made. It mints a new
136
+ * runtime password for the kept database (the old one is gone from stage's env). A failure leaves stage on the
137
+ * database it ran on, switched back the same way a failed copy does, and records nothing.
138
+ */
139
+ export declare function undoStageCopy(input: UndoStageCopyInput): Promise<StageCopyRunRecord>;
140
+ export {};