@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.
- package/dist/bootstrap/declare.js +1 -0
- package/dist/index.d.ts +8 -3
- package/dist/index.js +5 -2
- package/dist/migrations/0060_hosting_repos.sql +7 -0
- package/dist/org.d.ts +9 -1
- package/dist/org.js +27 -0
- package/dist/plan/archon-blue-green.js +1 -0
- package/dist/plan/blue-green-apply.d.ts +9 -0
- package/dist/plan/blue-green-apply.js +86 -2
- package/dist/plan/blue-green.d.ts +8 -0
- package/dist/plan/blue-green.js +18 -1
- package/dist/plan/call-timeout.d.ts +6 -0
- package/dist/plan/call-timeout.js +27 -0
- package/dist/plan/diff.d.ts +2 -0
- package/dist/plan/diff.js +1 -0
- package/dist/plan/executor.d.ts +2 -0
- package/dist/plan/executor.js +25 -1
- package/dist/plan/github-release-hook.d.ts +81 -0
- package/dist/plan/github-release-hook.js +159 -0
- package/dist/plan/index.d.ts +7 -1
- package/dist/plan/index.js +4 -1
- package/dist/plan/live.d.ts +1 -1
- package/dist/plan/plan-apply.d.ts +2 -0
- package/dist/plan/plan-apply.js +8 -3
- package/dist/plan/platform-diff.d.ts +59 -0
- package/dist/plan/platform-diff.js +98 -1
- package/dist/plan/platform-providers.d.ts +10 -0
- package/dist/plan/platform-providers.js +71 -1
- package/dist/plan/stage-copy-port.d.ts +62 -0
- package/dist/plan/stage-copy-port.js +208 -0
- package/dist/plan/stage-copy.d.ts +140 -0
- package/dist/plan/stage-copy.js +538 -0
- package/dist/plan/stage-sink.d.ts +37 -0
- package/dist/plan/stage-sink.js +33 -0
- package/dist/store/postgres.js +13 -2
- package/dist/store/schema.d.ts +19 -0
- package/dist/store/schema.js +2 -0
- 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 {};
|