@wtfalch/foundry 0.34.0 → 0.35.2
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/bootstrap.d.ts +1 -1
- package/dist/bootstrap/bootstrap.js +49 -11
- package/dist/bootstrap/declare.d.ts +12 -5
- package/dist/bootstrap/declare.js +33 -18
- package/dist/bootstrap/versions.d.ts +20 -0
- package/dist/bootstrap/versions.js +46 -0
- package/dist/calls-sfu.js +3 -3
- package/dist/index.d.ts +9 -4
- package/dist/index.js +5 -2
- package/dist/migrations/0053_heartbeats_provider.sql +9 -0
- package/dist/migrations/0053_org_version_pin.sql +6 -0
- package/dist/org.d.ts +60 -14
- package/dist/org.js +118 -28
- package/dist/plan/backup-gate.d.ts +93 -0
- package/dist/plan/backup-gate.js +261 -0
- package/dist/plan/broker-entries.d.ts +9 -1
- package/dist/plan/broker-entries.js +20 -3
- package/dist/plan/build-record.d.ts +56 -0
- package/dist/plan/build-record.js +109 -0
- package/dist/plan/calls-matrix.js +3 -3
- package/dist/plan/cloudflared-connector.js +2 -2
- package/dist/plan/company-backups.d.ts +31 -0
- package/dist/plan/company-backups.js +101 -0
- package/dist/plan/deploy-health.d.ts +12 -2
- package/dist/plan/deploy-health.js +3 -1
- package/dist/plan/destinations.d.ts +73 -0
- package/dist/plan/destinations.js +217 -0
- package/dist/plan/diff.d.ts +17 -8
- package/dist/plan/diff.js +131 -131
- package/dist/plan/edge-access.d.ts +15 -0
- package/dist/plan/edge-access.js +21 -2
- package/dist/plan/executor.d.ts +166 -7
- package/dist/plan/executor.js +547 -106
- package/dist/plan/health-targets.d.ts +1 -3
- package/dist/plan/health-targets.js +36 -36
- package/dist/plan/held-key-refs.d.ts +5 -0
- package/dist/plan/held-key-refs.js +5 -0
- package/dist/plan/hostnames.d.ts +10 -0
- package/dist/plan/hostnames.js +14 -0
- package/dist/plan/index.d.ts +5 -0
- package/dist/plan/index.js +3 -0
- package/dist/plan/live.d.ts +6 -0
- package/dist/plan/live.js +3 -2
- package/dist/plan/locked-hostnames.d.ts +2 -1
- package/dist/plan/locked-hostnames.js +16 -3
- package/dist/plan/login-host.d.ts +9 -20
- package/dist/plan/login-host.js +10 -25
- package/dist/plan/mail-relay.d.ts +48 -29
- package/dist/plan/mail-relay.js +145 -68
- package/dist/plan/network-move.d.ts +20 -0
- package/dist/plan/network-move.js +41 -0
- package/dist/plan/oikos-ingress.d.ts +111 -0
- package/dist/plan/oikos-ingress.js +231 -0
- package/dist/plan/org-names.d.ts +8 -2
- package/dist/plan/org-names.js +57 -1
- package/dist/plan/own-diff.d.ts +9 -8
- package/dist/plan/own-diff.js +42 -34
- package/dist/plan/own-hostnames.d.ts +27 -0
- package/dist/plan/own-hostnames.js +24 -0
- package/dist/plan/own-limits.d.ts +198 -7
- package/dist/plan/own-limits.js +163 -32
- package/dist/plan/own-services.js +3 -1
- package/dist/plan/own-sfu.js +3 -2
- package/dist/plan/plan-apply.d.ts +47 -7
- package/dist/plan/plan-apply.js +184 -38
- package/dist/plan/platform-diff.d.ts +133 -17
- package/dist/plan/platform-diff.js +231 -68
- package/dist/plan/platform-org.d.ts +79 -10
- package/dist/plan/platform-org.js +93 -13
- package/dist/plan/platform-providers.d.ts +118 -73
- package/dist/plan/platform-providers.js +799 -241
- package/dist/plan/proxy-labels.d.ts +57 -11
- package/dist/plan/proxy-labels.js +94 -14
- package/dist/plan/run-options.d.ts +19 -0
- package/dist/plan/run-options.js +46 -0
- package/dist/plan/zitadel-login-policy.d.ts +1 -1
- package/dist/plan/zitadel-login-policy.js +76 -8
- package/dist/platform.d.ts +32 -0
- package/dist/platform.js +16 -0
- package/dist/provision.d.ts +2 -0
- package/dist/provision.js +3 -1
- package/dist/slug.d.ts +3 -1
- package/dist/slug.js +25 -3
- package/dist/store/postgres.js +43 -1
- package/dist/store/schema.d.ts +36 -0
- package/dist/store/schema.js +4 -0
- package/package.json +3 -3
- package/versions.digests.json +11 -0
|
@@ -1,22 +1,26 @@
|
|
|
1
1
|
import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, randomUUID, sign, } from 'node:crypto';
|
|
2
2
|
import { isIPv4 } from 'node:net';
|
|
3
3
|
import { applyObjects, openInstance, platformMailSettingsObjects } from '@wtfalch/email/postmaster';
|
|
4
|
-
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
4
|
+
import { VERSIONS, floatingTagBlocked, pinnedRef, pinnedTagFor } from '../bootstrap/versions.js';
|
|
5
5
|
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
6
6
|
import { ownerMailboxes } from '../org.js';
|
|
7
7
|
import { BOULE_BRIDGE_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_PEER_ROLE, bouleHostnameFor, } from './boule.js';
|
|
8
|
+
import { recordBuiltCommit } from './build-record.js';
|
|
8
9
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
9
10
|
import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
|
|
11
|
+
import { placedOnPlatform } from './destinations.js';
|
|
10
12
|
import { ACCESS_POLICY_NAME, coolifyProjectMark, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
|
|
11
13
|
import { ACCESS_PROVIDER_RECORD_KEY, GATE_CLAIM_NAME } from './gates.js';
|
|
12
14
|
import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, tcpProbe, } from './health-check-settings.js';
|
|
13
15
|
import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
|
|
16
|
+
import { LIMITS, limited } from './own-limits.js';
|
|
14
17
|
export { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
|
|
15
18
|
import { coolifyDomains, coolifyTunnelDomains, filesShareHostname } from './hostnames.js';
|
|
16
19
|
import { MAIL_RELAY_BLOCKED, provisionMailRelay } from './mail-relay.js';
|
|
20
|
+
import { OIKOS_INGRESS_BLOCKED, PLATFORM_OIKOS_INGRESS_KEY, oikosIngressApplied, oikosIngressZoneBlocked, provisionOikosIngress, } from './oikos-ingress.js';
|
|
17
21
|
import { recordOwnDnsHostname } from './own-dns.js';
|
|
18
22
|
import { platformNetwork, platformTarget, scopeFromPlatform, } from './own-services.js';
|
|
19
|
-
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_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_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_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_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_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_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_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_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_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED, 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_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,
|
|
23
|
+
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_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_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_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_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_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED, 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_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, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
|
|
20
24
|
import { ensureSignInOrg, inviteOwnerWithLink, signInOrgDomain } from './sign-in-org.js';
|
|
21
25
|
export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
|
|
22
26
|
import { applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
|
|
@@ -90,70 +94,6 @@ const PLATFORM_COOLIFY_PROJECT_NAME = 'platform';
|
|
|
90
94
|
const JOBS_SCHEDULER_REPO = 'wtfalch/jobs';
|
|
91
95
|
/** The port the scheduler listens on (jobs' `PORT`, default 8080). */
|
|
92
96
|
const JOBS_PORT = '8080';
|
|
93
|
-
/**
|
|
94
|
-
* The real {@link PlatformStepExecutor} for the two steps built so far:
|
|
95
|
-
* `cloudflare:zone` for the platform's apex (#167) and `coolify:jobs` for the
|
|
96
|
-
* jobs scheduler (#173). `coolify` is optional so every #167-era caller --
|
|
97
|
-
* anything that only ever plans the apex zone -- keeps constructing this with
|
|
98
|
-
* two arguments; a `coolify:jobs` step reaching this executor with no
|
|
99
|
-
* `coolify` port configured throws, naming what is missing, rather than
|
|
100
|
-
* silently doing nothing.
|
|
101
|
-
*
|
|
102
|
-
* `cloudflareZone` mirrors `executor.ts`'s `cloudflareZone`: find the zone by
|
|
103
|
-
* apex and adopt it, create it only when absent. companyincluded.com already
|
|
104
|
-
* exists on Cloudflare, empty, so the first apply adopts it. The owner org
|
|
105
|
-
* (Company Included, whose own domain is the apex) may record the same zone
|
|
106
|
-
* under its own applied state, so that owner is allowed. Any other org that
|
|
107
|
-
* recorded it makes this refuse to adopt. There is no delete here and
|
|
108
|
-
* `PlatformStepExecutor` has none: neither path ever removes the zone.
|
|
109
|
-
*
|
|
110
|
-
* `coolifyJobs` mirrors `executor.ts`'s `coolifyApp`/`coolifyDatabase`,
|
|
111
|
-
* simplified for one platform-level singleton rather than one per org: find
|
|
112
|
-
* or create a dedicated `platform` Coolify project, find or create the
|
|
113
|
-
* scheduler's own database, find or create the scheduler app itself (from
|
|
114
|
-
* https://github.com/wtfalch/jobs, one Dockerfile at its repo root, on the
|
|
115
|
-
* declared `gitRef`), then push the database's `internal_db_url` in as
|
|
116
|
-
* `DATABASE_URL`. There is no park branch yet, matching `planJobs`'s own
|
|
117
|
-
* scope note -- `platform.jobs.enabled` going from true to false plans
|
|
118
|
-
* nothing, so this method is never asked to stop or delete anything.
|
|
119
|
-
*
|
|
120
|
-
* #170 adds `sfu:livekit`, the platform's shared calls SFU. `sfu` is
|
|
121
|
-
* optional: an existing host that constructs this executor for the zone step
|
|
122
|
-
* alone keeps compiling, and a plan that includes an `sfu:livekit` step
|
|
123
|
-
* without it configured refuses clearly rather than silently doing nothing.
|
|
124
|
-
* #152: the step mints one LiveKit key per org with calls enabled rather
|
|
125
|
-
* than one shared pair, so it also needs `declaredOrgStore.list()` -- the
|
|
126
|
-
* same store this class already takes for `findOrgIdForZone`.
|
|
127
|
-
*
|
|
128
|
-
* #168 adds `coolify:app` keyed {@link PLATFORM_AUTH_KEY}, the platform's own
|
|
129
|
-
* sign-in ({@link provisionPlatformAuth}), once a host passes
|
|
130
|
-
* {@link PlatformAuthDeps}.
|
|
131
|
-
*
|
|
132
|
-
* #169 adds `coolify:app` keyed {@link PLATFORM_MAIL_KEY}, the platform's own
|
|
133
|
-
* mail server ({@link provisionPlatformMail}), once a host passes
|
|
134
|
-
* {@link PlatformMailDeps}.
|
|
135
|
-
*
|
|
136
|
-
* #174 adds `coolify:app` keyed {@link PLATFORM_INTEGRATIONS_KEY},
|
|
137
|
-
* the integrations host ({@link provisionPlatformIntegrations}), once a host
|
|
138
|
-
* passes {@link PlatformIntegrationsDeps}.
|
|
139
|
-
*
|
|
140
|
-
* #171 adds `coolify:app` keyed {@link PLATFORM_FILES_KEY}, the files host
|
|
141
|
-
* ({@link provisionPlatformFiles}), once a host passes {@link PlatformFilesDeps}.
|
|
142
|
-
*
|
|
143
|
-
* #172 adds `coolify:app` keyed {@link PLATFORM_AI_KEY}, the platform's AI
|
|
144
|
-
* host ({@link provisionPlatformAi}), once a host passes {@link PlatformAiDeps}.
|
|
145
|
-
*
|
|
146
|
-
* #478 adds `coolify:app` keyed {@link PLATFORM_EDGE_KEY}, the edge host
|
|
147
|
-
* ({@link provisionPlatformEdge}), once a host passes {@link PlatformEdgeDeps}.
|
|
148
|
-
*
|
|
149
|
-
* #175 adds `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}, Archon itself
|
|
150
|
-
* ({@link provisionPlatformArchon}), once a host passes
|
|
151
|
-
* {@link PlatformArchonDeps}.
|
|
152
|
-
*
|
|
153
|
-
* #257 adds `coolify:backup:<service>`, a nightly R2 backup of each
|
|
154
|
-
* platform database ({@link provisionPlatformBackup}), once a host passes
|
|
155
|
-
* {@link PlatformBackupDeps}.
|
|
156
|
-
*/
|
|
157
97
|
export class FoundryPlatformStepExecutor {
|
|
158
98
|
cloudflare;
|
|
159
99
|
declaredOrgStore;
|
|
@@ -173,6 +113,7 @@ export class FoundryPlatformStepExecutor {
|
|
|
173
113
|
heartbeats;
|
|
174
114
|
uptime;
|
|
175
115
|
accessProvider;
|
|
116
|
+
oikosIngress;
|
|
176
117
|
constructor(cloudflare, declaredOrgStore, options = {}) {
|
|
177
118
|
this.cloudflare = cloudflare;
|
|
178
119
|
this.declaredOrgStore = declaredOrgStore;
|
|
@@ -192,8 +133,23 @@ export class FoundryPlatformStepExecutor {
|
|
|
192
133
|
this.heartbeats = options.heartbeats;
|
|
193
134
|
this.uptime = options.uptime;
|
|
194
135
|
this.accessProvider = options.accessProvider;
|
|
136
|
+
this.oikosIngress = options.oikosIngress;
|
|
137
|
+
}
|
|
138
|
+
platformPorts = new WeakMap();
|
|
139
|
+
/** The port placed on the platform's destination, made once per port so a server is listed once. */
|
|
140
|
+
onPlatform(coolify) {
|
|
141
|
+
let made = this.platformPorts.get(coolify);
|
|
142
|
+
if (!made) {
|
|
143
|
+
made = placedOnPlatform(coolify);
|
|
144
|
+
this.platformPorts.set(coolify, made);
|
|
145
|
+
}
|
|
146
|
+
return made;
|
|
147
|
+
}
|
|
148
|
+
/** `deps` with its Coolify port placed on the platform destination, then limited by `profile`. */
|
|
149
|
+
limitedOnPlatform(deps, profile) {
|
|
150
|
+
return { ...deps, coolify: limited(this.onPlatform(deps.coolify), profile) };
|
|
195
151
|
}
|
|
196
|
-
async blockedSteps(platform, recordedIds = {}) {
|
|
152
|
+
async blockedSteps(platform, recordedIds = {}, owner = {}) {
|
|
197
153
|
const blocked = {};
|
|
198
154
|
if (platform.auth.enabled && !this.auth) {
|
|
199
155
|
blocked.auth =
|
|
@@ -235,7 +191,8 @@ export class FoundryPlatformStepExecutor {
|
|
|
235
191
|
}
|
|
236
192
|
if (platform.mail.relay === true) {
|
|
237
193
|
const relayBlocked = this.mail
|
|
238
|
-
? mailRelayBlockedReason()
|
|
194
|
+
? (mailRelayBlockedReason() ??
|
|
195
|
+
(await relayServerBlockedReason(platform, this.mail, recordedIds)))
|
|
239
196
|
: MAIL_RELAY_BLOCKED.executorMissing;
|
|
240
197
|
if (relayBlocked)
|
|
241
198
|
blocked.relay = relayBlocked;
|
|
@@ -298,6 +255,23 @@ export class FoundryPlatformStepExecutor {
|
|
|
298
255
|
if (edgeBlocked)
|
|
299
256
|
blocked.edge = edgeBlocked;
|
|
300
257
|
}
|
|
258
|
+
if (platform.edge.enabled && !oikosIngressApplied(recordedIds)) {
|
|
259
|
+
const edgeUrl = recordedIds[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
|
|
260
|
+
if (!this.oikosIngress) {
|
|
261
|
+
blocked.oikosIngress = OIKOS_INGRESS_BLOCKED.executorMissing;
|
|
262
|
+
}
|
|
263
|
+
else if (edgeUrl) {
|
|
264
|
+
let zoneBlocked;
|
|
265
|
+
try {
|
|
266
|
+
zoneBlocked = await oikosIngressZoneBlocked(await this.oikosIngress.zones(edgeUrl), this.oikosIngress.callTimeoutMs);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
zoneBlocked = OIKOS_INGRESS_BLOCKED.zoneUnchecked;
|
|
270
|
+
}
|
|
271
|
+
if (zoneBlocked)
|
|
272
|
+
blocked.oikosIngress = zoneBlocked;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
301
275
|
if (platform.archon.enabled) {
|
|
302
276
|
if (!this.archon) {
|
|
303
277
|
blocked.archon =
|
|
@@ -308,7 +282,7 @@ export class FoundryPlatformStepExecutor {
|
|
|
308
282
|
'Archon is blocked, not planned: the host names no email the Cloudflare Access app in front of it may let through';
|
|
309
283
|
}
|
|
310
284
|
else {
|
|
311
|
-
const inputs = await collectArchonServiceInputs(platform, this.archon, recordedIds);
|
|
285
|
+
const inputs = await collectArchonServiceInputs(platform, this.archon, recordedIds, owner.files ?? null);
|
|
312
286
|
if ('blocked' in inputs) {
|
|
313
287
|
blocked.archon = inputs.blocked;
|
|
314
288
|
}
|
|
@@ -321,6 +295,22 @@ export class FoundryPlatformStepExecutor {
|
|
|
321
295
|
}
|
|
322
296
|
}
|
|
323
297
|
}
|
|
298
|
+
// Archon's own sign-in organisation lives in the owner company's ZITADEL, reached through the sign-in dependencies.
|
|
299
|
+
if (platform.archon.enabled) {
|
|
300
|
+
if (!this.auth) {
|
|
301
|
+
blocked.archonOrg =
|
|
302
|
+
"Archon's sign-in organisation is blocked, not planned: the host built the platform executor without its auth dependencies";
|
|
303
|
+
}
|
|
304
|
+
else if (!this.auth.zitadelForInstance) {
|
|
305
|
+
blocked.archonOrg =
|
|
306
|
+
"Archon's sign-in organisation is blocked, not planned: the host built the platform's auth dependencies without zitadelForInstance, so it cannot reach the owner company's ZITADEL";
|
|
307
|
+
}
|
|
308
|
+
const ownerCompany = await this.declaredOrgStore.get(platform.ownerOrgId);
|
|
309
|
+
const inviteBlocked = ownerInviteBlockedReason(ownerCompany, Boolean(this.auth?.ownerInviteMail));
|
|
310
|
+
if (inviteBlocked) {
|
|
311
|
+
blocked.archonOrgInvite = inviteBlocked.replace("the platform owner's invite", "the first person's invite into Archon's sign-in organisation");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
324
314
|
// Only once a database actually exists to back up -- the same
|
|
325
315
|
// "nothing to report yet" rule `planBackups` (`platform-diff.ts`)
|
|
326
316
|
// itself follows for "no database", so an otherwise-unrelated platform
|
|
@@ -338,21 +328,30 @@ export class FoundryPlatformStepExecutor {
|
|
|
338
328
|
if (hcTagBlocked)
|
|
339
329
|
blocked.hc = hcTagBlocked;
|
|
340
330
|
}
|
|
341
|
-
//
|
|
342
|
-
//
|
|
331
|
+
// foundry#544: `'hosted'` needs none of `hc`'s own applied state -- only
|
|
332
|
+
// the self-hosted provider's standing note depends on `hc` being
|
|
333
|
+
// recorded applied at all. `planHeartbeats` itself already withholds the
|
|
334
|
+
// step while neither provider is ready, so a host with no heartbeats
|
|
343
335
|
// deps wired carries no standing blocked note before there is anything
|
|
344
336
|
// for it to reconcile.
|
|
345
|
-
|
|
337
|
+
const heartbeatsHosted = platform.heartbeatsProvider === 'hosted';
|
|
338
|
+
if (!this.heartbeats &&
|
|
339
|
+
(heartbeatsHosted || Boolean(recordedIds[`coolify:app:${PLATFORM_HC_KEY}`]))) {
|
|
346
340
|
blocked.heartbeatsExecutorMissing = true;
|
|
347
341
|
}
|
|
348
|
-
else if (platform.hc.enabled && this.heartbeats) {
|
|
342
|
+
else if ((heartbeatsHosted || platform.hc.enabled) && this.heartbeats) {
|
|
349
343
|
// #540 round 2: checked whether or not `hc` is recorded applied yet --
|
|
350
344
|
// `hcReady` (`planPlatform`) goes true the same round `hc` is merely
|
|
351
345
|
// *planned*, so this has to hold the heartbeats step back from the
|
|
352
346
|
// very first round, before `coolify:app:platform:hc` is ever recorded.
|
|
353
347
|
// See `PLATFORM_HC_API_KEY_REF`'s own doc comment (`platform-diff.ts`)
|
|
354
|
-
// for why nothing here can mint this key itself
|
|
355
|
-
|
|
348
|
+
// for why nothing here can mint this key itself; foundry#544's
|
|
349
|
+
// `PLATFORM_HEARTBEATS_HOSTED_API_KEY_REF` is the same rule for the
|
|
350
|
+
// outside provider's own key.
|
|
351
|
+
const apiKeyRef = heartbeatsHosted
|
|
352
|
+
? PLATFORM_HEARTBEATS_HOSTED_API_KEY_REF
|
|
353
|
+
: PLATFORM_HC_API_KEY_REF;
|
|
354
|
+
const apiKey = await this.heartbeats.heldKeys?.resolve?.(apiKeyRef);
|
|
356
355
|
if (!apiKey)
|
|
357
356
|
blocked.heartbeatsApiKeyMissing = true;
|
|
358
357
|
}
|
|
@@ -360,7 +359,7 @@ export class FoundryPlatformStepExecutor {
|
|
|
360
359
|
// #604: only once edge and sign-in are applied (the planner reports
|
|
361
360
|
// that case itself) and no provider is recorded yet.
|
|
362
361
|
if (platform.edge.enabled &&
|
|
363
|
-
|
|
362
|
+
owner.signIn &&
|
|
364
363
|
!recordedIds[ACCESS_PROVIDER_RECORD_KEY] &&
|
|
365
364
|
recordedIds[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY]) {
|
|
366
365
|
if (!this.accessProvider?.zitadel.createOidcAppWithSecret) {
|
|
@@ -382,9 +381,10 @@ export class FoundryPlatformStepExecutor {
|
|
|
382
381
|
platform.archon.enabled &&
|
|
383
382
|
recordedIds[PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY]) {
|
|
384
383
|
try {
|
|
385
|
-
const zitadel =
|
|
386
|
-
this.archon.
|
|
387
|
-
|
|
384
|
+
const zitadel = owner.signIn
|
|
385
|
+
? this.archon.zitadelForInstance?.(owner.signIn.issuerUrl, owner.signIn.bearerRef)
|
|
386
|
+
: undefined;
|
|
387
|
+
if (zitadel?.updateOidcAppIdTokenUserinfoAssertion)
|
|
388
388
|
blocked.archonIdTokenUserinfo = true;
|
|
389
389
|
}
|
|
390
390
|
catch (e) {
|
|
@@ -486,18 +486,25 @@ export class FoundryPlatformStepExecutor {
|
|
|
486
486
|
* login-client token is no longer the sole live PAT on its ZITADEL machine
|
|
487
487
|
* user.
|
|
488
488
|
*/
|
|
489
|
-
async driftedSteps(platform, recordedIds = {}) {
|
|
489
|
+
async driftedSteps(platform, recordedIds = {}, owner = {}) {
|
|
490
490
|
const result = {};
|
|
491
491
|
if (platform.archon.enabled &&
|
|
492
492
|
this.archon &&
|
|
493
493
|
recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`]) {
|
|
494
|
-
const
|
|
494
|
+
const ownerCompany = await this.declaredOrgStore.get(platform.ownerOrgId);
|
|
495
|
+
const accessDrifted = await archonAccessDrifted(this.archon, recordedIds, platform.archon.hostname, archonAccessEmails(this.archon.accessEmails, ownerCompany), platform.uptime.enabled
|
|
495
496
|
? recordedIds[PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY]
|
|
496
497
|
: undefined);
|
|
497
|
-
const
|
|
498
|
+
const archonOrgId = recordedIds[PLATFORM_ARCHON_ORG_SCOPE_KEY];
|
|
499
|
+
const brokerDrifted = !accessDrifted &&
|
|
500
|
+
(await archonBrokerRegistrationDrifted(this.archon.coolify, owner.signIn ?? null, archonOrgId, recordedIds));
|
|
498
501
|
const authOrgDrifted = !accessDrifted &&
|
|
499
502
|
!brokerDrifted &&
|
|
500
|
-
(await archonAuthOrgDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`],
|
|
503
|
+
(await archonAuthOrgDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`], {
|
|
504
|
+
AUTH_ORGANIZATION_ID: archonOrgId,
|
|
505
|
+
CI_ORGANIZATION_ID: owner.signIn?.organisationId,
|
|
506
|
+
ARCHON_FIRST_OWNER_LINK: archonFirstOwnerLink(recordedIds, owner.signIn ?? null),
|
|
507
|
+
}));
|
|
501
508
|
const authzDrifted = !accessDrifted &&
|
|
502
509
|
!brokerDrifted &&
|
|
503
510
|
!authOrgDrifted &&
|
|
@@ -602,13 +609,13 @@ export class FoundryPlatformStepExecutor {
|
|
|
602
609
|
throw new Error('platform:auth needs FoundryPlatformStepExecutor to be constructed with its auth dependencies (#168)');
|
|
603
610
|
}
|
|
604
611
|
const company = await this.declaredOrgStore.get(context.platform.ownerOrgId);
|
|
605
|
-
return provisionPlatformAuth(context, this.auth, company);
|
|
612
|
+
return provisionPlatformAuth(context, this.limitedOnPlatform(this.auth, LIMITS.platformAuth), company);
|
|
606
613
|
}
|
|
607
614
|
if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_FILES_KEY) {
|
|
608
615
|
if (!this.files) {
|
|
609
616
|
throw new Error('platform:files needs FoundryPlatformStepExecutor to be constructed with its files dependencies (#171)');
|
|
610
617
|
}
|
|
611
|
-
return provisionPlatformFiles(context, this.files, filesShareDomains(await this.declaredOrgStore.list()));
|
|
618
|
+
return provisionPlatformFiles(context, this.limitedOnPlatform(this.files, LIMITS.platformFiles), filesShareDomains(await this.declaredOrgStore.list()));
|
|
612
619
|
}
|
|
613
620
|
if (action.system === 'coolify' &&
|
|
614
621
|
action.kind === 'app' &&
|
|
@@ -616,19 +623,19 @@ export class FoundryPlatformStepExecutor {
|
|
|
616
623
|
if (!this.integrations) {
|
|
617
624
|
throw new Error('platform:integrations needs FoundryPlatformStepExecutor to be constructed with its integrations dependencies (#174)');
|
|
618
625
|
}
|
|
619
|
-
return provisionPlatformIntegrations(action, context, this.integrations);
|
|
626
|
+
return provisionPlatformIntegrations(action, context, this.limitedOnPlatform(this.integrations, LIMITS.platformIntegrations));
|
|
620
627
|
}
|
|
621
628
|
if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_AI_KEY) {
|
|
622
629
|
if (!this.ai) {
|
|
623
630
|
throw new Error('platform:ai needs FoundryPlatformStepExecutor to be constructed with its ai dependencies (#172)');
|
|
624
631
|
}
|
|
625
|
-
return provisionPlatformAi(context, this.ai);
|
|
632
|
+
return provisionPlatformAi(context, this.limitedOnPlatform(this.ai, LIMITS.platformAi));
|
|
626
633
|
}
|
|
627
634
|
if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_EDGE_KEY) {
|
|
628
635
|
if (!this.edge) {
|
|
629
636
|
throw new Error('platform:edge needs FoundryPlatformStepExecutor to be constructed with its edge dependencies (#478)');
|
|
630
637
|
}
|
|
631
|
-
return provisionPlatformEdge(context, this.edge);
|
|
638
|
+
return provisionPlatformEdge(context, this.limitedOnPlatform(this.edge, LIMITS.platformEdge));
|
|
632
639
|
}
|
|
633
640
|
if (action.system === 'coolify' &&
|
|
634
641
|
action.kind === 'app' &&
|
|
@@ -637,13 +644,13 @@ export class FoundryPlatformStepExecutor {
|
|
|
637
644
|
throw new Error('platform:archon needs FoundryPlatformStepExecutor to be constructed with its archon dependencies (#175)');
|
|
638
645
|
}
|
|
639
646
|
const ownerCompany = await this.declaredOrgStore.get(context.platform.ownerOrgId);
|
|
640
|
-
return provisionPlatformArchon(context, this.archon, ownerCompany);
|
|
647
|
+
return provisionPlatformArchon(context, this.limitedOnPlatform(this.archon, LIMITS.platformArchon), ownerCompany);
|
|
641
648
|
}
|
|
642
649
|
if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_MAIL_KEY) {
|
|
643
650
|
if (!this.mail) {
|
|
644
651
|
throw new Error('platform:mail needs FoundryPlatformStepExecutor to be constructed with its mail dependencies (#169)');
|
|
645
652
|
}
|
|
646
|
-
return provisionPlatformMail(context, this.mail);
|
|
653
|
+
return provisionPlatformMail(context, this.limitedOnPlatform(this.mail, LIMITS.platformMail));
|
|
647
654
|
}
|
|
648
655
|
if (action.system === 'coolify' &&
|
|
649
656
|
action.kind === 'app' &&
|
|
@@ -651,13 +658,22 @@ export class FoundryPlatformStepExecutor {
|
|
|
651
658
|
if (!this.mail) {
|
|
652
659
|
throw new Error('platform:mail-relay needs FoundryPlatformStepExecutor to be constructed with its mail dependencies');
|
|
653
660
|
}
|
|
654
|
-
return provisionPlatformMailRelay(action, context, this.mail);
|
|
661
|
+
return provisionPlatformMailRelay(action, context, this.limitedOnPlatform(this.mail, LIMITS.platformMailRelay));
|
|
655
662
|
}
|
|
656
663
|
if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_HC_KEY) {
|
|
657
664
|
if (!this.hc) {
|
|
658
665
|
throw new Error('platform:hc needs FoundryPlatformStepExecutor to be constructed with its hc dependencies (#540)');
|
|
659
666
|
}
|
|
660
|
-
return provisionPlatformHc(context, this.hc);
|
|
667
|
+
return provisionPlatformHc(context, this.limitedOnPlatform(this.hc, LIMITS.platformHc));
|
|
668
|
+
}
|
|
669
|
+
if (action.system === 'zitadel' &&
|
|
670
|
+
action.kind === 'archon-org' &&
|
|
671
|
+
action.key === PLATFORM_ARCHON_KEY) {
|
|
672
|
+
if (!this.auth) {
|
|
673
|
+
throw new Error("platform:archon-org needs FoundryPlatformStepExecutor to be constructed with its auth dependencies: Archon's sign-in organisation is made through them");
|
|
674
|
+
}
|
|
675
|
+
const ownerCompany = await this.declaredOrgStore.get(context.platform.ownerOrgId);
|
|
676
|
+
return provisionPlatformArchonOrg(context, this.auth, ownerCompany);
|
|
661
677
|
}
|
|
662
678
|
if (action.system === 'zitadel' &&
|
|
663
679
|
action.kind === 'id-token-userinfo' &&
|
|
@@ -695,6 +711,14 @@ export class FoundryPlatformStepExecutor {
|
|
|
695
711
|
}
|
|
696
712
|
return provisionPlatformUptime(context, this.uptime);
|
|
697
713
|
}
|
|
714
|
+
if (action.system === 'coolify' &&
|
|
715
|
+
action.kind === 'app' &&
|
|
716
|
+
action.key === PLATFORM_OIKOS_INGRESS_KEY) {
|
|
717
|
+
if (!this.oikosIngress) {
|
|
718
|
+
throw new Error('platform:oikos-ingress needs FoundryPlatformStepExecutor to be constructed with its oikos ingress dependencies');
|
|
719
|
+
}
|
|
720
|
+
return provisionPlatformOikosIngress(context, this.limitedOnPlatform(this.oikosIngress, LIMITS.platformIngress));
|
|
721
|
+
}
|
|
698
722
|
if (action.system === 'cloudflare' && action.kind === 'zone') {
|
|
699
723
|
return this.cloudflareZone(action, context);
|
|
700
724
|
}
|
|
@@ -768,7 +792,11 @@ export class FoundryPlatformStepExecutor {
|
|
|
768
792
|
if (!deliverSecret) {
|
|
769
793
|
throw new Error('platform step coolify:jobs needs a deliverSecret -- construct FoundryPlatformStepExecutor with one, to hand the scheduler admin token to the host (#173)');
|
|
770
794
|
}
|
|
771
|
-
return provisionJobsHost(scopeFromPlatform(context), {
|
|
795
|
+
return provisionJobsHost(scopeFromPlatform(context), {
|
|
796
|
+
coolify: limited(this.onPlatform(coolify), LIMITS.platformJobs),
|
|
797
|
+
deliverSecret,
|
|
798
|
+
runtime: this.runtime,
|
|
799
|
+
}, platformTarget(), {
|
|
772
800
|
hostname: action.desired.hostname,
|
|
773
801
|
gitRef: action.desired.gitRef,
|
|
774
802
|
project: platformJobsProject,
|
|
@@ -944,7 +972,8 @@ export async function provisionJobsHost(scope, deps, target, input) {
|
|
|
944
972
|
// the AI worker. A deploy that fails or times out throws here, records
|
|
945
973
|
// nothing and leaves the step planned.
|
|
946
974
|
if (!existingApp || tokenJustSet || pending || !running || addressNew) {
|
|
947
|
-
await waitForNewDeployment(coolify, appId, { kind: 'deployment-only' }, runtime, runtime.sleep ?? defaultSleep, label, 'the scheduler keeps its old address');
|
|
975
|
+
const built = await waitForNewDeployment(coolify, appId, { kind: 'deployment-only' }, runtime, runtime.sleep ?? defaultSleep, label, 'the scheduler keeps its old address');
|
|
976
|
+
await recordBuiltCommit(coolify, { appId: appId, deploymentUuid: built.deploymentUuid, name: 'jobs' }, (key, value) => scope.recordScopeId(key, value));
|
|
948
977
|
}
|
|
949
978
|
if (pending) {
|
|
950
979
|
scope.recordScopeId(JOBS_DEPLOY_PENDING_SCOPE_KEY, '');
|
|
@@ -991,7 +1020,7 @@ const AUTH_ZITADEL_PORT = '8080';
|
|
|
991
1020
|
*/
|
|
992
1021
|
export function zitadelDockerfile() {
|
|
993
1022
|
return [
|
|
994
|
-
`FROM ${AUTH_ZITADEL_IMAGE}:${
|
|
1023
|
+
`FROM ${AUTH_ZITADEL_IMAGE}:${pinnedRef('zitadel')}`,
|
|
995
1024
|
`EXPOSE ${AUTH_ZITADEL_PORT}`,
|
|
996
1025
|
'CMD ["start-from-init", "--masterkeyFromEnv", "--tlsMode", "external"]',
|
|
997
1026
|
'HEALTHCHECK --interval=15s --timeout=5s --start-period=180s --retries=5 CMD ["/app/zitadel", "ready"]',
|
|
@@ -1505,6 +1534,10 @@ export async function provisionAuthStack(scope, deps, target, input) {
|
|
|
1505
1534
|
{ key: 'ZITADEL_DATABASE_POSTGRES_DSN', value: db.internal_db_url },
|
|
1506
1535
|
{ key: 'ZITADEL_FIRSTINSTANCE_SKIP', value: 'true' },
|
|
1507
1536
|
{ key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED', value: 'true' },
|
|
1537
|
+
// A username is unique per organisation, and the login name is `name@<org domain>`. Without it a
|
|
1538
|
+
// name is unique across the instance, so the owner's account in Archon's own organisation
|
|
1539
|
+
// collides with the same owner's account in the company's organisation.
|
|
1540
|
+
{ key: 'ZITADEL_DEFAULTINSTANCE_DOMAINPOLICY_USERLOGINMUSTBEDOMAIN', value: 'true' },
|
|
1508
1541
|
{
|
|
1509
1542
|
key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI',
|
|
1510
1543
|
value: `https://${hostname}/`,
|
|
@@ -1766,6 +1799,16 @@ export async function provisionAuthStack(scope, deps, target, input) {
|
|
|
1766
1799
|
if (recorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY] !== org.id) {
|
|
1767
1800
|
await record(PLATFORM_ZITADEL_ORG_SCOPE_KEY, org.id);
|
|
1768
1801
|
}
|
|
1802
|
+
// The new instance starts with open self-registration and its host is
|
|
1803
|
+
// already public: close it in the people organisation before anything
|
|
1804
|
+
// else runs. The sign-in policy step records the close and repeats it;
|
|
1805
|
+
// closing twice changes nothing.
|
|
1806
|
+
try {
|
|
1807
|
+
await zitadel.closeSelfRegistration(org.id);
|
|
1808
|
+
}
|
|
1809
|
+
catch {
|
|
1810
|
+
throw new Error(`${label}: closing self-registration failed in ZITADEL org ${org.id}`);
|
|
1811
|
+
}
|
|
1769
1812
|
const { userId, token } = await zitadel.mintAdminMachineUser(org.id, {
|
|
1770
1813
|
userName: PLATFORM_AUTH_ADMIN_USER_NAME,
|
|
1771
1814
|
name: 'Foundry (platform admin)',
|
|
@@ -2064,6 +2107,109 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
|
|
|
2064
2107
|
ownerCompany,
|
|
2065
2108
|
});
|
|
2066
2109
|
}
|
|
2110
|
+
/**
|
|
2111
|
+
* Archon's own sign-in organisation (`zitadel:archon-org` keyed
|
|
2112
|
+
* {@link PLATFORM_ARCHON_KEY}), in the owner company's ZITADEL and apart from
|
|
2113
|
+
* the organisation that holds the company's people: the org, its primary
|
|
2114
|
+
* domain, closed self-registration, a login policy that forces a second
|
|
2115
|
+
* factor, and the first person's invite (a link mailed to their personal
|
|
2116
|
+
* address). Each call records its key right after it succeeded, so a failure
|
|
2117
|
+
* leaves the earlier keys recorded and the next plan names the step again; a
|
|
2118
|
+
* repeat converges because every call looks before it writes. The errors name
|
|
2119
|
+
* the org only, never the cause: the cause can carry an address or the invite.
|
|
2120
|
+
* The invite code reaches only the mail port (30 second timeout, see
|
|
2121
|
+
* `inviteOwnerWithLink`) and is never recorded or logged.
|
|
2122
|
+
*/
|
|
2123
|
+
async function provisionPlatformArchonOrg(context, deps, ownerCompany) {
|
|
2124
|
+
const ownerSignIn = context.owner?.signIn ?? null;
|
|
2125
|
+
if (!ownerSignIn || !ownerCompany) {
|
|
2126
|
+
throw new Error("platform:archon-org: the owner company's sign-in has not been applied yet; Archon's organisation is made in its ZITADEL");
|
|
2127
|
+
}
|
|
2128
|
+
if (!deps.zitadelForInstance) {
|
|
2129
|
+
throw new Error("platform:archon-org: the host built the platform's auth dependencies without zitadelForInstance, so it cannot reach the owner company's ZITADEL");
|
|
2130
|
+
}
|
|
2131
|
+
const zitadel = deps.zitadelForInstance(ownerSignIn.issuerUrl, ownerSignIn.bearerRef);
|
|
2132
|
+
const record = async (key, value) => {
|
|
2133
|
+
context.recordScopeId(key, value);
|
|
2134
|
+
await context.persist?.();
|
|
2135
|
+
};
|
|
2136
|
+
const name = archonOrgName(ownerCompany.slug);
|
|
2137
|
+
let orgId;
|
|
2138
|
+
try {
|
|
2139
|
+
({ orgId } = await ensureSignInOrg({
|
|
2140
|
+
zitadel,
|
|
2141
|
+
name,
|
|
2142
|
+
domain: archonOrgDomain(signInOrgDomain(ownerCompany)),
|
|
2143
|
+
recordScopeId: (key, id) => context.recordScopeId(key, id),
|
|
2144
|
+
persistScopeIds: async () => {
|
|
2145
|
+
await context.persist?.();
|
|
2146
|
+
},
|
|
2147
|
+
orgScopeKey: PLATFORM_ARCHON_ORG_SCOPE_KEY,
|
|
2148
|
+
domainScopeKey: PLATFORM_ARCHON_ORG_DOMAIN_SCOPE_KEY,
|
|
2149
|
+
domainBlockedScopeKey: PLATFORM_ARCHON_ORG_DOMAIN_BLOCKED_SCOPE_KEY,
|
|
2150
|
+
registrationClosedScopeKey: PLATFORM_ARCHON_ORG_REGISTRATION_CLOSED_SCOPE_KEY,
|
|
2151
|
+
}));
|
|
2152
|
+
}
|
|
2153
|
+
catch {
|
|
2154
|
+
throw new Error(`platform:archon-org: making the sign-in organisation ${name} (its domain and closed registration) failed in ZITADEL`);
|
|
2155
|
+
}
|
|
2156
|
+
await context.persist?.();
|
|
2157
|
+
try {
|
|
2158
|
+
await zitadel.enforceSecondFactor(orgId);
|
|
2159
|
+
}
|
|
2160
|
+
catch {
|
|
2161
|
+
throw new Error(`platform:archon-org: forcing a second factor failed in ZITADEL org ${orgId}`);
|
|
2162
|
+
}
|
|
2163
|
+
await record(PLATFORM_ARCHON_ORG_POLICY_SCOPE_KEY, orgId);
|
|
2164
|
+
const recorded = context.recordedIds();
|
|
2165
|
+
const inviteRecorded = recorded[PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY];
|
|
2166
|
+
// An invite made in another org (an earlier Archon org) is made again.
|
|
2167
|
+
if (!inviteRecorded ||
|
|
2168
|
+
(inviteRecorded !== 'none' && recorded[PLATFORM_ARCHON_OWNER_INVITE_ORG_SCOPE_KEY] !== orgId)) {
|
|
2169
|
+
const owner = ownerCompany.owners[0];
|
|
2170
|
+
if (!owner) {
|
|
2171
|
+
await record(PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY, 'none');
|
|
2172
|
+
}
|
|
2173
|
+
else {
|
|
2174
|
+
const mailboxAddress = ownerMailboxes(ownerCompany)[0]?.address;
|
|
2175
|
+
// `ownerInviteBlockedReason` reports these two as blocked; the step goes on without the invite.
|
|
2176
|
+
if (mailboxAddress && deps.ownerInviteMail) {
|
|
2177
|
+
let invitedUserId;
|
|
2178
|
+
try {
|
|
2179
|
+
({ userId: invitedUserId } = await inviteOwnerWithLink({
|
|
2180
|
+
zitadel,
|
|
2181
|
+
mail: deps.ownerInviteMail,
|
|
2182
|
+
orgId,
|
|
2183
|
+
authIssuer: ownerSignIn.issuerUrl,
|
|
2184
|
+
owner: {
|
|
2185
|
+
givenName: owner.givenName,
|
|
2186
|
+
familyName: owner.familyName,
|
|
2187
|
+
mailboxAddress,
|
|
2188
|
+
personalEmail: owner.email,
|
|
2189
|
+
},
|
|
2190
|
+
label: 'platform:archon-org',
|
|
2191
|
+
...(deps.runtime?.onSecret ? { onSecret: deps.runtime.onSecret } : {}),
|
|
2192
|
+
...(inviteRecorded && inviteRecorded !== 'none'
|
|
2193
|
+
? { invitedUserId: inviteRecorded }
|
|
2194
|
+
: {}),
|
|
2195
|
+
}));
|
|
2196
|
+
}
|
|
2197
|
+
catch (error) {
|
|
2198
|
+
const cause = redactSecrets(error instanceof Error ? error.message : String(error), [
|
|
2199
|
+
owner.email,
|
|
2200
|
+
owner.email.toLowerCase(),
|
|
2201
|
+
mailboxAddress,
|
|
2202
|
+
]);
|
|
2203
|
+
throw new Error(`platform:archon-org: could not invite the first person into ZITADEL org ${orgId}: ${cause}`);
|
|
2204
|
+
}
|
|
2205
|
+
// The user id first: a crash between the two replans the invite, never skips it.
|
|
2206
|
+
await record(PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY, invitedUserId);
|
|
2207
|
+
await record(PLATFORM_ARCHON_OWNER_INVITE_ORG_SCOPE_KEY, orgId);
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
return {};
|
|
2212
|
+
}
|
|
2067
2213
|
/**
|
|
2068
2214
|
* Reserves the username `admin` and switches self-registration off in the
|
|
2069
2215
|
* platform's sign-in org (`zitadel:sign-in-policy` keyed {@link PLATFORM_AUTH_KEY}).
|
|
@@ -2138,7 +2284,7 @@ const PLATFORM_AUTH_MAIL_SENDER_NAME = 'Company Included';
|
|
|
2138
2284
|
// #169: the platform's own mail server (Stalwart)
|
|
2139
2285
|
// ---------------------------------------------------------------------------
|
|
2140
2286
|
const PLATFORM_MAIL_PROJECT = 'platform';
|
|
2141
|
-
const PLATFORM_MAIL_INTERNAL_NAME = 'platform-mail-stalwart';
|
|
2287
|
+
export const PLATFORM_MAIL_INTERNAL_NAME = 'platform-mail-stalwart';
|
|
2142
2288
|
/**
|
|
2143
2289
|
* `stalw.art/docs/install/docker`: the official image. `platform.mail.gitRef`
|
|
2144
2290
|
* carries its tag -- `platform.ts`'s `PlatformService.gitRef` doc comment
|
|
@@ -2184,7 +2330,7 @@ const STALWART_RECOVERY_USER = 'admin';
|
|
|
2184
2330
|
*/
|
|
2185
2331
|
const STALWART_READY_PATH = '/healthz/ready';
|
|
2186
2332
|
/** Where the server the app was created on is recorded, so the MX record follows the app, not a later pick. */
|
|
2187
|
-
const PLATFORM_MAIL_SERVER_SCOPE_KEY = 'coolify_platform_mail_server';
|
|
2333
|
+
export const PLATFORM_MAIL_SERVER_SCOPE_KEY = 'coolify_platform_mail_server';
|
|
2188
2334
|
const PLATFORM_MAIL_PROJECT_SCOPE_KEY = 'coolify_project_platform_mail';
|
|
2189
2335
|
const PLATFORM_MAIL_APP_SCOPE_KEY = 'coolify_platform_mail_app';
|
|
2190
2336
|
const PLATFORM_MAIL_RELAY_APP_SCOPE_KEY = 'coolify_platform_mail_relay_app';
|
|
@@ -2357,6 +2503,7 @@ const PLATFORM_MAIL_ZITADEL_PROJECT_NAME = 'mail';
|
|
|
2357
2503
|
*/
|
|
2358
2504
|
export async function provisionMailServer(scope, deps, target, input) {
|
|
2359
2505
|
const { hostname, apex, mailHost, imageTag, hostPorts, issuerUrl, mxRecord, network } = input;
|
|
2506
|
+
const zone = input.zone ?? apex;
|
|
2360
2507
|
const label = target.kind === 'org' ? `own step coolify:own-mail for ${target.id}` : 'platform:mail';
|
|
2361
2508
|
const keyDescription = target.kind === 'org' ? `foundry:org:${target.id}:admin` : PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION;
|
|
2362
2509
|
const recorded = scope.recordedIds();
|
|
@@ -2393,7 +2540,7 @@ export async function provisionMailServer(scope, deps, target, input) {
|
|
|
2393
2540
|
environment_name: environment.name,
|
|
2394
2541
|
environment_uuid: environment.uuid,
|
|
2395
2542
|
docker_registry_image_name: STALWART_IMAGE,
|
|
2396
|
-
docker_registry_image_tag: imageTag,
|
|
2543
|
+
docker_registry_image_tag: pinnedTagFor('stalwart', imageTag),
|
|
2397
2544
|
ports_exposes: STALWART_PORTS_EXPOSES,
|
|
2398
2545
|
...(hostPorts ? { ports_mappings: hostPorts } : {}),
|
|
2399
2546
|
name: target.names.mailApp,
|
|
@@ -2413,7 +2560,7 @@ export async function provisionMailServer(scope, deps, target, input) {
|
|
|
2413
2560
|
// `'ready'` probe below. Stalwart's own readiness route and HTTP port.
|
|
2414
2561
|
await deps.coolify.updateApplication(appId, {
|
|
2415
2562
|
custom_internal_name: target.internal.mail,
|
|
2416
|
-
docker_registry_image_tag: imageTag,
|
|
2563
|
+
docker_registry_image_tag: pinnedTagFor('stalwart', imageTag),
|
|
2417
2564
|
...healthCheckSettings(STALWART_READY_PATH, Number(STALWART_HTTP_PORT)),
|
|
2418
2565
|
// Only a platform that uses the shared relay, or one that did, changes a published port.
|
|
2419
2566
|
...(portsChanged ? { ports_mappings: hostPorts } : {}),
|
|
@@ -2513,15 +2660,15 @@ export async function provisionMailServer(scope, deps, target, input) {
|
|
|
2513
2660
|
recordedConnectorAppId: recorded[PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY] ?? null,
|
|
2514
2661
|
recordAppId: (appId) => record(PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, appId),
|
|
2515
2662
|
});
|
|
2516
|
-
const zoneId = await network.zoneId(
|
|
2663
|
+
const zoneId = await network.zoneId(zone);
|
|
2517
2664
|
if (!zoneId) {
|
|
2518
|
-
throw new Error(`${label}: no Cloudflare zone for ${
|
|
2665
|
+
throw new Error(`${label}: no Cloudflare zone for ${zone} to put ${hostname} in`);
|
|
2519
2666
|
}
|
|
2520
2667
|
const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
|
|
2521
2668
|
const dns = await network.dns();
|
|
2522
|
-
const [dnsRecord] = await dns.findDnsRecord(
|
|
2669
|
+
const [dnsRecord] = await dns.findDnsRecord(zone, hostname, 'CNAME');
|
|
2523
2670
|
if (!dnsRecord) {
|
|
2524
|
-
await dns.createDnsRecord(
|
|
2671
|
+
await dns.createDnsRecord(zone, {
|
|
2525
2672
|
type: 'CNAME',
|
|
2526
2673
|
name: hostname,
|
|
2527
2674
|
content: dnsTarget,
|
|
@@ -2529,7 +2676,7 @@ export async function provisionMailServer(scope, deps, target, input) {
|
|
|
2529
2676
|
});
|
|
2530
2677
|
}
|
|
2531
2678
|
else if (dnsRecord.content !== dnsTarget) {
|
|
2532
|
-
await dns.updateDnsRecord(
|
|
2679
|
+
await dns.updateDnsRecord(zone, dnsRecord.id, {
|
|
2533
2680
|
type: 'CNAME',
|
|
2534
2681
|
name: hostname,
|
|
2535
2682
|
content: dnsTarget,
|
|
@@ -2541,9 +2688,9 @@ export async function provisionMailServer(scope, deps, target, input) {
|
|
|
2541
2688
|
// ---- the MX hostname: a direct, unproxied A record --------------------------
|
|
2542
2689
|
if (mxRecord) {
|
|
2543
2690
|
const serverIp = (mxRecord.serverIpv4 ?? server.ip); // checked by mailServerIpBlocked above
|
|
2544
|
-
const [existingMx] = await dns.findDnsRecord(
|
|
2691
|
+
const [existingMx] = await dns.findDnsRecord(zone, mailHost, 'A');
|
|
2545
2692
|
if (!existingMx) {
|
|
2546
|
-
await dns.createDnsRecord(
|
|
2693
|
+
await dns.createDnsRecord(zone, {
|
|
2547
2694
|
type: 'A',
|
|
2548
2695
|
name: mailHost,
|
|
2549
2696
|
content: serverIp,
|
|
@@ -2551,7 +2698,7 @@ export async function provisionMailServer(scope, deps, target, input) {
|
|
|
2551
2698
|
});
|
|
2552
2699
|
}
|
|
2553
2700
|
else if (existingMx.content !== serverIp) {
|
|
2554
|
-
await dns.updateDnsRecord(
|
|
2701
|
+
await dns.updateDnsRecord(zone, existingMx.id, {
|
|
2555
2702
|
type: 'A',
|
|
2556
2703
|
name: mailHost,
|
|
2557
2704
|
content: serverIp,
|
|
@@ -2797,28 +2944,87 @@ async function mailBlockedReason(platform, mail, recordedIds) {
|
|
|
2797
2944
|
return "the platform's mail server is blocked, not planned: no usable Coolify server could be resolved for it";
|
|
2798
2945
|
}
|
|
2799
2946
|
}
|
|
2947
|
+
async function provisionPlatformOikosIngress(context, deps) {
|
|
2948
|
+
const recorded = context.recordedIds();
|
|
2949
|
+
const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
|
|
2950
|
+
if (!edgeUrl)
|
|
2951
|
+
throw new Error(OIKOS_INGRESS_BLOCKED.edgeNotApplied);
|
|
2952
|
+
return provisionOikosIngress(context, {
|
|
2953
|
+
coolify: deps.coolify,
|
|
2954
|
+
tunnels: await platformTunnels(deps, recorded, 'platform:oikos-ingress'),
|
|
2955
|
+
dns: await platformDns(deps, recorded, 'platform:oikos-ingress'),
|
|
2956
|
+
ratelimits: await deps.ratelimits(edgeUrl),
|
|
2957
|
+
zones: await deps.zones(edgeUrl),
|
|
2958
|
+
resolveServer: () => resolvePlatformServer(deps.coolify),
|
|
2959
|
+
callTimeoutMs: deps.callTimeoutMs,
|
|
2960
|
+
});
|
|
2961
|
+
}
|
|
2800
2962
|
/** Why the shared mail relay cannot be built from its pinned base image, or `null`. */
|
|
2801
2963
|
function mailRelayBlockedReason() {
|
|
2802
2964
|
const tagBlocked = floatingTagBlocked('mail relay base', VERSIONS.alpine, '3.22.1');
|
|
2803
2965
|
return tagBlocked ? `the platform's mail relay is blocked, not planned: ${tagBlocked}` : null;
|
|
2804
2966
|
}
|
|
2967
|
+
/** Why `mx.<apex>` has no address to point at, or `null`. Skipped once the relay is applied, as the mail step's own check was. */
|
|
2968
|
+
async function relayServerBlockedReason(platform, mail, recordedIds) {
|
|
2969
|
+
if (platform.serverIpv4 || recordedIds[`coolify:app:${PLATFORM_MAIL_RELAY_KEY}`])
|
|
2970
|
+
return null;
|
|
2971
|
+
try {
|
|
2972
|
+
const server = await resolveMailServer(mail.coolify, recordedIds[PLATFORM_MAIL_SERVER_SCOPE_KEY]);
|
|
2973
|
+
return mailServerIpBlocked(server.ip, platform.serverIpv4);
|
|
2974
|
+
}
|
|
2975
|
+
catch {
|
|
2976
|
+
return "the platform's mail relay is blocked, not planned: no usable Coolify server could be resolved for it";
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2805
2979
|
/**
|
|
2806
|
-
* Runs the shared mail relay step
|
|
2807
|
-
*
|
|
2808
|
-
* address of the Stalwart it forwards to.
|
|
2980
|
+
* Runs the shared mail relay step in the platform mail project and on the
|
|
2981
|
+
* platform server (recorded by the mail step when one ran, else found or made).
|
|
2809
2982
|
*/
|
|
2810
2983
|
async function provisionPlatformMailRelay(action, context, deps) {
|
|
2811
2984
|
const recorded = context.recordedIds();
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2985
|
+
// No platform mail step runs any more (the owner company runs its own mail), so
|
|
2986
|
+
// the relay finds or makes its project and server itself, as the mail step did.
|
|
2987
|
+
let projectUuid = recorded[PLATFORM_MAIL_PROJECT_SCOPE_KEY];
|
|
2988
|
+
if (!projectUuid) {
|
|
2989
|
+
const found = await deps.coolify.findProjectByName(PLATFORM_MAIL_PROJECT);
|
|
2990
|
+
projectUuid = found
|
|
2991
|
+
? found.uuid
|
|
2992
|
+
: (await deps.coolify.createProject(PLATFORM_MAIL_PROJECT)).uuid;
|
|
2993
|
+
context.recordScopeId(PLATFORM_MAIL_PROJECT_SCOPE_KEY, projectUuid);
|
|
2994
|
+
await context.persist?.();
|
|
2995
|
+
}
|
|
2996
|
+
let serverUuid = recorded[PLATFORM_MAIL_SERVER_SCOPE_KEY];
|
|
2997
|
+
if (!serverUuid) {
|
|
2998
|
+
serverUuid = (await resolveMailServer(deps.coolify, undefined)).uuid;
|
|
2999
|
+
context.recordScopeId(PLATFORM_MAIL_SERVER_SCOPE_KEY, serverUuid);
|
|
3000
|
+
await context.persist?.();
|
|
3001
|
+
}
|
|
3002
|
+
// The relay answers port 25 on `mx.<apex>`, and no mail step writes that name any more: the relay does,
|
|
3003
|
+
// first, so a failed write leaves the step planned (the routes are recorded last).
|
|
3004
|
+
const mxHost = platformMailMxHost(context.platform.apex);
|
|
3005
|
+
const server = await resolveMailServer(deps.coolify, serverUuid);
|
|
3006
|
+
const serverIpBlocked = mailServerIpBlocked(server.ip, context.platform.serverIpv4);
|
|
3007
|
+
if (serverIpBlocked)
|
|
3008
|
+
throw new Error(serverIpBlocked);
|
|
3009
|
+
const serverIp = (context.platform.serverIpv4 ?? server.ip);
|
|
3010
|
+
const network = platformNetwork(deps, scopeFromPlatform(context), 'platform:mail-relay');
|
|
3011
|
+
const zoneId = await network.zoneId(context.platform.apex);
|
|
3012
|
+
if (!zoneId) {
|
|
3013
|
+
throw new Error(`platform:mail-relay: no Cloudflare zone for ${context.platform.apex} to put ${mxHost} in`);
|
|
3014
|
+
}
|
|
3015
|
+
const dns = await network.dns();
|
|
3016
|
+
const [existingMx] = await dns.findDnsRecord(context.platform.apex, mxHost, 'A');
|
|
3017
|
+
const mxRecord = { type: 'A', name: mxHost, content: serverIp, proxied: false };
|
|
3018
|
+
if (!existingMx) {
|
|
3019
|
+
await dns.createDnsRecord(context.platform.apex, mxRecord);
|
|
3020
|
+
}
|
|
3021
|
+
else if (existingMx.content !== serverIp) {
|
|
3022
|
+
await dns.updateDnsRecord(context.platform.apex, existingMx.id, mxRecord);
|
|
2816
3023
|
}
|
|
2817
3024
|
return provisionMailRelay(action, context, deps, {
|
|
2818
3025
|
projectUuid,
|
|
2819
3026
|
serverUuid,
|
|
2820
|
-
hostname:
|
|
2821
|
-
defaultTarget: `${PLATFORM_MAIL_INTERNAL_NAME}:25`,
|
|
3027
|
+
hostname: mxHost,
|
|
2822
3028
|
appScopeKey: PLATFORM_MAIL_RELAY_APP_SCOPE_KEY,
|
|
2823
3029
|
});
|
|
2824
3030
|
}
|
|
@@ -3004,14 +3210,13 @@ const PLATFORM_ARCHON_PROJECT = 'platform';
|
|
|
3004
3210
|
const PLATFORM_ARCHON_APP_NAME = 'platform-archon';
|
|
3005
3211
|
const PLATFORM_ARCHON_DB_NAME = 'platform-archon-db';
|
|
3006
3212
|
/**
|
|
3007
|
-
* The database Archon runs on.
|
|
3008
|
-
*
|
|
3009
|
-
*
|
|
3010
|
-
*
|
|
3011
|
-
*
|
|
3012
|
-
* {@link ARCHON_RUNTIME_ROLE} follows.
|
|
3213
|
+
* The database Archon runs on, and its owner. A platform made from zero
|
|
3214
|
+
* (2026-10-12) creates `platform-archon-db` with this as its first database,
|
|
3215
|
+
* so both URLs built here set it themselves and never read
|
|
3216
|
+
* `internal_db_url`'s path. Archon's own migrations grant to
|
|
3217
|
+
* `current_database() || '_rt'`, so {@link ARCHON_RUNTIME_ROLE} follows.
|
|
3013
3218
|
*/
|
|
3014
|
-
const ARCHON_POSTGRES_DB = '
|
|
3219
|
+
const ARCHON_POSTGRES_DB = 'archon';
|
|
3015
3220
|
const ARCHON_RUNTIME_ROLE = `${ARCHON_POSTGRES_DB}_rt`;
|
|
3016
3221
|
/** Fixed so the tunnel addresses the container directly (see `provisionPlatformAuth`). */
|
|
3017
3222
|
const PLATFORM_ARCHON_INTERNAL_NAME = 'platform-archon';
|
|
@@ -3052,9 +3257,75 @@ const PLATFORM_ARCHON_PROJECT_SCOPE_KEY = 'coolify_project_platform_archon';
|
|
|
3052
3257
|
const PLATFORM_ARCHON_DB_SCOPE_KEY = 'coolify_platform_archon_database';
|
|
3053
3258
|
const PLATFORM_ARCHON_TUNNEL_SCOPE_KEY = 'cloudflare_platform_archon_tunnel';
|
|
3054
3259
|
const PLATFORM_ARCHON_ACCESS_SCOPE_KEY = 'cloudflare_platform_archon_access_app';
|
|
3055
|
-
|
|
3260
|
+
/**
|
|
3261
|
+
* Archon paths a machine calls and Archon authenticates itself (its `proxy.ts`:
|
|
3262
|
+
* `PUBLIC_EXACT` and `ORG_CHANGES_PATH_RE`). Each gets its own Access app with
|
|
3263
|
+
* one bypass policy, because the hostname app would otherwise ask a machine for
|
|
3264
|
+
* an emailed code. `/api/uptime/targets` and `/api/uptime/results` are left out
|
|
3265
|
+
* on purpose: they pass the hostname app through the uptime service-token
|
|
3266
|
+
* policy, and a bypass would make that token meaningless. `*` is one path
|
|
3267
|
+
* segment (the organisation id).
|
|
3268
|
+
*/
|
|
3269
|
+
export const PLATFORM_ARCHON_PUBLIC_PATHS = [
|
|
3270
|
+
{ name: 'prices', path: '/api/prices' },
|
|
3271
|
+
{ name: 'vipps-webhook', path: '/vipps/webhook' },
|
|
3272
|
+
{ name: 'vipps-agreement', path: '/vipps/agreement' },
|
|
3273
|
+
{ name: 'usage-tick', path: '/api/usage/tick' },
|
|
3274
|
+
{ name: 'reporting-collect', path: '/api/reporting/collect' },
|
|
3275
|
+
{ name: 'org-changes', path: '/api/orgs/*/changes' },
|
|
3276
|
+
];
|
|
3277
|
+
const PLATFORM_ARCHON_PUBLIC_POLICY_NAME = 'foundry: public path';
|
|
3278
|
+
const archonPublicAppName = (name) => `${PLATFORM_ARCHON_ACCESS_APP_NAME}:public:${name}`;
|
|
3279
|
+
/** The scope key one bypass app's id is recorded under. */
|
|
3280
|
+
export const archonPublicAccessScopeKey = (name) => `cloudflare_platform_archon_access_public_${name.replace(/-/g, '_')}`;
|
|
3281
|
+
/** Whether a policy list is exactly the one bypass-for-everyone policy. */
|
|
3282
|
+
function onlyBypassEveryone(policies) {
|
|
3283
|
+
const only = policies[0];
|
|
3284
|
+
return (policies.length === 1 &&
|
|
3285
|
+
only?.name === PLATFORM_ARCHON_PUBLIC_POLICY_NAME &&
|
|
3286
|
+
only.decision === 'bypass' &&
|
|
3287
|
+
Array.isArray(only.include) &&
|
|
3288
|
+
only.include.length === 1 &&
|
|
3289
|
+
JSON.stringify(only.include[0]) === '{"everyone":{}}');
|
|
3290
|
+
}
|
|
3291
|
+
/**
|
|
3292
|
+
* One Access app per public path, each with the one policy bypass/everyone and
|
|
3293
|
+
* no identity provider. Adopts only an app of its own name on the exact domain,
|
|
3294
|
+
* refuses another name there, and rebuilds one whose policies are not exactly
|
|
3295
|
+
* the bypass. A rebuild needs no closed gate: the hostname app covers the path
|
|
3296
|
+
* meanwhile, which only asks for more.
|
|
3297
|
+
*/
|
|
3298
|
+
async function ensureArchonPublicAccess(cf, hostname, recorded, record) {
|
|
3299
|
+
for (const { name, path } of PLATFORM_ARCHON_PUBLIC_PATHS) {
|
|
3300
|
+
const domain = `${hostname}${path}`;
|
|
3301
|
+
const appName = archonPublicAppName(name);
|
|
3302
|
+
const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
|
|
3303
|
+
const foreign = onDomain.find((a) => a.name !== appName);
|
|
3304
|
+
if (foreign) {
|
|
3305
|
+
throw new Error(`platform:archon: Access app '${foreign.name}' already holds ${domain}; foundry does not adopt it`);
|
|
3306
|
+
}
|
|
3307
|
+
let id = onDomain[0]?.id;
|
|
3308
|
+
if (id && !onlyBypassEveryone(await cf.listAccessPolicies(id))) {
|
|
3309
|
+
await cf.deleteAccessApp(id);
|
|
3310
|
+
id = undefined;
|
|
3311
|
+
}
|
|
3312
|
+
if (!id) {
|
|
3313
|
+
id = (await cf.createAccessApp({ name: appName, domain, type: 'self_hosted' })).id;
|
|
3314
|
+
await cf.createAccessPolicy(id, {
|
|
3315
|
+
name: PLATFORM_ARCHON_PUBLIC_POLICY_NAME,
|
|
3316
|
+
decision: 'bypass',
|
|
3317
|
+
include: [{ everyone: {} }],
|
|
3318
|
+
});
|
|
3319
|
+
}
|
|
3320
|
+
const key = archonPublicAccessScopeKey(name);
|
|
3321
|
+
if (recorded[key] !== id)
|
|
3322
|
+
await record(key, id);
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3056
3325
|
/** The hostname the sign-in client's redirect URIs were made for; a change makes a new client. */
|
|
3057
3326
|
const PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY = 'zitadel_platform_archon_client_hostname';
|
|
3327
|
+
/** The owner's ZITADEL organisation the sign-in client was made in; a change makes a new client. */
|
|
3328
|
+
const PLATFORM_ARCHON_CLIENT_ORG_SCOPE_KEY = 'zitadel_platform_archon_client_org';
|
|
3058
3329
|
/** Archon's OIDC client id: public, not a secret, so a plain scope id. */
|
|
3059
3330
|
export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_client_id';
|
|
3060
3331
|
/**
|
|
@@ -3065,13 +3336,16 @@ export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_clie
|
|
|
3065
3336
|
* names the ids only, never the cause.
|
|
3066
3337
|
*/
|
|
3067
3338
|
async function switchOnArchonIdTokenUserinfo(context, deps) {
|
|
3068
|
-
const { platform } = context;
|
|
3069
3339
|
const recorded = context.recordedIds();
|
|
3070
|
-
|
|
3340
|
+
// The client lives in the owner company's ZITADEL; without its sign-in there is nothing to switch on.
|
|
3341
|
+
const ownerSignIn = context.owner?.signIn ?? null;
|
|
3342
|
+
const zitadel = ownerSignIn
|
|
3343
|
+
? deps.zitadelForInstance?.(ownerSignIn.issuerUrl, ownerSignIn.bearerRef)
|
|
3344
|
+
: undefined;
|
|
3071
3345
|
// The planner plans nothing without the method; a stale plan does nothing.
|
|
3072
|
-
if (!zitadel
|
|
3346
|
+
if (!ownerSignIn || !zitadel?.updateOidcAppIdTokenUserinfoAssertion)
|
|
3073
3347
|
return {};
|
|
3074
|
-
const orgId =
|
|
3348
|
+
const orgId = ownerSignIn.organisationId;
|
|
3075
3349
|
const projectId = recorded[PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY];
|
|
3076
3350
|
const appId = recorded[PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY];
|
|
3077
3351
|
if (!orgId || !projectId || !appId) {
|
|
@@ -3197,7 +3471,25 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3197
3471
|
const hostname = platform.archon.hostname;
|
|
3198
3472
|
const gitRef = platform.archon.gitRef;
|
|
3199
3473
|
const recorded = context.recordedIds();
|
|
3200
|
-
|
|
3474
|
+
// Operators sign in through the owner company's own sign-in, never a platform one.
|
|
3475
|
+
const ownerSignIn = context.owner?.signIn ?? null;
|
|
3476
|
+
const ownerFiles = context.owner?.files ?? null;
|
|
3477
|
+
if (!ownerSignIn) {
|
|
3478
|
+
throw new Error("platform:archon: the owner company's sign-in has not been applied yet; Archon's sign-in client lives in it");
|
|
3479
|
+
}
|
|
3480
|
+
const issuer = ownerSignIn.issuerUrl;
|
|
3481
|
+
// Archon signs people in from its own organisation; the company's is only handed on as CI_ORGANIZATION_ID.
|
|
3482
|
+
const authOrgId = recorded[PLATFORM_ARCHON_ORG_SCOPE_KEY];
|
|
3483
|
+
const ciOrgId = ownerSignIn.organisationId;
|
|
3484
|
+
if (!authOrgId) {
|
|
3485
|
+
throw new Error("platform:archon: Archon's own sign-in organisation is not recorded yet; zitadel:archon-org runs first");
|
|
3486
|
+
}
|
|
3487
|
+
// Archon accepts sign-ins from that organisation only once it forces a second factor and keeps registration closed.
|
|
3488
|
+
if (recorded[PLATFORM_ARCHON_ORG_POLICY_SCOPE_KEY] !== authOrgId ||
|
|
3489
|
+
recorded[PLATFORM_ARCHON_ORG_REGISTRATION_CLOSED_SCOPE_KEY] !== authOrgId) {
|
|
3490
|
+
throw new Error("platform:archon: Archon's sign-in organisation does not force a second factor and keep registration closed yet; zitadel:archon-org runs first");
|
|
3491
|
+
}
|
|
3492
|
+
const firstOwnerLink = archonFirstOwnerLink(recorded, ownerSignIn);
|
|
3201
3493
|
const appliedGitRef = recorded[PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY];
|
|
3202
3494
|
const runtime = deps.runtime ?? {};
|
|
3203
3495
|
const sleep = runtime.sleep ?? defaultSleep;
|
|
@@ -3205,13 +3497,7 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3205
3497
|
if (deps.accessEmails.length === 0) {
|
|
3206
3498
|
throw new Error('platform:archon: no Access emails given; refusing to put Archon behind an Access app nobody can pass');
|
|
3207
3499
|
}
|
|
3208
|
-
|
|
3209
|
-
// which `platform:auth` builds. The planner orders that step first and
|
|
3210
|
-
// blocks this one while auth is off; this is the same rule for a caller
|
|
3211
|
-
// that reaches the step anyway.
|
|
3212
|
-
if (!platform.auth.enabled || !recorded[`coolify:app:${PLATFORM_AUTH_KEY}`]) {
|
|
3213
|
-
throw new Error("platform:archon: the platform's own sign-in (platform:auth) has not been applied yet; Archon's sign-in client lives in it");
|
|
3214
|
-
}
|
|
3500
|
+
const accessEmails = archonAccessEmails(deps.accessEmails, ownerCompany);
|
|
3215
3501
|
const record = async (key, id) => {
|
|
3216
3502
|
context.recordScopeId(key, id);
|
|
3217
3503
|
await context.persist?.();
|
|
@@ -3219,7 +3505,7 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3219
3505
|
// Every service the platform declares must have delivered what Archon reads.
|
|
3220
3506
|
// The planner blocks first; this is the same rule for a caller that reaches
|
|
3221
3507
|
// the step anyway, and it is where the values are read for the env below.
|
|
3222
|
-
const inputs = await collectArchonServiceInputs(platform, deps, recorded);
|
|
3508
|
+
const inputs = await collectArchonServiceInputs(platform, deps, recorded, ownerFiles);
|
|
3223
3509
|
if ('blocked' in inputs)
|
|
3224
3510
|
throw new Error(`platform:archon: ${inputs.blocked}`);
|
|
3225
3511
|
// #213: Archon's own onboarding reads these back as held keys before it
|
|
@@ -3324,11 +3610,25 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3324
3610
|
...healthCheckSettings(ARCHON_HEALTH_CHECK_PATH, Number(ARCHON_PORT), ARCHON_HEALTH_CHECK_START_PERIOD_SECONDS),
|
|
3325
3611
|
git_branch: gitRef,
|
|
3326
3612
|
});
|
|
3327
|
-
// ---- sign-in client in the
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
if (
|
|
3331
|
-
|
|
3613
|
+
// ---- sign-in client in the owner company's own ZITADEL --------------------
|
|
3614
|
+
// Only the owner's own instance, by its issuer and bearer reference: the
|
|
3615
|
+
// platform's `deps.zitadel` is never used, so a missing factory is an error.
|
|
3616
|
+
if (!deps.zitadelForInstance) {
|
|
3617
|
+
throw new Error("platform:archon: the host built the platform executor without zitadelForInstance, so it cannot reach the owner company's ZITADEL");
|
|
3618
|
+
}
|
|
3619
|
+
const zitadel = deps.zitadelForInstance(issuer, ownerSignIn.bearerRef);
|
|
3620
|
+
const org = { id: authOrgId };
|
|
3621
|
+
// The client made in another organisation (the company's, before Archon had its own) is deleted
|
|
3622
|
+
// once the new one serves: its ids are kept here before the recorded ones are overwritten.
|
|
3623
|
+
const oldClientOrg = recorded[PLATFORM_ARCHON_CLIENT_ORG_SCOPE_KEY];
|
|
3624
|
+
const oldProject = recorded[PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY];
|
|
3625
|
+
const oldApp = recorded[PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY];
|
|
3626
|
+
if (oldClientOrg &&
|
|
3627
|
+
oldClientOrg !== authOrgId &&
|
|
3628
|
+
oldProject &&
|
|
3629
|
+
oldApp &&
|
|
3630
|
+
!recorded[PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY]) {
|
|
3631
|
+
await record(PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY, `${oldClientOrg}|${oldProject}|${oldApp}`);
|
|
3332
3632
|
}
|
|
3333
3633
|
const { project } = await zitadel.findOrCreateProject(org.id, PLATFORM_ARCHON_ZITADEL_PROJECT);
|
|
3334
3634
|
if (recorded[PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY] !== project.id) {
|
|
@@ -3337,7 +3637,8 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3337
3637
|
// The client's redirect URIs name the hostname, and no call updates them, so
|
|
3338
3638
|
// a client made for another hostname is deleted and made again. Its id is
|
|
3339
3639
|
// public and there is no secret to lose.
|
|
3340
|
-
let clientId = recorded[PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY] === hostname
|
|
3640
|
+
let clientId = recorded[PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY] === hostname &&
|
|
3641
|
+
recorded[PLATFORM_ARCHON_CLIENT_ORG_SCOPE_KEY] === org.id
|
|
3341
3642
|
? recorded[PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY]
|
|
3342
3643
|
: undefined;
|
|
3343
3644
|
if (!clientId) {
|
|
@@ -3369,18 +3670,18 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3369
3670
|
// Made with the setting on (providers 0.9.0): nothing to repair on this id.
|
|
3370
3671
|
context.recordScopeId(PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, created.appId);
|
|
3371
3672
|
context.recordScopeId(PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY, hostname);
|
|
3673
|
+
context.recordScopeId(PLATFORM_ARCHON_CLIENT_ORG_SCOPE_KEY, org.id);
|
|
3372
3674
|
await record(PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY, created.clientId);
|
|
3373
3675
|
clientId = created.clientId;
|
|
3374
3676
|
}
|
|
3375
3677
|
// ---- env ------------------------------------------------------------------
|
|
3376
|
-
// People sign in from the owner company's sign-in org. The project, client
|
|
3377
|
-
// and Access app above stay in the `foundry` org, which holds no people.
|
|
3378
|
-
const authOrgId = archonAuthOrganizationId(context.recordedIds()) ?? org.id;
|
|
3379
3678
|
const envs = [
|
|
3380
3679
|
{ key: 'APP_URL', value: `https://${hostname}` },
|
|
3381
3680
|
{ key: 'AUTH_ISSUER', value: issuer },
|
|
3382
3681
|
{ key: 'AUTH_ORGANIZATION_ID', value: authOrgId },
|
|
3682
|
+
{ key: 'CI_ORGANIZATION_ID', value: ciOrgId },
|
|
3383
3683
|
{ key: 'AUTH_CLIENT_ID', value: clientId },
|
|
3684
|
+
...(firstOwnerLink ? [{ key: 'ARCHON_FIRST_OWNER_LINK', value: firstOwnerLink }] : []),
|
|
3384
3685
|
];
|
|
3385
3686
|
// #265: an owner URL or reporting site already on the app is kept as it is.
|
|
3386
3687
|
// The owner URL must name the adopted database's host, or the app's two
|
|
@@ -3389,6 +3690,7 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3389
3690
|
const ownerUrl = archonDatabaseUrl(db.internal_db_url);
|
|
3390
3691
|
if (liveOwnerUrl) {
|
|
3391
3692
|
assertSameDatabaseHost('DATABASE_URL_OWNER', liveOwnerUrl, db.internal_db_url);
|
|
3693
|
+
assertArchonDatabasePath('DATABASE_URL_OWNER', liveOwnerUrl);
|
|
3392
3694
|
}
|
|
3393
3695
|
else {
|
|
3394
3696
|
envs.push({ key: 'DATABASE_URL_OWNER', value: ownerUrl });
|
|
@@ -3453,7 +3755,12 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3453
3755
|
// empty, and `readBrokerApps`/`validateBrokerAppEntry` would refuse it the
|
|
3454
3756
|
// moment this step tried to register it. See `ensureGeneratedEnv`'s own
|
|
3455
3757
|
// doc comment for why no other call site gets this argument.
|
|
3456
|
-
|
|
3758
|
+
// A key minted for another organisation (the company's) is never kept: Archon's new
|
|
3759
|
+
// entry gets a fresh one, so the key the old entry held stops being Archon's.
|
|
3760
|
+
const freshKey = recorded[PLATFORM_ARCHON_APP_KEY_ORG_SCOPE_KEY] !== authOrgId;
|
|
3761
|
+
const appKey = freshKey
|
|
3762
|
+
? randomBytes(32).toString('base64url')
|
|
3763
|
+
: ((await ensureGeneratedEnv(deps.coolify, appId, 'AUTH_APP_KEY', () => randomBytes(32).toString('base64url'), 32)) ?? (await currentEnvValue(deps.coolify, appId, 'AUTH_APP_KEY')));
|
|
3457
3764
|
if (!appKey) {
|
|
3458
3765
|
throw new Error(`platform:archon: app ${appId} lost its AUTH_APP_KEY between two reads`);
|
|
3459
3766
|
}
|
|
@@ -3462,7 +3769,8 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3462
3769
|
// rule above), and hashed as the live value so it never reads as a changed
|
|
3463
3770
|
// env.
|
|
3464
3771
|
const keepLiveFilesEnv = new Set();
|
|
3465
|
-
|
|
3772
|
+
// The owner's own files host, once applied, replaces whatever is live.
|
|
3773
|
+
for (const key of ownerFiles ? [] : ['FILES_URL', 'FILES_AUDIENCE']) {
|
|
3466
3774
|
const live = await currentEnvValue(deps.coolify, appId, key);
|
|
3467
3775
|
if (live) {
|
|
3468
3776
|
keepLiveFilesEnv.add(key);
|
|
@@ -3520,6 +3828,10 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3520
3828
|
throw error;
|
|
3521
3829
|
throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), carried));
|
|
3522
3830
|
}
|
|
3831
|
+
// The fresh key is in Archon's env now: from here a repeat keeps it. Recorded before the broker write,
|
|
3832
|
+
// so a failure there is repaired by writing the same key, not by minting another.
|
|
3833
|
+
if (freshKey)
|
|
3834
|
+
await record(PLATFORM_ARCHON_APP_KEY_ORG_SCOPE_KEY, authOrgId);
|
|
3523
3835
|
// #436 round 2: `bulkUpsertEnvs` only adds or updates, so the
|
|
3524
3836
|
// `FILES_MANAGEMENT_KEY` this step used to write before the fix above
|
|
3525
3837
|
// would otherwise survive on an Archon already deployed with it --
|
|
@@ -3543,7 +3855,10 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3543
3855
|
const staleUptime = !platform.uptime.enabled && stale.key === 'UPTIME_TOKEN';
|
|
3544
3856
|
// People now come from the owner company's store, so a first operator set by an older apply goes.
|
|
3545
3857
|
const staleOperator = stale.key === 'AUTHZ_BOOTSTRAP_OPERATOR';
|
|
3546
|
-
|
|
3858
|
+
// The link is written only while both ids are known; one that is no longer wanted goes.
|
|
3859
|
+
const staleLink = !firstOwnerLink && stale.key === 'ARCHON_FIRST_OWNER_LINK';
|
|
3860
|
+
if ((stale.key === 'FILES_MANAGEMENT_KEY' || staleUptime || staleOperator || staleLink) &&
|
|
3861
|
+
stale.uuid) {
|
|
3547
3862
|
await deps.coolify.deleteEnv(appId, stale.uuid);
|
|
3548
3863
|
staleKeyDeleted = true;
|
|
3549
3864
|
}
|
|
@@ -3556,10 +3871,7 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3556
3871
|
// returns), and this step is unreachable until that resourceId is recorded
|
|
3557
3872
|
// (the guard at the top of this function) -- so a missing id here means a
|
|
3558
3873
|
// caller reached this step some other way.
|
|
3559
|
-
const brokerAppId =
|
|
3560
|
-
if (!brokerAppId) {
|
|
3561
|
-
throw new Error('platform:archon: platform:auth has not recorded its broker app id yet; apply platform:auth again');
|
|
3562
|
-
}
|
|
3874
|
+
const brokerAppId = ownerSignIn.brokerAppId;
|
|
3563
3875
|
const brokerApps = await readBrokerApps(deps.coolify, brokerAppId);
|
|
3564
3876
|
// #211 round 2: merged into the matching entry, not replacing it -- the
|
|
3565
3877
|
// broker's own `App` (`broker/src/apps.ts`) carries fields this step never
|
|
@@ -3573,14 +3885,22 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3573
3885
|
// first -- a previous bug, or a hand-edited AUTH_APPS, can leave more than
|
|
3574
3886
|
// one. The first is kept (and updated below); the rest are dropped, so no
|
|
3575
3887
|
// duplicate carries a stale key a broker client could still present.
|
|
3888
|
+
// No other entry may sign in Archon's organisation: its key would reach Archon's people.
|
|
3889
|
+
const conflicts = archonOrgConflicts(brokerApps, authOrgId);
|
|
3890
|
+
if (conflicts.length > 0) {
|
|
3891
|
+
throw new Error(`platform:archon: broker entry ${conflicts.map((n) => `'${n}'`).join(', ')} signs in Archon's own organisation; no other entry may`);
|
|
3892
|
+
}
|
|
3576
3893
|
const archonMatchIndices = brokerApps
|
|
3577
|
-
.map((a, i) =>
|
|
3578
|
-
a.name === PLATFORM_ARCHON_ZITADEL_APP
|
|
3579
|
-
? i
|
|
3580
|
-
: -1)
|
|
3894
|
+
.map((a, i) => a.organizationId === authOrgId && a.name === PLATFORM_ARCHON_ZITADEL_APP ? i : -1)
|
|
3581
3895
|
.filter((i) => i >= 0);
|
|
3582
3896
|
const archonEntryIndex = archonMatchIndices[0] ?? -1;
|
|
3583
|
-
|
|
3897
|
+
// Archon's entry in an organisation it used to sign in from (the company's, before Archon had its
|
|
3898
|
+
// own) goes, key and all. An entry of that name in any other organisation is not Archon's to touch.
|
|
3899
|
+
const oldOrgs = archonOldOrgIds(recorded, ciOrgId, authOrgId);
|
|
3900
|
+
const oldEntryIndices = new Set(brokerApps
|
|
3901
|
+
.map((a, i) => a.name === PLATFORM_ARCHON_ZITADEL_APP && oldOrgs.has(a.organizationId) ? i : -1)
|
|
3902
|
+
.filter((i) => i >= 0));
|
|
3903
|
+
const duplicateIndices = new Set([...archonMatchIndices.slice(1), ...oldEntryIndices]);
|
|
3584
3904
|
const archonEntry = {
|
|
3585
3905
|
...(archonEntryIndex >= 0 ? brokerApps[archonEntryIndex] : {}),
|
|
3586
3906
|
organizationId: authOrgId,
|
|
@@ -3595,11 +3915,10 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3595
3915
|
// the wrong platform or not at all.
|
|
3596
3916
|
from: `Company Included <noreply@${platform.apex}>`,
|
|
3597
3917
|
};
|
|
3598
|
-
const
|
|
3599
|
-
?
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
: [...brokerApps, archonEntry];
|
|
3918
|
+
const keptBrokerApps = brokerApps
|
|
3919
|
+
.map((a, i) => (i === archonEntryIndex ? archonEntry : a))
|
|
3920
|
+
.filter((_, i) => !duplicateIndices.has(i));
|
|
3921
|
+
const desiredBrokerApps = archonEntryIndex >= 0 ? keptBrokerApps : [...keptBrokerApps, archonEntry];
|
|
3603
3922
|
const desiredAuthApps = [{ key: 'AUTH_APPS', value: JSON.stringify(desiredBrokerApps) }];
|
|
3604
3923
|
// Rebuilt and compared every run (not gated on a freshly minted key), so a
|
|
3605
3924
|
// clientId or hostname change reaches the broker too, and so a previous
|
|
@@ -3634,6 +3953,23 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3634
3953
|
await deps.coolify.deploy(brokerAppId);
|
|
3635
3954
|
await record(PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, brokerDeployRecordValue(brokerAppId, desiredAuthAppsRaw));
|
|
3636
3955
|
}
|
|
3956
|
+
// ---- the old sign-in client ------------------------------------------------
|
|
3957
|
+
// The broker no longer knows it, and Archon's env names the new one: delete the old client, so
|
|
3958
|
+
// no account of the company's organisation can still sign in to it. A repeat deletes nothing twice.
|
|
3959
|
+
const retiredClient = context.recordedIds()[PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY];
|
|
3960
|
+
if (retiredClient) {
|
|
3961
|
+
const [oldOrgId, oldProjectId, oldAppId] = retiredClient.split('|');
|
|
3962
|
+
if (oldOrgId && oldProjectId && oldAppId) {
|
|
3963
|
+
try {
|
|
3964
|
+
await zitadel.deleteApp(oldOrgId, oldProjectId, oldAppId);
|
|
3965
|
+
}
|
|
3966
|
+
catch (error) {
|
|
3967
|
+
if (!isNotFoundError(error))
|
|
3968
|
+
throw error;
|
|
3969
|
+
}
|
|
3970
|
+
}
|
|
3971
|
+
await record(PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY, '');
|
|
3972
|
+
}
|
|
3637
3973
|
// ---- Cloudflare Access, before the hostname resolves ----------------------
|
|
3638
3974
|
const tunnels = await platformTunnels(deps, context.recordedIds(), 'platform:archon');
|
|
3639
3975
|
const access = await platformAccess(deps, context.recordedIds(), 'platform:archon');
|
|
@@ -3641,7 +3977,7 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3641
3977
|
const uptimeAccessTokenId = platform.uptime.enabled
|
|
3642
3978
|
? recorded[PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY]
|
|
3643
3979
|
: undefined;
|
|
3644
|
-
const accessAppId = await ensureArchonAccess(access, hostname,
|
|
3980
|
+
const { id: accessAppId, pinId } = await ensureArchonAccess(access, hostname, accessEmails, uptimeAccessTokenId, async () => {
|
|
3645
3981
|
// The Access app is about to be rebuilt: answer 404 for every hostname
|
|
3646
3982
|
// meanwhile, so it is never served without the gate. The marker is
|
|
3647
3983
|
// persisted first: a failure before the real ingress is written back
|
|
@@ -3657,6 +3993,10 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3657
3993
|
if (recorded[PLATFORM_ARCHON_ACCESS_SCOPE_KEY] !== accessAppId) {
|
|
3658
3994
|
await record(PLATFORM_ARCHON_ACCESS_SCOPE_KEY, accessAppId);
|
|
3659
3995
|
}
|
|
3996
|
+
if (recorded[PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY] !== pinId) {
|
|
3997
|
+
await record(PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY, pinId);
|
|
3998
|
+
}
|
|
3999
|
+
await ensureArchonPublicAccess(access, hostname, context.recordedIds(), record);
|
|
3660
4000
|
// ---- deploy ---------------------------------------------------------------
|
|
3661
4001
|
// #252: phase 1 of the shared deploy-health helper only -- Coolify's own
|
|
3662
4002
|
// deployment record reaching `'finished'`, which is enough on its own to
|
|
@@ -3709,7 +4049,8 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3709
4049
|
// retry intends is identical to what a successful one intends, so
|
|
3710
4050
|
// leaving the old confirmation in place would read as still correct.
|
|
3711
4051
|
await record(PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, '');
|
|
3712
|
-
await waitForDeploymentFinished(deps.coolify, appId, runtime, 'platform:archon', `archon stays at ${appliedGitRef ?? 'no recorded ref'} on ${recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] ?? 'no recorded hostname'}`);
|
|
4052
|
+
const built = await waitForDeploymentFinished(deps.coolify, appId, runtime, 'platform:archon', `archon stays at ${appliedGitRef ?? 'no recorded ref'} on ${recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] ?? 'no recorded hostname'}`);
|
|
4053
|
+
await recordBuiltCommit(deps.coolify, { appId: appId, deploymentUuid: built.deploymentUuid, name: 'archon' }, record);
|
|
3713
4054
|
}
|
|
3714
4055
|
// ---- tunnel and DNS ---------------------------------------------------------
|
|
3715
4056
|
const existingTunnel = await tunnels.findTunnelByName(PLATFORM_ARCHON_TUNNEL_NAME);
|
|
@@ -3815,6 +4156,10 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3815
4156
|
// it.
|
|
3816
4157
|
await record(PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, brokerAppId);
|
|
3817
4158
|
await record(PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY, authOrgId);
|
|
4159
|
+
await record(PLATFORM_ARCHON_APPLIED_CI_ORG_SCOPE_KEY, ciOrgId);
|
|
4160
|
+
await record(PLATFORM_ARCHON_APPLIED_FIRST_OWNER_LINK_SCOPE_KEY, firstOwnerLink);
|
|
4161
|
+
await record(PLATFORM_ARCHON_APPLIED_AUTH_HOSTNAME_SCOPE_KEY, ownerSignIn.hostname);
|
|
4162
|
+
await record(PLATFORM_ARCHON_APPLIED_FILES_SCOPE_KEY, archonFilesMarker(ownerFiles));
|
|
3818
4163
|
// The host runs inside this app: every marker is recorded above, so the
|
|
3819
4164
|
// deploy goes last and is not waited for -- the old container stops during
|
|
3820
4165
|
// it, and the response, the lease and the audit row must be done first.
|
|
@@ -4126,6 +4471,9 @@ const PLATFORM_ARCHON_UPTIME_POLICY_NAME = 'foundry: uptime service token';
|
|
|
4126
4471
|
* is added in place, one for another token rebuilds the app.
|
|
4127
4472
|
*/
|
|
4128
4473
|
async function ensureArchonAccess(cf, domain, emails, uptimeTokenId, closeGate) {
|
|
4474
|
+
// The app accepts the one-time PIN and nothing else, so a person is mailed a code
|
|
4475
|
+
// instead of being offered the company's own sign-in provider.
|
|
4476
|
+
const { id: pinId } = await cf.ensureOneTimePinProvider();
|
|
4129
4477
|
const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
|
|
4130
4478
|
const foreign = onDomain.find((a) => a.name !== PLATFORM_ARCHON_ACCESS_APP_NAME);
|
|
4131
4479
|
if (foreign) {
|
|
@@ -4136,6 +4484,8 @@ async function ensureArchonAccess(cf, domain, emails, uptimeTokenId, closeGate)
|
|
|
4136
4484
|
name: PLATFORM_ARCHON_ACCESS_APP_NAME,
|
|
4137
4485
|
domain,
|
|
4138
4486
|
type: 'self_hosted',
|
|
4487
|
+
allowedIdps: [pinId],
|
|
4488
|
+
autoRedirectToIdentity: true,
|
|
4139
4489
|
});
|
|
4140
4490
|
await cf.createAccessPolicy(created.id, archonAccessPolicy(emails));
|
|
4141
4491
|
if (uptimeTokenId) {
|
|
@@ -4145,7 +4495,7 @@ async function ensureArchonAccess(cf, domain, emails, uptimeTokenId, closeGate)
|
|
|
4145
4495
|
};
|
|
4146
4496
|
const existing = onDomain[0];
|
|
4147
4497
|
if (!existing)
|
|
4148
|
-
return (await create()).id;
|
|
4498
|
+
return { id: (await create()).id, pinId };
|
|
4149
4499
|
const policies = await cf.listAccessPolicies(existing.id);
|
|
4150
4500
|
const other = policies.find((p) => p.name !== ACCESS_POLICY_NAME && p.name !== PLATFORM_ARCHON_UPTIME_POLICY_NAME);
|
|
4151
4501
|
if (other) {
|
|
@@ -4154,19 +4504,52 @@ async function ensureArchonAccess(cf, domain, emails, uptimeTokenId, closeGate)
|
|
|
4154
4504
|
const emailPolicies = policies.filter((p) => p.name === ACCESS_POLICY_NAME);
|
|
4155
4505
|
const tokenPolicies = policies.filter((p) => p.name === PLATFORM_ARCHON_UPTIME_POLICY_NAME);
|
|
4156
4506
|
const staleToken = tokenPolicies.some((p) => !uptimePolicyIntact(p, uptimeTokenId));
|
|
4157
|
-
if (emailPolicies.every((p) => sameEmails(p.include, emails)) &&
|
|
4507
|
+
if (emailPolicies.every((p) => sameEmails(p.include, emails)) &&
|
|
4508
|
+
!staleToken &&
|
|
4509
|
+
accessAppPinned(existing, pinId)) {
|
|
4158
4510
|
if (emailPolicies.length === 0) {
|
|
4159
4511
|
await cf.createAccessPolicy(existing.id, archonAccessPolicy(emails));
|
|
4160
4512
|
}
|
|
4161
4513
|
if (uptimeTokenId && tokenPolicies.length === 0) {
|
|
4162
4514
|
await cf.createAccessPolicy(existing.id, archonUptimePolicy(uptimeTokenId));
|
|
4163
4515
|
}
|
|
4164
|
-
return existing.id;
|
|
4516
|
+
return { id: existing.id, pinId };
|
|
4165
4517
|
}
|
|
4166
|
-
// No call updates an app's policy
|
|
4518
|
+
// No call updates an app's policy or its providers (edge keeps an existing app's
|
|
4519
|
+
// `allowedIdps`): rebuild the app, hostname closed meanwhile.
|
|
4167
4520
|
await closeGate();
|
|
4168
4521
|
await cf.deleteAccessApp(existing.id);
|
|
4169
|
-
return (await create()).id;
|
|
4522
|
+
return { id: (await create()).id, pinId };
|
|
4523
|
+
}
|
|
4524
|
+
/** Whether the Access app accepts the one-time PIN provider alone and sends a visitor straight to it. */
|
|
4525
|
+
function accessAppPinned(app, pinId) {
|
|
4526
|
+
return (Array.isArray(app.allowedIdps) &&
|
|
4527
|
+
app.allowedIdps.length === 1 &&
|
|
4528
|
+
app.allowedIdps[0] === pinId &&
|
|
4529
|
+
app.autoRedirectToIdentity === true);
|
|
4530
|
+
}
|
|
4531
|
+
/**
|
|
4532
|
+
* Who Archon's Access app lets through. Each person is let in on the personal
|
|
4533
|
+
* address their invite was mailed to, never on a work address: the work
|
|
4534
|
+
* mailbox is on the platform's own mail, which the platform's own sign-in
|
|
4535
|
+
* reaches. So the owner company's first owner's work addresses leave the host's
|
|
4536
|
+
* list and their personal address joins it; every other address stays as the
|
|
4537
|
+
* host gave it. Case-insensitive, de-duplicated, order kept.
|
|
4538
|
+
*/
|
|
4539
|
+
export function archonAccessEmails(hostEmails, ownerCompany) {
|
|
4540
|
+
const first = ownerCompany?.owners[0];
|
|
4541
|
+
// The first owner's work address: the one their Archon invite is made for.
|
|
4542
|
+
const firstWork = ownerCompany && first ? ownerMailboxes(ownerCompany)[0]?.address : null;
|
|
4543
|
+
const work = new Set(firstWork ? [firstWork.toLowerCase()] : []);
|
|
4544
|
+
const out = [];
|
|
4545
|
+
const seen = new Set();
|
|
4546
|
+
for (const email of [...hostEmails.filter((e) => !work.has(e.toLowerCase())), first?.email]) {
|
|
4547
|
+
if (!email || seen.has(email.toLowerCase()))
|
|
4548
|
+
continue;
|
|
4549
|
+
seen.add(email.toLowerCase());
|
|
4550
|
+
out.push(email);
|
|
4551
|
+
}
|
|
4552
|
+
return out;
|
|
4170
4553
|
}
|
|
4171
4554
|
function archonAccessPolicy(emails) {
|
|
4172
4555
|
return {
|
|
@@ -4224,6 +4607,10 @@ async function archonAccessDrifted(deps, recorded, domain, emails, uptimeTokenId
|
|
|
4224
4607
|
const app = (await cf.listAccessApps()).find((a) => a.domain === domain && a.name === PLATFORM_ARCHON_ACCESS_APP_NAME);
|
|
4225
4608
|
if (!app)
|
|
4226
4609
|
return true;
|
|
4610
|
+
// Read against the provider id the app was last made with: a plan asks edge to make nothing.
|
|
4611
|
+
const pinId = recorded[PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY];
|
|
4612
|
+
if (!pinId || !accessAppPinned(app, pinId))
|
|
4613
|
+
return true;
|
|
4227
4614
|
const policies = await cf.listAccessPolicies(app.id);
|
|
4228
4615
|
const emailPolicies = policies.filter((p) => p.name === ACCESS_POLICY_NAME);
|
|
4229
4616
|
const tokenPolicies = policies.filter((p) => p.name === PLATFORM_ARCHON_UPTIME_POLICY_NAME);
|
|
@@ -4234,7 +4621,15 @@ async function archonAccessDrifted(deps, recorded, domain, emails, uptimeTokenId
|
|
|
4234
4621
|
}
|
|
4235
4622
|
if (!tokenPolicies.every((p) => uptimePolicyIntact(p, uptimeTokenId)))
|
|
4236
4623
|
return true;
|
|
4237
|
-
|
|
4624
|
+
if (uptimeTokenId !== undefined && tokenPolicies.length === 0)
|
|
4625
|
+
return true;
|
|
4626
|
+
const apps = await cf.listAccessApps();
|
|
4627
|
+
for (const { name, path } of PLATFORM_ARCHON_PUBLIC_PATHS) {
|
|
4628
|
+
const bypass = apps.find((a) => a.domain === `${domain}${path}` && a.name === archonPublicAppName(name));
|
|
4629
|
+
if (!bypass || !onlyBypassEveryone(await cf.listAccessPolicies(bypass.id)))
|
|
4630
|
+
return true;
|
|
4631
|
+
}
|
|
4632
|
+
return false;
|
|
4238
4633
|
}
|
|
4239
4634
|
catch {
|
|
4240
4635
|
return false;
|
|
@@ -4254,19 +4649,43 @@ async function archonAccessDrifted(deps, recorded, domain, emails, uptimeTokenId
|
|
|
4254
4649
|
* recorded) is not drift either -- `planArchon`'s other checks already cover
|
|
4255
4650
|
* a step that has never applied.
|
|
4256
4651
|
*/
|
|
4257
|
-
async function archonBrokerRegistrationDrifted(coolify,
|
|
4258
|
-
|
|
4259
|
-
const orgId = archonAuthOrganizationId(recordedIds);
|
|
4260
|
-
if (!brokerAppId || !orgId)
|
|
4652
|
+
async function archonBrokerRegistrationDrifted(coolify, signIn, archonOrgId, recorded = {}) {
|
|
4653
|
+
if (!signIn || !archonOrgId)
|
|
4261
4654
|
return false;
|
|
4262
4655
|
try {
|
|
4263
|
-
const apps = await readBrokerApps(coolify, brokerAppId);
|
|
4264
|
-
|
|
4656
|
+
const apps = await readBrokerApps(coolify, signIn.brokerAppId);
|
|
4657
|
+
const own = apps.some((a) => a.organizationId === archonOrgId && a.name === PLATFORM_ARCHON_ZITADEL_APP);
|
|
4658
|
+
// The company's old entry for Archon must be gone, and no other entry may sign in Archon's org.
|
|
4659
|
+
const oldOrgs = archonOldOrgIds(recorded, signIn.organisationId, archonOrgId);
|
|
4660
|
+
const stale = apps.some((a) => a.name === PLATFORM_ARCHON_ZITADEL_APP && oldOrgs.has(a.organizationId));
|
|
4661
|
+
return !own || stale || archonOrgConflicts(apps, archonOrgId).length > 0;
|
|
4265
4662
|
}
|
|
4266
4663
|
catch {
|
|
4267
4664
|
return false;
|
|
4268
4665
|
}
|
|
4269
4666
|
}
|
|
4667
|
+
/** The organisations Archon signed in from before its own: the company's, and the one its last apply recorded. */
|
|
4668
|
+
function archonOldOrgIds(recorded, ciOrgId, archonOrgId) {
|
|
4669
|
+
const old = new Set([ciOrgId]);
|
|
4670
|
+
const applied = recorded[PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY];
|
|
4671
|
+
if (applied)
|
|
4672
|
+
old.add(applied);
|
|
4673
|
+
if (archonOrgId)
|
|
4674
|
+
old.delete(archonOrgId);
|
|
4675
|
+
return old;
|
|
4676
|
+
}
|
|
4677
|
+
/**
|
|
4678
|
+
* The names of the broker entries that sign in Archon's organisation without
|
|
4679
|
+
* being Archon's own. There must be none: a key of another app would then
|
|
4680
|
+
* reach Archon's people (passkeys, password reset, deactivation).
|
|
4681
|
+
*/
|
|
4682
|
+
export function archonOrgConflicts(entries, archonOrgId) {
|
|
4683
|
+
if (!archonOrgId)
|
|
4684
|
+
return [];
|
|
4685
|
+
return entries
|
|
4686
|
+
.filter((e) => e.organizationId === archonOrgId && e.name !== PLATFORM_ARCHON_ZITADEL_APP)
|
|
4687
|
+
.map((e) => e.name.slice(0, 100));
|
|
4688
|
+
}
|
|
4270
4689
|
/** The decoded password of a connection URL, or `undefined` when it has none or cannot be parsed. */
|
|
4271
4690
|
function passwordOf(url) {
|
|
4272
4691
|
try {
|
|
@@ -4399,17 +4818,31 @@ async function archonAuthzDatabaseDrifted(coolify, appId, owner) {
|
|
|
4399
4818
|
}
|
|
4400
4819
|
}
|
|
4401
4820
|
/**
|
|
4402
|
-
* Whether Archon's live
|
|
4403
|
-
*
|
|
4404
|
-
*
|
|
4405
|
-
*
|
|
4821
|
+
* Whether one of Archon's live sign-in envs is not what it should be:
|
|
4822
|
+
* `AUTH_ORGANIZATION_ID` (Archon's own organisation), `CI_ORGANIZATION_ID` (the
|
|
4823
|
+
* company's) and `ARCHON_FIRST_OWNER_LINK` (absent when no link is wanted).
|
|
4824
|
+
* A name with no expected value is not checked, except the link. Without this
|
|
4825
|
+
* nothing replans Archon when an org changes behind its back. A failed read is
|
|
4826
|
+
* not drift.
|
|
4406
4827
|
*/
|
|
4407
|
-
async function archonAuthOrgDrifted(coolify, appId,
|
|
4408
|
-
|
|
4409
|
-
if (!appId || !expected)
|
|
4828
|
+
async function archonAuthOrgDrifted(coolify, appId, expected) {
|
|
4829
|
+
if (!appId)
|
|
4410
4830
|
return false;
|
|
4411
4831
|
try {
|
|
4412
|
-
|
|
4832
|
+
for (const [key, want] of Object.entries(expected)) {
|
|
4833
|
+
// A value expected as absent (an empty link) must be absent too; the key itself is never named in a value.
|
|
4834
|
+
if (key === 'ARCHON_FIRST_OWNER_LINK') {
|
|
4835
|
+
if (((await currentEnvValue(coolify, appId, key)) || undefined) !== (want || undefined)) {
|
|
4836
|
+
return true;
|
|
4837
|
+
}
|
|
4838
|
+
continue;
|
|
4839
|
+
}
|
|
4840
|
+
if (!want)
|
|
4841
|
+
continue;
|
|
4842
|
+
if ((await currentEnvValue(coolify, appId, key)) !== want)
|
|
4843
|
+
return true;
|
|
4844
|
+
}
|
|
4845
|
+
return false;
|
|
4413
4846
|
}
|
|
4414
4847
|
catch {
|
|
4415
4848
|
return false;
|
|
@@ -4492,13 +4925,15 @@ async function archonRollingUpdateDrifted(coolify, appId) {
|
|
|
4492
4925
|
* held-key ref, never a value. Also read at plan time, so the text must not
|
|
4493
4926
|
* vary between calls.
|
|
4494
4927
|
*/
|
|
4495
|
-
async function collectArchonServiceInputs(platform, deps, recorded) {
|
|
4928
|
+
async function collectArchonServiceInputs(platform, deps, recorded, ownerFiles = null) {
|
|
4496
4929
|
const block = (why) => ({
|
|
4497
4930
|
blocked: `Archon is blocked, not planned: ${why}`,
|
|
4498
4931
|
});
|
|
4499
4932
|
const envs = [];
|
|
4500
4933
|
const heldByRef = new Map();
|
|
4501
|
-
|
|
4934
|
+
// Files is the owner company's own host, not a platform service: its env is
|
|
4935
|
+
// added once that step has applied, and Archon starts without it until then.
|
|
4936
|
+
const services = ['ai', 'integrations', 'jobs'].filter((s) => platform[s].enabled);
|
|
4502
4937
|
if (services.length > 0 && !deps.heldKeys) {
|
|
4503
4938
|
return block(`the host built the platform executor without a held-key reader, and ${services.join(', ')} ${services.length === 1 ? 'is' : 'are'} enabled`);
|
|
4504
4939
|
}
|
|
@@ -4551,31 +4986,7 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
|
|
|
4551
4986
|
}
|
|
4552
4987
|
const held = (ref) => heldByRef.get(ref);
|
|
4553
4988
|
for (const service of services) {
|
|
4554
|
-
if (service === '
|
|
4555
|
-
envs.push(
|
|
4556
|
-
// #501: Files is private and has no DNS, so FILES_URL is the recorded
|
|
4557
|
-
// internal address; FILES_AUDIENCE is the exact HTTPS origin the
|
|
4558
|
-
// signature is made for, equal to Files' own `APP_URL`.
|
|
4559
|
-
{ key: 'FILES_URL', value: recorded[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY] }, { key: 'FILES_AUDIENCE', value: `https://${platform.files.hostname}` },
|
|
4560
|
-
// #436: Company Included's own Files organisation, provisioned by
|
|
4561
|
-
// the ordinary per-org `planFilesOrganisation` step under its own
|
|
4562
|
-
// org id -- `executor.ts`'s `filesOrganisation` calls
|
|
4563
|
-
// `provisionOrganisation({ organisationId: org.id, ... })`, so
|
|
4564
|
-
// `platform.ownerOrgId` is already that organisation's id, with no
|
|
4565
|
-
// separate platform-level mint needed.
|
|
4566
|
-
{ key: 'FILES_ORGANISATION_ID', value: platform.ownerOrgId },
|
|
4567
|
-
// #436: the signed-assertion identity `filesSigner()` reads
|
|
4568
|
-
// (`archon`'s `src/lib/authz/files-management.ts`), not the bearer
|
|
4569
|
-
// token `FILES_MANAGEMENT_KEY` used to name here -- that name made
|
|
4570
|
-
// Archon take the issued-key branch of `filesManagementFetch`,
|
|
4571
|
-
// which then failed on the `FILES_MANAGEMENT_SCOPE` this step never
|
|
4572
|
-
// set, surfacing as "Files is not configured" (foundry#436).
|
|
4573
|
-
{ key: 'FILES_MANAGEMENT_ISSUER', value: FILES_MANAGEMENT_ISSUER }, { key: 'FILES_MANAGEMENT_KEY_ID', value: FILES_MANAGEMENT_KEY_ID }, {
|
|
4574
|
-
key: 'FILES_MANAGEMENT_PRIVATE_KEY',
|
|
4575
|
-
value: held(ARCHON_SERVICE_INPUTS.files.refs[0]),
|
|
4576
|
-
});
|
|
4577
|
-
}
|
|
4578
|
-
else if (service === 'ai') {
|
|
4989
|
+
if (service === 'ai') {
|
|
4579
4990
|
envs.push({
|
|
4580
4991
|
key: 'AI_URL',
|
|
4581
4992
|
value: recorded[PLATFORM_AI_INTERNAL_URL_SCOPE_KEY],
|
|
@@ -4611,6 +5022,22 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
|
|
|
4611
5022
|
{ key: 'JOBS_URL', value: recorded[JOBS_INTERNAL_URL_SCOPE_KEY] }, { key: 'JOBS_ADMIN_TOKEN', value: held(JOBS_ADMIN_TOKEN_REF) });
|
|
4612
5023
|
}
|
|
4613
5024
|
}
|
|
5025
|
+
if (ownerFiles) {
|
|
5026
|
+
let key = null;
|
|
5027
|
+
try {
|
|
5028
|
+
key = (await deps.heldKeys?.resolve(ownerFiles.managementKeyRef)) ?? null;
|
|
5029
|
+
}
|
|
5030
|
+
catch {
|
|
5031
|
+
// The cause is dropped in case it echoes a secret.
|
|
5032
|
+
return block(`the held key ${ownerFiles.managementKeyRef} could not be read`);
|
|
5033
|
+
}
|
|
5034
|
+
if (!key)
|
|
5035
|
+
return block(`the held key ${ownerFiles.managementKeyRef} is not held yet`);
|
|
5036
|
+
envs.push(
|
|
5037
|
+
// Files is private and has no DNS, so FILES_URL is the recorded internal
|
|
5038
|
+
// address; FILES_AUDIENCE is the exact HTTPS origin the signature is for.
|
|
5039
|
+
{ key: 'FILES_URL', value: ownerFiles.internalUrl }, { key: 'FILES_AUDIENCE', value: ownerFiles.audience }, { key: 'FILES_ORGANISATION_ID', value: ownerFiles.organisationId }, { key: 'FILES_MANAGEMENT_ISSUER', value: FILES_MANAGEMENT_ISSUER }, { key: 'FILES_MANAGEMENT_KEY_ID', value: FILES_MANAGEMENT_KEY_ID }, { key: 'FILES_MANAGEMENT_PRIVATE_KEY', value: key });
|
|
5040
|
+
}
|
|
4614
5041
|
return { envs };
|
|
4615
5042
|
}
|
|
4616
5043
|
/**
|
|
@@ -4643,12 +5070,30 @@ function assertSameDatabaseHost(key, liveUrl, dbInternalUrl) {
|
|
|
4643
5070
|
throw new Error(`platform:archon: ${key} on the app points at host ${liveHost ?? '(unparseable)'}, but the adopted database ${PLATFORM_ARCHON_DB_NAME} is at host ${dbHost ?? '(unparseable)'}; refusing to leave the app's database URLs on different databases`);
|
|
4644
5071
|
}
|
|
4645
5072
|
}
|
|
5073
|
+
/**
|
|
5074
|
+
* Throws when `liveUrl`, kept as it is on the app, names another database than
|
|
5075
|
+
* {@link ARCHON_POSTGRES_DB}: a hand-set URL on the wrong path, or a platform
|
|
5076
|
+
* still on the retired `archon_v2`. Names the two databases only, never the URL.
|
|
5077
|
+
*/
|
|
5078
|
+
function assertArchonDatabasePath(key, liveUrl) {
|
|
5079
|
+
let database;
|
|
5080
|
+
try {
|
|
5081
|
+
database = decodeURIComponent(new URL(liveUrl).pathname.slice(1));
|
|
5082
|
+
}
|
|
5083
|
+
catch {
|
|
5084
|
+
database = null;
|
|
5085
|
+
}
|
|
5086
|
+
if (database !== ARCHON_POSTGRES_DB) {
|
|
5087
|
+
throw new Error(`platform:archon: ${key} on the app names database ${database ?? '(unparseable)'}, but Archon runs on database ${ARCHON_POSTGRES_DB}; refusing to keep it. Point ${key} at ${ARCHON_POSTGRES_DB} in Coolify and apply again`);
|
|
5088
|
+
}
|
|
5089
|
+
}
|
|
4646
5090
|
async function ensureArchonRuntimeUrl(coolify, appId, dbInternalUrl) {
|
|
4647
5091
|
const envs = await coolify.listEnvs(appId);
|
|
4648
5092
|
const live = envs.find((e) => e.key === 'DATABASE_URL' && !e.is_preview);
|
|
4649
5093
|
if (live) {
|
|
4650
5094
|
// #265: kept as it is, but it must name the adopted database's host.
|
|
4651
5095
|
assertSameDatabaseHost('DATABASE_URL', live.value, dbInternalUrl);
|
|
5096
|
+
assertArchonDatabasePath('DATABASE_URL', live.value);
|
|
4652
5097
|
return null;
|
|
4653
5098
|
}
|
|
4654
5099
|
let host;
|
|
@@ -5209,7 +5654,8 @@ async function provisionPlatformAi(context, deps) {
|
|
|
5209
5654
|
// nothing public is wired to either app.
|
|
5210
5655
|
const internalUrl = `http://${PLATFORM_AI_RUNNER.internalName}:${PLATFORM_AI_PORT}`;
|
|
5211
5656
|
if (deployRunner) {
|
|
5212
|
-
await waitForNewDeployment(coolify, runner.appId, { kind: 'ready', url: `${internalUrl}/api/health` }, runtime, sleep, 'platform:ai', `the runner stays at ${recorded[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] ?? 'no recorded ref'}`);
|
|
5657
|
+
const built = await waitForNewDeployment(coolify, runner.appId, { kind: 'ready', url: `${internalUrl}/api/health` }, runtime, sleep, 'platform:ai', `the runner stays at ${recorded[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] ?? 'no recorded ref'}`);
|
|
5658
|
+
await recordBuiltCommit(coolify, { appId: runner.appId, deploymentUuid: built.deploymentUuid, name: 'ai' }, record);
|
|
5213
5659
|
}
|
|
5214
5660
|
if (deployWorker) {
|
|
5215
5661
|
await waitForNewDeployment(coolify, worker.appId, { kind: 'deployment-only' }, runtime, sleep, 'platform:ai', `the worker stays at ${recorded[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] ?? 'no recorded ref'}`);
|
|
@@ -5295,6 +5741,54 @@ async function provisionPlatformAi(context, deps) {
|
|
|
5295
5741
|
}
|
|
5296
5742
|
await record(PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, credentialId);
|
|
5297
5743
|
}
|
|
5744
|
+
// ---- the public route ------------------------------------------------------
|
|
5745
|
+
// A company's entries run on their own network and cannot dial `platform-ai`, so they call
|
|
5746
|
+
// `https://<hostname>`. The route is the heartbeat server's (`platform:hc`): a tunnel that dials the
|
|
5747
|
+
// runner by its internal name, a connector app, and a proxied CNAME. No Coolify `domains`, no Access
|
|
5748
|
+
// app: ai authenticates every caller by its bearer key, and a gate in front would stop that.
|
|
5749
|
+
const tunnels = await platformTunnels(deps, context.recordedIds(), 'platform:ai');
|
|
5750
|
+
const tunnel = (await tunnels.findTunnelByName(AI_TUNNEL_NAME)) ??
|
|
5751
|
+
(await tunnels.createTunnel(AI_TUNNEL_NAME));
|
|
5752
|
+
// Recorded last, once the DNS record is written: planAi and aiUrl read it as "the route is complete",
|
|
5753
|
+
// so a failure below is planned again and no entry is given a dead AI_URL. The tunnel is found by name on the rerun.
|
|
5754
|
+
await tunnels.putTunnelConfig(tunnel.id, {
|
|
5755
|
+
ingress: [
|
|
5756
|
+
{ hostname, path: AI_ENTRY_PATH_PATTERN, service: internalUrl },
|
|
5757
|
+
{ service: 'http_status:404' },
|
|
5758
|
+
],
|
|
5759
|
+
});
|
|
5760
|
+
const server = await resolvePlatformServer(coolify);
|
|
5761
|
+
await ensureCloudflaredConnector({
|
|
5762
|
+
coolify,
|
|
5763
|
+
tunnels,
|
|
5764
|
+
tunnelId: tunnel.id,
|
|
5765
|
+
appName: AI_CLOUDFLARED_APP_NAME,
|
|
5766
|
+
projectUuid,
|
|
5767
|
+
environment,
|
|
5768
|
+
resolveServer: async () => server,
|
|
5769
|
+
recordedConnectorAppId: context.recordedIds()[PLATFORM_AI_CONNECTOR_APP_SCOPE_KEY] ?? null,
|
|
5770
|
+
recordAppId: (connectorAppId) => record(PLATFORM_AI_CONNECTOR_APP_SCOPE_KEY, connectorAppId),
|
|
5771
|
+
});
|
|
5772
|
+
const dns = await platformDns(deps, context.recordedIds(), 'platform:ai');
|
|
5773
|
+
const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
|
|
5774
|
+
const [dnsRecord] = await dns.findDnsRecord(platform.apex, hostname, 'CNAME');
|
|
5775
|
+
if (!dnsRecord) {
|
|
5776
|
+
await dns.createDnsRecord(platform.apex, {
|
|
5777
|
+
type: 'CNAME',
|
|
5778
|
+
name: hostname,
|
|
5779
|
+
content: dnsTarget,
|
|
5780
|
+
proxied: true,
|
|
5781
|
+
});
|
|
5782
|
+
}
|
|
5783
|
+
else if (dnsRecord.content !== dnsTarget) {
|
|
5784
|
+
await dns.updateDnsRecord(platform.apex, dnsRecord.id, {
|
|
5785
|
+
type: 'CNAME',
|
|
5786
|
+
name: hostname,
|
|
5787
|
+
content: dnsTarget,
|
|
5788
|
+
proxied: true,
|
|
5789
|
+
});
|
|
5790
|
+
}
|
|
5791
|
+
await record(PLATFORM_AI_TUNNEL_SCOPE_KEY, tunnel.id);
|
|
5298
5792
|
return { resourceId: runner.appId };
|
|
5299
5793
|
}
|
|
5300
5794
|
/**
|
|
@@ -6252,7 +6746,8 @@ export async function provisionFilesHost(scope, deps, target, input) {
|
|
|
6252
6746
|
// record first is still what rules out the old container staying healthy
|
|
6253
6747
|
// through a failed deploy.
|
|
6254
6748
|
const internalUrl = `http://${target.internal.files}:${FILES_PORT}`;
|
|
6255
|
-
await waitForNewDeployment(deps.coolify, runnerApp.appId, { kind: 'ready', url: `${internalUrl}${FILES_HEALTH_CHECK_PATH}` }, runtime, sleep, label, `the runner stays at ${recorded[PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
|
|
6749
|
+
const built = await waitForNewDeployment(deps.coolify, runnerApp.appId, { kind: 'ready', url: `${internalUrl}${FILES_HEALTH_CHECK_PATH}` }, runtime, sleep, label, `the runner stays at ${recorded[PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
|
|
6750
|
+
await recordBuiltCommit(deps.coolify, { appId: runnerApp.appId, deploymentUuid: built.deploymentUuid, name: 'files' }, record);
|
|
6256
6751
|
await record(PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, internalUrl);
|
|
6257
6752
|
// Last: a run that failed anywhere above is planned again.
|
|
6258
6753
|
scope.recordScopeId(PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, input.hostname);
|
|
@@ -6578,7 +7073,6 @@ const PLATFORM_ACCESS_PROVIDER_NAME = 'foundry';
|
|
|
6578
7073
|
* again.
|
|
6579
7074
|
*/
|
|
6580
7075
|
async function provisionPlatformAccessProvider(context, deps) {
|
|
6581
|
-
const { platform } = context;
|
|
6582
7076
|
const recorded = context.recordedIds();
|
|
6583
7077
|
const sleep = deps.runtime?.sleep ?? defaultSleep;
|
|
6584
7078
|
const attempts = deps.runtime?.deliverAttempts ?? 3;
|
|
@@ -6597,10 +7091,19 @@ async function provisionPlatformAccessProvider(context, deps) {
|
|
|
6597
7091
|
catch {
|
|
6598
7092
|
throw new Error(`${label}: edge could not confirm that Cloudflare Access is ready`);
|
|
6599
7093
|
}
|
|
6600
|
-
|
|
6601
|
-
const
|
|
6602
|
-
|
|
7094
|
+
// The client lives in the owner company's own ZITADEL, never a platform one.
|
|
7095
|
+
const ownerSignIn = context.owner?.signIn ?? null;
|
|
7096
|
+
if (!ownerSignIn) {
|
|
7097
|
+
throw new Error(`${label}: the owner company's sign-in has not been applied yet`);
|
|
7098
|
+
}
|
|
7099
|
+
if (!deps.zitadelForInstance) {
|
|
7100
|
+
throw new Error(`${label}: the host built the Access provider dependencies without zitadelForInstance, so it cannot reach the owner company's ZITADEL`);
|
|
7101
|
+
}
|
|
7102
|
+
const issuer = ownerSignIn.issuerUrl;
|
|
7103
|
+
const zitadel = deps.zitadelForInstance(issuer, ownerSignIn.bearerRef);
|
|
7104
|
+
const org = { id: ownerSignIn.organisationId };
|
|
6603
7105
|
const { project } = await zitadel.findOrCreateProject(org.id, PLATFORM_ACCESS_ZITADEL_PROJECT);
|
|
7106
|
+
context.recordScopeId(PLATFORM_ACCESS_ZITADEL_PROJECT_SCOPE_KEY, project.id);
|
|
6604
7107
|
const orphan = (await zitadel.listApps(org.id, project.id)).apps.find((a) => a.name === PLATFORM_ACCESS_ZITADEL_APP && a.state !== 'APP_STATE_REMOVED');
|
|
6605
7108
|
if (orphan) {
|
|
6606
7109
|
try {
|
|
@@ -6617,6 +7120,7 @@ async function provisionPlatformAccessProvider(context, deps) {
|
|
|
6617
7120
|
postLogoutRedirectUris: [`https://${authDomain}/`],
|
|
6618
7121
|
});
|
|
6619
7122
|
context.recordScopeId(PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY, client.appId);
|
|
7123
|
+
context.recordScopeId(PLATFORM_ACCESS_ZITADEL_APP_SCOPE_KEY, client.appId);
|
|
6620
7124
|
await context.persist?.();
|
|
6621
7125
|
let providerId = '';
|
|
6622
7126
|
await deliverWithRetry(async (_ref, secret) => {
|
|
@@ -6632,7 +7136,7 @@ async function provisionPlatformAccessProvider(context, deps) {
|
|
|
6632
7136
|
})).id;
|
|
6633
7137
|
}, ACCESS_PROVIDER_RECORD_KEY, client.clientSecret, attempts, sleep, `the ZITADEL client ${client.appId}'s secret`, label);
|
|
6634
7138
|
// Last: the guard says the secret reached edge.
|
|
6635
|
-
context.recordScopeId(PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY,
|
|
7139
|
+
context.recordScopeId(PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, ownerSignIn.hostname);
|
|
6636
7140
|
context.recordScopeId(PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, authDomain);
|
|
6637
7141
|
context.recordScopeId(ACCESS_PROVIDER_RECORD_KEY, providerId);
|
|
6638
7142
|
await context.persist?.();
|
|
@@ -6656,6 +7160,17 @@ const HC_PORT = '8000';
|
|
|
6656
7160
|
const HC_HEALTH_CHECK_PATH = '/';
|
|
6657
7161
|
const PLATFORM_HC_DB = 'hc';
|
|
6658
7162
|
const PLATFORM_HC_DB_USER = 'hc';
|
|
7163
|
+
/** The tunnel that carries the AI host's public hostname, and the app that runs its connector. */
|
|
7164
|
+
const AI_TUNNEL_NAME = 'platform-ai-tunnel';
|
|
7165
|
+
/**
|
|
7166
|
+
* The only paths the AI host's public route serves; everything else answers 404 at the tunnel. A company entry's key
|
|
7167
|
+
* holds `ai.runs:create` and `ai.offerings:invoke` (`AI_ENTRY_PERMISSIONS`), and `@wtfalch/ai`'s client reaches them
|
|
7168
|
+
* through `POST /v1/runs` (also `?wait=`), `POST /v1/runs/batch`, `GET /v1/runs`, `GET /v1/runs/:id` and
|
|
7169
|
+
* `GET /v1/offerings`. The management routes (`/v1/organisations`, `/v1/keys`, `/v1/budgets`, `/v1/webhooks`,
|
|
7170
|
+
* `/v1/connections`, `/v1/platform/keys`, ...) and `/api/health` are not on the internet.
|
|
7171
|
+
*/
|
|
7172
|
+
export const AI_ENTRY_PATH_PATTERN = '^/v1/(runs(/|$)|offerings$)';
|
|
7173
|
+
const AI_CLOUDFLARED_APP_NAME = 'platform-ai-cloudflared';
|
|
6659
7174
|
const HC_TUNNEL_NAME = 'platform-hc-tunnel';
|
|
6660
7175
|
/** #197: the one Coolify app that runs `cloudflared` for {@link HC_TUNNEL_NAME}. */
|
|
6661
7176
|
const HC_CLOUDFLARED_APP_NAME = 'platform-hc-cloudflared';
|
|
@@ -6749,7 +7264,7 @@ async function provisionPlatformHc(context, deps) {
|
|
|
6749
7264
|
environment_name: environment.name,
|
|
6750
7265
|
environment_uuid: environment.uuid,
|
|
6751
7266
|
docker_registry_image_name: HC_IMAGE,
|
|
6752
|
-
docker_registry_image_tag: gitRef,
|
|
7267
|
+
docker_registry_image_tag: pinnedTagFor('hc', gitRef),
|
|
6753
7268
|
ports_exposes: HC_PORT,
|
|
6754
7269
|
name: PLATFORM_HC_APP_NAME,
|
|
6755
7270
|
description: `Healthchecks for the platform's own heartbeats (#540, ${HC_IMAGE}:${gitRef})`,
|
|
@@ -6762,7 +7277,7 @@ async function provisionPlatformHc(context, deps) {
|
|
|
6762
7277
|
// own rule. The image tag too, so a declared release change reaches the app.
|
|
6763
7278
|
await deps.coolify.updateApplication(appId, {
|
|
6764
7279
|
custom_internal_name: HC_INTERNAL_NAME,
|
|
6765
|
-
docker_registry_image_tag: gitRef,
|
|
7280
|
+
docker_registry_image_tag: pinnedTagFor('hc', gitRef),
|
|
6766
7281
|
...healthCheckSettings(HC_HEALTH_CHECK_PATH, Number(HC_PORT)),
|
|
6767
7282
|
});
|
|
6768
7283
|
// ---- env, parsed off the database's own owner credentials -----------------
|
|
@@ -7471,7 +7986,8 @@ async function provisionPlatformIntegrations(action, context, deps) {
|
|
|
7471
7986
|
keyJustSet ||
|
|
7472
7987
|
runnerKeyStale ||
|
|
7473
7988
|
configChanged) {
|
|
7474
|
-
await waitForNewDeployment(coolify, runnerApp.appId, { kind: 'deployment-only' }, runtime, sleep, 'platform:integrations', `the runner stays at ${recorded[PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY] ?? 'no recorded ref'}`);
|
|
7989
|
+
const built = await waitForNewDeployment(coolify, runnerApp.appId, { kind: 'deployment-only' }, runtime, sleep, 'platform:integrations', `the runner stays at ${recorded[PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY] ?? 'no recorded ref'}`);
|
|
7990
|
+
await recordBuiltCommit(coolify, { appId: runnerApp.appId, deploymentUuid: built.deploymentUuid, name: 'integrations' }, record);
|
|
7475
7991
|
}
|
|
7476
7992
|
// #398: the runner is up with the keyring that holds the active id; only
|
|
7477
7993
|
// now may Archon move to it.
|
|
@@ -7602,7 +8118,7 @@ async function waitForIntegrationsMigration(coolify, appId, runtime, sleep) {
|
|
|
7602
8118
|
/** The name every platform database's backup is registered against -- one shared R2 bucket, one storage entry. */
|
|
7603
8119
|
const PLATFORM_BACKUP_S3_STORAGE_NAME = 'platform-backups';
|
|
7604
8120
|
/** The uuid `provisionPlatformBackup` records once {@link PLATFORM_BACKUP_S3_STORAGE_NAME} is registered on Coolify, so a later apply never re-registers it. */
|
|
7605
|
-
const PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY = 'coolify_platform_backup_s3_storage';
|
|
8121
|
+
export const PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY = 'coolify_platform_backup_s3_storage';
|
|
7606
8122
|
/**
|
|
7607
8123
|
* #257 fix round: a sha256 of the exact `endpoint|bucket|key|secret` the
|
|
7608
8124
|
* storage was last pushed with -- never the values themselves, the same
|
|
@@ -7632,9 +8148,10 @@ const PLATFORM_BACKUP_S3_STORAGE_PENDING_SCOPE_KEY = 'coolify_platform_backup_s3
|
|
|
7632
8148
|
const PLATFORM_BACKUP_DEFAULT_FREQUENCY = 'daily';
|
|
7633
8149
|
/**
|
|
7634
8150
|
* The database a service's schedule must name. A schedule that names none
|
|
7635
|
-
* dumps the Coolify resource's first database
|
|
7636
|
-
*
|
|
7637
|
-
* Every other service runs on its resource's first database and
|
|
8151
|
+
* dumps the Coolify resource's first database; Archon's is named here anyway,
|
|
8152
|
+
* so a resource whose first database is not {@link ARCHON_POSTGRES_DB} is still
|
|
8153
|
+
* dumped right. Every other service runs on its resource's first database and
|
|
8154
|
+
* needs no entry.
|
|
7638
8155
|
*/
|
|
7639
8156
|
const PLATFORM_BACKUP_DATABASE_NAMES = {
|
|
7640
8157
|
archon: ARCHON_POSTGRES_DB,
|
|
@@ -7650,8 +8167,11 @@ const PLATFORM_BACKUP_DATABASE_NAMES = {
|
|
|
7650
8167
|
* {@link repointPlatformBackupSchedules} uses for `s3_storage_uuid`.
|
|
7651
8168
|
*/
|
|
7652
8169
|
async function findPlatformBackupSchedule(coolify, service, databaseId) {
|
|
8170
|
+
return findDatabaseBackupSchedule(coolify, PLATFORM_BACKUP_DATABASE_NAMES[service], databaseId);
|
|
8171
|
+
}
|
|
8172
|
+
/** {@link findPlatformBackupSchedule} for any database; `database` undefined means the resource's first database. */
|
|
8173
|
+
async function findDatabaseBackupSchedule(coolify, database, databaseId) {
|
|
7653
8174
|
const schedules = (await coolify.listDatabaseBackups(databaseId)).filter((backup) => backup.enabled && backup.save_s3);
|
|
7654
|
-
const database = PLATFORM_BACKUP_DATABASE_NAMES[service];
|
|
7655
8175
|
if (!database)
|
|
7656
8176
|
return { schedule: schedules[0], named: true };
|
|
7657
8177
|
for (const schedule of schedules) {
|
|
@@ -8027,23 +8547,58 @@ async function provisionPlatformBackup(action, context, deps) {
|
|
|
8027
8547
|
const recorded = context.recordedIds();
|
|
8028
8548
|
const storageUuid = await ensurePlatformBackupStorage(coolify, r2, recorded, context);
|
|
8029
8549
|
const service = action.key;
|
|
8030
|
-
|
|
8031
|
-
|
|
8032
|
-
|
|
8033
|
-
|
|
8550
|
+
await provisionDatabaseBackup(coolify, {
|
|
8551
|
+
databaseUuid: databaseId,
|
|
8552
|
+
label: `platform ${service}`,
|
|
8553
|
+
storageUuid,
|
|
8554
|
+
frequency: deps.frequency,
|
|
8555
|
+
databaseName: PLATFORM_BACKUP_DATABASE_NAMES[service],
|
|
8556
|
+
});
|
|
8557
|
+
return undefined;
|
|
8558
|
+
}
|
|
8559
|
+
/**
|
|
8560
|
+
* F6: one nightly schedule of one database to a registered S3 storage, the
|
|
8561
|
+
* body `provisionPlatformBackup` had and every company database now shares.
|
|
8562
|
+
* An existing enabled, `save_s3` schedule is reused, never doubled: the lookup
|
|
8563
|
+
* runs before the create, so a run that created a schedule and died before
|
|
8564
|
+
* the caller recorded its uuid finds it again on the next apply. The
|
|
8565
|
+
* `PATCH` names the database to dump, because `createDatabaseBackup` takes no
|
|
8566
|
+
* database name; it also repairs an existing schedule that dumps the wrong
|
|
8567
|
+
* one. Returns the schedule's uuid.
|
|
8568
|
+
*/
|
|
8569
|
+
export async function provisionDatabaseBackup(coolify, input) {
|
|
8570
|
+
const { databaseUuid, label } = input;
|
|
8571
|
+
const { schedule, named } = await backupCall(label, () => findDatabaseBackupSchedule(coolify, input.databaseName, databaseUuid));
|
|
8572
|
+
const uuid = schedule?.uuid ??
|
|
8573
|
+
(await backupCall(label, () => coolify.createDatabaseBackup(databaseUuid, {
|
|
8574
|
+
frequency: input.frequency ?? PLATFORM_BACKUP_DEFAULT_FREQUENCY,
|
|
8034
8575
|
enabled: true,
|
|
8035
8576
|
save_s3: true,
|
|
8036
|
-
s3_storage_uuid: storageUuid,
|
|
8037
|
-
})).uuid;
|
|
8038
|
-
// `CoolifyPort.createDatabaseBackup` takes no database name, so a new
|
|
8039
|
-
// schedule is named here too, by the same PATCH that repairs an existing
|
|
8040
|
-
// one dumping the wrong database.
|
|
8577
|
+
s3_storage_uuid: input.storageUuid,
|
|
8578
|
+
}))).uuid;
|
|
8041
8579
|
if (!named) {
|
|
8042
|
-
await coolify.raw('PATCH', `/databases/${
|
|
8043
|
-
databases_to_backup:
|
|
8044
|
-
});
|
|
8580
|
+
await backupCall(label, () => coolify.raw('PATCH', `/databases/${databaseUuid}/backups/${uuid}`, {
|
|
8581
|
+
databases_to_backup: input.databaseName,
|
|
8582
|
+
}));
|
|
8583
|
+
}
|
|
8584
|
+
return { uuid };
|
|
8585
|
+
}
|
|
8586
|
+
/** How long one Coolify call of a database backup may take. */
|
|
8587
|
+
const BACKUP_CALL_TIMEOUT_MS = 30_000;
|
|
8588
|
+
/** Runs one Coolify call with a timeout; a timeout names the database, not the call's arguments. */
|
|
8589
|
+
async function backupCall(label, call) {
|
|
8590
|
+
let timer;
|
|
8591
|
+
try {
|
|
8592
|
+
return await Promise.race([
|
|
8593
|
+
call(),
|
|
8594
|
+
new Promise((_, reject) => {
|
|
8595
|
+
timer = setTimeout(() => reject(new Error(`backup of ${label}: Coolify did not answer within ${BACKUP_CALL_TIMEOUT_MS / 1000} seconds`)), BACKUP_CALL_TIMEOUT_MS);
|
|
8596
|
+
}),
|
|
8597
|
+
]);
|
|
8598
|
+
}
|
|
8599
|
+
finally {
|
|
8600
|
+
clearTimeout(timer);
|
|
8045
8601
|
}
|
|
8046
|
-
return undefined;
|
|
8047
8602
|
}
|
|
8048
8603
|
/**
|
|
8049
8604
|
* #540: reconciles the live Healthchecks checks against the ones foundry
|
|
@@ -8126,7 +8681,10 @@ async function provisionPlatformHeartbeats(context, deps) {
|
|
|
8126
8681
|
// Last: so a run that failed anywhere above (an unreconciled create or
|
|
8127
8682
|
// delete) leaves this unset and `planHeartbeats` plans the step again --
|
|
8128
8683
|
// the same "record markers last" rule `provisionPlatformHc` follows.
|
|
8129
|
-
|
|
8684
|
+
// foundry#544: the provider is folded in, matching `planHeartbeats`'s own
|
|
8685
|
+
// `desired` -- a provider switch with the same check names otherwise
|
|
8686
|
+
// desired is still a change from this marker.
|
|
8687
|
+
context.recordScopeId(PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, `${context.platform.heartbeatsProvider}:${[...desiredNames].sort().join(',')}`);
|
|
8130
8688
|
return undefined;
|
|
8131
8689
|
}
|
|
8132
8690
|
/**
|