@wtfalch/foundry 0.34.0 → 0.35.1
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 +770 -231
- 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;
|
|
195
137
|
}
|
|
196
|
-
|
|
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) };
|
|
151
|
+
}
|
|
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
|
}
|
|
@@ -3052,9 +3258,75 @@ const PLATFORM_ARCHON_PROJECT_SCOPE_KEY = 'coolify_project_platform_archon';
|
|
|
3052
3258
|
const PLATFORM_ARCHON_DB_SCOPE_KEY = 'coolify_platform_archon_database';
|
|
3053
3259
|
const PLATFORM_ARCHON_TUNNEL_SCOPE_KEY = 'cloudflare_platform_archon_tunnel';
|
|
3054
3260
|
const PLATFORM_ARCHON_ACCESS_SCOPE_KEY = 'cloudflare_platform_archon_access_app';
|
|
3055
|
-
|
|
3261
|
+
/**
|
|
3262
|
+
* Archon paths a machine calls and Archon authenticates itself (its `proxy.ts`:
|
|
3263
|
+
* `PUBLIC_EXACT` and `ORG_CHANGES_PATH_RE`). Each gets its own Access app with
|
|
3264
|
+
* one bypass policy, because the hostname app would otherwise ask a machine for
|
|
3265
|
+
* an emailed code. `/api/uptime/targets` and `/api/uptime/results` are left out
|
|
3266
|
+
* on purpose: they pass the hostname app through the uptime service-token
|
|
3267
|
+
* policy, and a bypass would make that token meaningless. `*` is one path
|
|
3268
|
+
* segment (the organisation id).
|
|
3269
|
+
*/
|
|
3270
|
+
export const PLATFORM_ARCHON_PUBLIC_PATHS = [
|
|
3271
|
+
{ name: 'prices', path: '/api/prices' },
|
|
3272
|
+
{ name: 'vipps-webhook', path: '/vipps/webhook' },
|
|
3273
|
+
{ name: 'vipps-agreement', path: '/vipps/agreement' },
|
|
3274
|
+
{ name: 'usage-tick', path: '/api/usage/tick' },
|
|
3275
|
+
{ name: 'reporting-collect', path: '/api/reporting/collect' },
|
|
3276
|
+
{ name: 'org-changes', path: '/api/orgs/*/changes' },
|
|
3277
|
+
];
|
|
3278
|
+
const PLATFORM_ARCHON_PUBLIC_POLICY_NAME = 'foundry: public path';
|
|
3279
|
+
const archonPublicAppName = (name) => `${PLATFORM_ARCHON_ACCESS_APP_NAME}:public:${name}`;
|
|
3280
|
+
/** The scope key one bypass app's id is recorded under. */
|
|
3281
|
+
export const archonPublicAccessScopeKey = (name) => `cloudflare_platform_archon_access_public_${name.replace(/-/g, '_')}`;
|
|
3282
|
+
/** Whether a policy list is exactly the one bypass-for-everyone policy. */
|
|
3283
|
+
function onlyBypassEveryone(policies) {
|
|
3284
|
+
const only = policies[0];
|
|
3285
|
+
return (policies.length === 1 &&
|
|
3286
|
+
only?.name === PLATFORM_ARCHON_PUBLIC_POLICY_NAME &&
|
|
3287
|
+
only.decision === 'bypass' &&
|
|
3288
|
+
Array.isArray(only.include) &&
|
|
3289
|
+
only.include.length === 1 &&
|
|
3290
|
+
JSON.stringify(only.include[0]) === '{"everyone":{}}');
|
|
3291
|
+
}
|
|
3292
|
+
/**
|
|
3293
|
+
* One Access app per public path, each with the one policy bypass/everyone and
|
|
3294
|
+
* no identity provider. Adopts only an app of its own name on the exact domain,
|
|
3295
|
+
* refuses another name there, and rebuilds one whose policies are not exactly
|
|
3296
|
+
* the bypass. A rebuild needs no closed gate: the hostname app covers the path
|
|
3297
|
+
* meanwhile, which only asks for more.
|
|
3298
|
+
*/
|
|
3299
|
+
async function ensureArchonPublicAccess(cf, hostname, recorded, record) {
|
|
3300
|
+
for (const { name, path } of PLATFORM_ARCHON_PUBLIC_PATHS) {
|
|
3301
|
+
const domain = `${hostname}${path}`;
|
|
3302
|
+
const appName = archonPublicAppName(name);
|
|
3303
|
+
const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
|
|
3304
|
+
const foreign = onDomain.find((a) => a.name !== appName);
|
|
3305
|
+
if (foreign) {
|
|
3306
|
+
throw new Error(`platform:archon: Access app '${foreign.name}' already holds ${domain}; foundry does not adopt it`);
|
|
3307
|
+
}
|
|
3308
|
+
let id = onDomain[0]?.id;
|
|
3309
|
+
if (id && !onlyBypassEveryone(await cf.listAccessPolicies(id))) {
|
|
3310
|
+
await cf.deleteAccessApp(id);
|
|
3311
|
+
id = undefined;
|
|
3312
|
+
}
|
|
3313
|
+
if (!id) {
|
|
3314
|
+
id = (await cf.createAccessApp({ name: appName, domain, type: 'self_hosted' })).id;
|
|
3315
|
+
await cf.createAccessPolicy(id, {
|
|
3316
|
+
name: PLATFORM_ARCHON_PUBLIC_POLICY_NAME,
|
|
3317
|
+
decision: 'bypass',
|
|
3318
|
+
include: [{ everyone: {} }],
|
|
3319
|
+
});
|
|
3320
|
+
}
|
|
3321
|
+
const key = archonPublicAccessScopeKey(name);
|
|
3322
|
+
if (recorded[key] !== id)
|
|
3323
|
+
await record(key, id);
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3056
3326
|
/** The hostname the sign-in client's redirect URIs were made for; a change makes a new client. */
|
|
3057
3327
|
const PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY = 'zitadel_platform_archon_client_hostname';
|
|
3328
|
+
/** The owner's ZITADEL organisation the sign-in client was made in; a change makes a new client. */
|
|
3329
|
+
const PLATFORM_ARCHON_CLIENT_ORG_SCOPE_KEY = 'zitadel_platform_archon_client_org';
|
|
3058
3330
|
/** Archon's OIDC client id: public, not a secret, so a plain scope id. */
|
|
3059
3331
|
export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_client_id';
|
|
3060
3332
|
/**
|
|
@@ -3065,13 +3337,16 @@ export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_clie
|
|
|
3065
3337
|
* names the ids only, never the cause.
|
|
3066
3338
|
*/
|
|
3067
3339
|
async function switchOnArchonIdTokenUserinfo(context, deps) {
|
|
3068
|
-
const { platform } = context;
|
|
3069
3340
|
const recorded = context.recordedIds();
|
|
3070
|
-
|
|
3341
|
+
// The client lives in the owner company's ZITADEL; without its sign-in there is nothing to switch on.
|
|
3342
|
+
const ownerSignIn = context.owner?.signIn ?? null;
|
|
3343
|
+
const zitadel = ownerSignIn
|
|
3344
|
+
? deps.zitadelForInstance?.(ownerSignIn.issuerUrl, ownerSignIn.bearerRef)
|
|
3345
|
+
: undefined;
|
|
3071
3346
|
// The planner plans nothing without the method; a stale plan does nothing.
|
|
3072
|
-
if (!zitadel
|
|
3347
|
+
if (!ownerSignIn || !zitadel?.updateOidcAppIdTokenUserinfoAssertion)
|
|
3073
3348
|
return {};
|
|
3074
|
-
const orgId =
|
|
3349
|
+
const orgId = ownerSignIn.organisationId;
|
|
3075
3350
|
const projectId = recorded[PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY];
|
|
3076
3351
|
const appId = recorded[PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY];
|
|
3077
3352
|
if (!orgId || !projectId || !appId) {
|
|
@@ -3197,7 +3472,25 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3197
3472
|
const hostname = platform.archon.hostname;
|
|
3198
3473
|
const gitRef = platform.archon.gitRef;
|
|
3199
3474
|
const recorded = context.recordedIds();
|
|
3200
|
-
|
|
3475
|
+
// Operators sign in through the owner company's own sign-in, never a platform one.
|
|
3476
|
+
const ownerSignIn = context.owner?.signIn ?? null;
|
|
3477
|
+
const ownerFiles = context.owner?.files ?? null;
|
|
3478
|
+
if (!ownerSignIn) {
|
|
3479
|
+
throw new Error("platform:archon: the owner company's sign-in has not been applied yet; Archon's sign-in client lives in it");
|
|
3480
|
+
}
|
|
3481
|
+
const issuer = ownerSignIn.issuerUrl;
|
|
3482
|
+
// Archon signs people in from its own organisation; the company's is only handed on as CI_ORGANIZATION_ID.
|
|
3483
|
+
const authOrgId = recorded[PLATFORM_ARCHON_ORG_SCOPE_KEY];
|
|
3484
|
+
const ciOrgId = ownerSignIn.organisationId;
|
|
3485
|
+
if (!authOrgId) {
|
|
3486
|
+
throw new Error("platform:archon: Archon's own sign-in organisation is not recorded yet; zitadel:archon-org runs first");
|
|
3487
|
+
}
|
|
3488
|
+
// Archon accepts sign-ins from that organisation only once it forces a second factor and keeps registration closed.
|
|
3489
|
+
if (recorded[PLATFORM_ARCHON_ORG_POLICY_SCOPE_KEY] !== authOrgId ||
|
|
3490
|
+
recorded[PLATFORM_ARCHON_ORG_REGISTRATION_CLOSED_SCOPE_KEY] !== authOrgId) {
|
|
3491
|
+
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");
|
|
3492
|
+
}
|
|
3493
|
+
const firstOwnerLink = archonFirstOwnerLink(recorded, ownerSignIn);
|
|
3201
3494
|
const appliedGitRef = recorded[PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY];
|
|
3202
3495
|
const runtime = deps.runtime ?? {};
|
|
3203
3496
|
const sleep = runtime.sleep ?? defaultSleep;
|
|
@@ -3205,13 +3498,7 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3205
3498
|
if (deps.accessEmails.length === 0) {
|
|
3206
3499
|
throw new Error('platform:archon: no Access emails given; refusing to put Archon behind an Access app nobody can pass');
|
|
3207
3500
|
}
|
|
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
|
-
}
|
|
3501
|
+
const accessEmails = archonAccessEmails(deps.accessEmails, ownerCompany);
|
|
3215
3502
|
const record = async (key, id) => {
|
|
3216
3503
|
context.recordScopeId(key, id);
|
|
3217
3504
|
await context.persist?.();
|
|
@@ -3219,7 +3506,7 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3219
3506
|
// Every service the platform declares must have delivered what Archon reads.
|
|
3220
3507
|
// The planner blocks first; this is the same rule for a caller that reaches
|
|
3221
3508
|
// the step anyway, and it is where the values are read for the env below.
|
|
3222
|
-
const inputs = await collectArchonServiceInputs(platform, deps, recorded);
|
|
3509
|
+
const inputs = await collectArchonServiceInputs(platform, deps, recorded, ownerFiles);
|
|
3223
3510
|
if ('blocked' in inputs)
|
|
3224
3511
|
throw new Error(`platform:archon: ${inputs.blocked}`);
|
|
3225
3512
|
// #213: Archon's own onboarding reads these back as held keys before it
|
|
@@ -3324,11 +3611,25 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3324
3611
|
...healthCheckSettings(ARCHON_HEALTH_CHECK_PATH, Number(ARCHON_PORT), ARCHON_HEALTH_CHECK_START_PERIOD_SECONDS),
|
|
3325
3612
|
git_branch: gitRef,
|
|
3326
3613
|
});
|
|
3327
|
-
// ---- sign-in client in the
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
if (
|
|
3331
|
-
|
|
3614
|
+
// ---- sign-in client in the owner company's own ZITADEL --------------------
|
|
3615
|
+
// Only the owner's own instance, by its issuer and bearer reference: the
|
|
3616
|
+
// platform's `deps.zitadel` is never used, so a missing factory is an error.
|
|
3617
|
+
if (!deps.zitadelForInstance) {
|
|
3618
|
+
throw new Error("platform:archon: the host built the platform executor without zitadelForInstance, so it cannot reach the owner company's ZITADEL");
|
|
3619
|
+
}
|
|
3620
|
+
const zitadel = deps.zitadelForInstance(issuer, ownerSignIn.bearerRef);
|
|
3621
|
+
const org = { id: authOrgId };
|
|
3622
|
+
// The client made in another organisation (the company's, before Archon had its own) is deleted
|
|
3623
|
+
// once the new one serves: its ids are kept here before the recorded ones are overwritten.
|
|
3624
|
+
const oldClientOrg = recorded[PLATFORM_ARCHON_CLIENT_ORG_SCOPE_KEY];
|
|
3625
|
+
const oldProject = recorded[PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY];
|
|
3626
|
+
const oldApp = recorded[PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY];
|
|
3627
|
+
if (oldClientOrg &&
|
|
3628
|
+
oldClientOrg !== authOrgId &&
|
|
3629
|
+
oldProject &&
|
|
3630
|
+
oldApp &&
|
|
3631
|
+
!recorded[PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY]) {
|
|
3632
|
+
await record(PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY, `${oldClientOrg}|${oldProject}|${oldApp}`);
|
|
3332
3633
|
}
|
|
3333
3634
|
const { project } = await zitadel.findOrCreateProject(org.id, PLATFORM_ARCHON_ZITADEL_PROJECT);
|
|
3334
3635
|
if (recorded[PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY] !== project.id) {
|
|
@@ -3337,7 +3638,8 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3337
3638
|
// The client's redirect URIs name the hostname, and no call updates them, so
|
|
3338
3639
|
// a client made for another hostname is deleted and made again. Its id is
|
|
3339
3640
|
// public and there is no secret to lose.
|
|
3340
|
-
let clientId = recorded[PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY] === hostname
|
|
3641
|
+
let clientId = recorded[PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY] === hostname &&
|
|
3642
|
+
recorded[PLATFORM_ARCHON_CLIENT_ORG_SCOPE_KEY] === org.id
|
|
3341
3643
|
? recorded[PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY]
|
|
3342
3644
|
: undefined;
|
|
3343
3645
|
if (!clientId) {
|
|
@@ -3369,18 +3671,18 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
|
|
|
3369
3671
|
// Made with the setting on (providers 0.9.0): nothing to repair on this id.
|
|
3370
3672
|
context.recordScopeId(PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, created.appId);
|
|
3371
3673
|
context.recordScopeId(PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY, hostname);
|
|
3674
|
+
context.recordScopeId(PLATFORM_ARCHON_CLIENT_ORG_SCOPE_KEY, org.id);
|
|
3372
3675
|
await record(PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY, created.clientId);
|
|
3373
3676
|
clientId = created.clientId;
|
|
3374
3677
|
}
|
|
3375
3678
|
// ---- 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
3679
|
const envs = [
|
|
3380
3680
|
{ key: 'APP_URL', value: `https://${hostname}` },
|
|
3381
3681
|
{ key: 'AUTH_ISSUER', value: issuer },
|
|
3382
3682
|
{ key: 'AUTH_ORGANIZATION_ID', value: authOrgId },
|
|
3683
|
+
{ key: 'CI_ORGANIZATION_ID', value: ciOrgId },
|
|
3383
3684
|
{ key: 'AUTH_CLIENT_ID', value: clientId },
|
|
3685
|
+
...(firstOwnerLink ? [{ key: 'ARCHON_FIRST_OWNER_LINK', value: firstOwnerLink }] : []),
|
|
3384
3686
|
];
|
|
3385
3687
|
// #265: an owner URL or reporting site already on the app is kept as it is.
|
|
3386
3688
|
// The owner URL must name the adopted database's host, or the app's two
|
|
@@ -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
|
/**
|
|
@@ -5209,7 +5636,8 @@ async function provisionPlatformAi(context, deps) {
|
|
|
5209
5636
|
// nothing public is wired to either app.
|
|
5210
5637
|
const internalUrl = `http://${PLATFORM_AI_RUNNER.internalName}:${PLATFORM_AI_PORT}`;
|
|
5211
5638
|
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'}`);
|
|
5639
|
+
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'}`);
|
|
5640
|
+
await recordBuiltCommit(coolify, { appId: runner.appId, deploymentUuid: built.deploymentUuid, name: 'ai' }, record);
|
|
5213
5641
|
}
|
|
5214
5642
|
if (deployWorker) {
|
|
5215
5643
|
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 +5723,54 @@ async function provisionPlatformAi(context, deps) {
|
|
|
5295
5723
|
}
|
|
5296
5724
|
await record(PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, credentialId);
|
|
5297
5725
|
}
|
|
5726
|
+
// ---- the public route ------------------------------------------------------
|
|
5727
|
+
// A company's entries run on their own network and cannot dial `platform-ai`, so they call
|
|
5728
|
+
// `https://<hostname>`. The route is the heartbeat server's (`platform:hc`): a tunnel that dials the
|
|
5729
|
+
// runner by its internal name, a connector app, and a proxied CNAME. No Coolify `domains`, no Access
|
|
5730
|
+
// app: ai authenticates every caller by its bearer key, and a gate in front would stop that.
|
|
5731
|
+
const tunnels = await platformTunnels(deps, context.recordedIds(), 'platform:ai');
|
|
5732
|
+
const tunnel = (await tunnels.findTunnelByName(AI_TUNNEL_NAME)) ??
|
|
5733
|
+
(await tunnels.createTunnel(AI_TUNNEL_NAME));
|
|
5734
|
+
// Recorded last, once the DNS record is written: planAi and aiUrl read it as "the route is complete",
|
|
5735
|
+
// 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.
|
|
5736
|
+
await tunnels.putTunnelConfig(tunnel.id, {
|
|
5737
|
+
ingress: [
|
|
5738
|
+
{ hostname, path: AI_ENTRY_PATH_PATTERN, service: internalUrl },
|
|
5739
|
+
{ service: 'http_status:404' },
|
|
5740
|
+
],
|
|
5741
|
+
});
|
|
5742
|
+
const server = await resolvePlatformServer(coolify);
|
|
5743
|
+
await ensureCloudflaredConnector({
|
|
5744
|
+
coolify,
|
|
5745
|
+
tunnels,
|
|
5746
|
+
tunnelId: tunnel.id,
|
|
5747
|
+
appName: AI_CLOUDFLARED_APP_NAME,
|
|
5748
|
+
projectUuid,
|
|
5749
|
+
environment,
|
|
5750
|
+
resolveServer: async () => server,
|
|
5751
|
+
recordedConnectorAppId: context.recordedIds()[PLATFORM_AI_CONNECTOR_APP_SCOPE_KEY] ?? null,
|
|
5752
|
+
recordAppId: (connectorAppId) => record(PLATFORM_AI_CONNECTOR_APP_SCOPE_KEY, connectorAppId),
|
|
5753
|
+
});
|
|
5754
|
+
const dns = await platformDns(deps, context.recordedIds(), 'platform:ai');
|
|
5755
|
+
const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
|
|
5756
|
+
const [dnsRecord] = await dns.findDnsRecord(platform.apex, hostname, 'CNAME');
|
|
5757
|
+
if (!dnsRecord) {
|
|
5758
|
+
await dns.createDnsRecord(platform.apex, {
|
|
5759
|
+
type: 'CNAME',
|
|
5760
|
+
name: hostname,
|
|
5761
|
+
content: dnsTarget,
|
|
5762
|
+
proxied: true,
|
|
5763
|
+
});
|
|
5764
|
+
}
|
|
5765
|
+
else if (dnsRecord.content !== dnsTarget) {
|
|
5766
|
+
await dns.updateDnsRecord(platform.apex, dnsRecord.id, {
|
|
5767
|
+
type: 'CNAME',
|
|
5768
|
+
name: hostname,
|
|
5769
|
+
content: dnsTarget,
|
|
5770
|
+
proxied: true,
|
|
5771
|
+
});
|
|
5772
|
+
}
|
|
5773
|
+
await record(PLATFORM_AI_TUNNEL_SCOPE_KEY, tunnel.id);
|
|
5298
5774
|
return { resourceId: runner.appId };
|
|
5299
5775
|
}
|
|
5300
5776
|
/**
|
|
@@ -6252,7 +6728,8 @@ export async function provisionFilesHost(scope, deps, target, input) {
|
|
|
6252
6728
|
// record first is still what rules out the old container staying healthy
|
|
6253
6729
|
// through a failed deploy.
|
|
6254
6730
|
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'}`);
|
|
6731
|
+
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'}`);
|
|
6732
|
+
await recordBuiltCommit(deps.coolify, { appId: runnerApp.appId, deploymentUuid: built.deploymentUuid, name: 'files' }, record);
|
|
6256
6733
|
await record(PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, internalUrl);
|
|
6257
6734
|
// Last: a run that failed anywhere above is planned again.
|
|
6258
6735
|
scope.recordScopeId(PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, input.hostname);
|
|
@@ -6578,7 +7055,6 @@ const PLATFORM_ACCESS_PROVIDER_NAME = 'foundry';
|
|
|
6578
7055
|
* again.
|
|
6579
7056
|
*/
|
|
6580
7057
|
async function provisionPlatformAccessProvider(context, deps) {
|
|
6581
|
-
const { platform } = context;
|
|
6582
7058
|
const recorded = context.recordedIds();
|
|
6583
7059
|
const sleep = deps.runtime?.sleep ?? defaultSleep;
|
|
6584
7060
|
const attempts = deps.runtime?.deliverAttempts ?? 3;
|
|
@@ -6597,10 +7073,19 @@ async function provisionPlatformAccessProvider(context, deps) {
|
|
|
6597
7073
|
catch {
|
|
6598
7074
|
throw new Error(`${label}: edge could not confirm that Cloudflare Access is ready`);
|
|
6599
7075
|
}
|
|
6600
|
-
|
|
6601
|
-
const
|
|
6602
|
-
|
|
7076
|
+
// The client lives in the owner company's own ZITADEL, never a platform one.
|
|
7077
|
+
const ownerSignIn = context.owner?.signIn ?? null;
|
|
7078
|
+
if (!ownerSignIn) {
|
|
7079
|
+
throw new Error(`${label}: the owner company's sign-in has not been applied yet`);
|
|
7080
|
+
}
|
|
7081
|
+
if (!deps.zitadelForInstance) {
|
|
7082
|
+
throw new Error(`${label}: the host built the Access provider dependencies without zitadelForInstance, so it cannot reach the owner company's ZITADEL`);
|
|
7083
|
+
}
|
|
7084
|
+
const issuer = ownerSignIn.issuerUrl;
|
|
7085
|
+
const zitadel = deps.zitadelForInstance(issuer, ownerSignIn.bearerRef);
|
|
7086
|
+
const org = { id: ownerSignIn.organisationId };
|
|
6603
7087
|
const { project } = await zitadel.findOrCreateProject(org.id, PLATFORM_ACCESS_ZITADEL_PROJECT);
|
|
7088
|
+
context.recordScopeId(PLATFORM_ACCESS_ZITADEL_PROJECT_SCOPE_KEY, project.id);
|
|
6604
7089
|
const orphan = (await zitadel.listApps(org.id, project.id)).apps.find((a) => a.name === PLATFORM_ACCESS_ZITADEL_APP && a.state !== 'APP_STATE_REMOVED');
|
|
6605
7090
|
if (orphan) {
|
|
6606
7091
|
try {
|
|
@@ -6617,6 +7102,7 @@ async function provisionPlatformAccessProvider(context, deps) {
|
|
|
6617
7102
|
postLogoutRedirectUris: [`https://${authDomain}/`],
|
|
6618
7103
|
});
|
|
6619
7104
|
context.recordScopeId(PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY, client.appId);
|
|
7105
|
+
context.recordScopeId(PLATFORM_ACCESS_ZITADEL_APP_SCOPE_KEY, client.appId);
|
|
6620
7106
|
await context.persist?.();
|
|
6621
7107
|
let providerId = '';
|
|
6622
7108
|
await deliverWithRetry(async (_ref, secret) => {
|
|
@@ -6632,7 +7118,7 @@ async function provisionPlatformAccessProvider(context, deps) {
|
|
|
6632
7118
|
})).id;
|
|
6633
7119
|
}, ACCESS_PROVIDER_RECORD_KEY, client.clientSecret, attempts, sleep, `the ZITADEL client ${client.appId}'s secret`, label);
|
|
6634
7120
|
// Last: the guard says the secret reached edge.
|
|
6635
|
-
context.recordScopeId(PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY,
|
|
7121
|
+
context.recordScopeId(PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, ownerSignIn.hostname);
|
|
6636
7122
|
context.recordScopeId(PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, authDomain);
|
|
6637
7123
|
context.recordScopeId(ACCESS_PROVIDER_RECORD_KEY, providerId);
|
|
6638
7124
|
await context.persist?.();
|
|
@@ -6656,6 +7142,17 @@ const HC_PORT = '8000';
|
|
|
6656
7142
|
const HC_HEALTH_CHECK_PATH = '/';
|
|
6657
7143
|
const PLATFORM_HC_DB = 'hc';
|
|
6658
7144
|
const PLATFORM_HC_DB_USER = 'hc';
|
|
7145
|
+
/** The tunnel that carries the AI host's public hostname, and the app that runs its connector. */
|
|
7146
|
+
const AI_TUNNEL_NAME = 'platform-ai-tunnel';
|
|
7147
|
+
/**
|
|
7148
|
+
* The only paths the AI host's public route serves; everything else answers 404 at the tunnel. A company entry's key
|
|
7149
|
+
* holds `ai.runs:create` and `ai.offerings:invoke` (`AI_ENTRY_PERMISSIONS`), and `@wtfalch/ai`'s client reaches them
|
|
7150
|
+
* through `POST /v1/runs` (also `?wait=`), `POST /v1/runs/batch`, `GET /v1/runs`, `GET /v1/runs/:id` and
|
|
7151
|
+
* `GET /v1/offerings`. The management routes (`/v1/organisations`, `/v1/keys`, `/v1/budgets`, `/v1/webhooks`,
|
|
7152
|
+
* `/v1/connections`, `/v1/platform/keys`, ...) and `/api/health` are not on the internet.
|
|
7153
|
+
*/
|
|
7154
|
+
export const AI_ENTRY_PATH_PATTERN = '^/v1/(runs(/|$)|offerings$)';
|
|
7155
|
+
const AI_CLOUDFLARED_APP_NAME = 'platform-ai-cloudflared';
|
|
6659
7156
|
const HC_TUNNEL_NAME = 'platform-hc-tunnel';
|
|
6660
7157
|
/** #197: the one Coolify app that runs `cloudflared` for {@link HC_TUNNEL_NAME}. */
|
|
6661
7158
|
const HC_CLOUDFLARED_APP_NAME = 'platform-hc-cloudflared';
|
|
@@ -6749,7 +7246,7 @@ async function provisionPlatformHc(context, deps) {
|
|
|
6749
7246
|
environment_name: environment.name,
|
|
6750
7247
|
environment_uuid: environment.uuid,
|
|
6751
7248
|
docker_registry_image_name: HC_IMAGE,
|
|
6752
|
-
docker_registry_image_tag: gitRef,
|
|
7249
|
+
docker_registry_image_tag: pinnedTagFor('hc', gitRef),
|
|
6753
7250
|
ports_exposes: HC_PORT,
|
|
6754
7251
|
name: PLATFORM_HC_APP_NAME,
|
|
6755
7252
|
description: `Healthchecks for the platform's own heartbeats (#540, ${HC_IMAGE}:${gitRef})`,
|
|
@@ -6762,7 +7259,7 @@ async function provisionPlatformHc(context, deps) {
|
|
|
6762
7259
|
// own rule. The image tag too, so a declared release change reaches the app.
|
|
6763
7260
|
await deps.coolify.updateApplication(appId, {
|
|
6764
7261
|
custom_internal_name: HC_INTERNAL_NAME,
|
|
6765
|
-
docker_registry_image_tag: gitRef,
|
|
7262
|
+
docker_registry_image_tag: pinnedTagFor('hc', gitRef),
|
|
6766
7263
|
...healthCheckSettings(HC_HEALTH_CHECK_PATH, Number(HC_PORT)),
|
|
6767
7264
|
});
|
|
6768
7265
|
// ---- env, parsed off the database's own owner credentials -----------------
|
|
@@ -7471,7 +7968,8 @@ async function provisionPlatformIntegrations(action, context, deps) {
|
|
|
7471
7968
|
keyJustSet ||
|
|
7472
7969
|
runnerKeyStale ||
|
|
7473
7970
|
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'}`);
|
|
7971
|
+
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'}`);
|
|
7972
|
+
await recordBuiltCommit(coolify, { appId: runnerApp.appId, deploymentUuid: built.deploymentUuid, name: 'integrations' }, record);
|
|
7475
7973
|
}
|
|
7476
7974
|
// #398: the runner is up with the keyring that holds the active id; only
|
|
7477
7975
|
// now may Archon move to it.
|
|
@@ -7602,7 +8100,7 @@ async function waitForIntegrationsMigration(coolify, appId, runtime, sleep) {
|
|
|
7602
8100
|
/** The name every platform database's backup is registered against -- one shared R2 bucket, one storage entry. */
|
|
7603
8101
|
const PLATFORM_BACKUP_S3_STORAGE_NAME = 'platform-backups';
|
|
7604
8102
|
/** 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';
|
|
8103
|
+
export const PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY = 'coolify_platform_backup_s3_storage';
|
|
7606
8104
|
/**
|
|
7607
8105
|
* #257 fix round: a sha256 of the exact `endpoint|bucket|key|secret` the
|
|
7608
8106
|
* storage was last pushed with -- never the values themselves, the same
|
|
@@ -7650,8 +8148,11 @@ const PLATFORM_BACKUP_DATABASE_NAMES = {
|
|
|
7650
8148
|
* {@link repointPlatformBackupSchedules} uses for `s3_storage_uuid`.
|
|
7651
8149
|
*/
|
|
7652
8150
|
async function findPlatformBackupSchedule(coolify, service, databaseId) {
|
|
8151
|
+
return findDatabaseBackupSchedule(coolify, PLATFORM_BACKUP_DATABASE_NAMES[service], databaseId);
|
|
8152
|
+
}
|
|
8153
|
+
/** {@link findPlatformBackupSchedule} for any database; `database` undefined means the resource's first database. */
|
|
8154
|
+
async function findDatabaseBackupSchedule(coolify, database, databaseId) {
|
|
7653
8155
|
const schedules = (await coolify.listDatabaseBackups(databaseId)).filter((backup) => backup.enabled && backup.save_s3);
|
|
7654
|
-
const database = PLATFORM_BACKUP_DATABASE_NAMES[service];
|
|
7655
8156
|
if (!database)
|
|
7656
8157
|
return { schedule: schedules[0], named: true };
|
|
7657
8158
|
for (const schedule of schedules) {
|
|
@@ -8027,23 +8528,58 @@ async function provisionPlatformBackup(action, context, deps) {
|
|
|
8027
8528
|
const recorded = context.recordedIds();
|
|
8028
8529
|
const storageUuid = await ensurePlatformBackupStorage(coolify, r2, recorded, context);
|
|
8029
8530
|
const service = action.key;
|
|
8030
|
-
|
|
8031
|
-
|
|
8032
|
-
|
|
8033
|
-
|
|
8531
|
+
await provisionDatabaseBackup(coolify, {
|
|
8532
|
+
databaseUuid: databaseId,
|
|
8533
|
+
label: `platform ${service}`,
|
|
8534
|
+
storageUuid,
|
|
8535
|
+
frequency: deps.frequency,
|
|
8536
|
+
databaseName: PLATFORM_BACKUP_DATABASE_NAMES[service],
|
|
8537
|
+
});
|
|
8538
|
+
return undefined;
|
|
8539
|
+
}
|
|
8540
|
+
/**
|
|
8541
|
+
* F6: one nightly schedule of one database to a registered S3 storage, the
|
|
8542
|
+
* body `provisionPlatformBackup` had and every company database now shares.
|
|
8543
|
+
* An existing enabled, `save_s3` schedule is reused, never doubled: the lookup
|
|
8544
|
+
* runs before the create, so a run that created a schedule and died before
|
|
8545
|
+
* the caller recorded its uuid finds it again on the next apply. The
|
|
8546
|
+
* `PATCH` names the database to dump, because `createDatabaseBackup` takes no
|
|
8547
|
+
* database name; it also repairs an existing schedule that dumps the wrong
|
|
8548
|
+
* one. Returns the schedule's uuid.
|
|
8549
|
+
*/
|
|
8550
|
+
export async function provisionDatabaseBackup(coolify, input) {
|
|
8551
|
+
const { databaseUuid, label } = input;
|
|
8552
|
+
const { schedule, named } = await backupCall(label, () => findDatabaseBackupSchedule(coolify, input.databaseName, databaseUuid));
|
|
8553
|
+
const uuid = schedule?.uuid ??
|
|
8554
|
+
(await backupCall(label, () => coolify.createDatabaseBackup(databaseUuid, {
|
|
8555
|
+
frequency: input.frequency ?? PLATFORM_BACKUP_DEFAULT_FREQUENCY,
|
|
8034
8556
|
enabled: true,
|
|
8035
8557
|
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.
|
|
8558
|
+
s3_storage_uuid: input.storageUuid,
|
|
8559
|
+
}))).uuid;
|
|
8041
8560
|
if (!named) {
|
|
8042
|
-
await coolify.raw('PATCH', `/databases/${
|
|
8043
|
-
databases_to_backup:
|
|
8044
|
-
});
|
|
8561
|
+
await backupCall(label, () => coolify.raw('PATCH', `/databases/${databaseUuid}/backups/${uuid}`, {
|
|
8562
|
+
databases_to_backup: input.databaseName,
|
|
8563
|
+
}));
|
|
8564
|
+
}
|
|
8565
|
+
return { uuid };
|
|
8566
|
+
}
|
|
8567
|
+
/** How long one Coolify call of a database backup may take. */
|
|
8568
|
+
const BACKUP_CALL_TIMEOUT_MS = 30_000;
|
|
8569
|
+
/** Runs one Coolify call with a timeout; a timeout names the database, not the call's arguments. */
|
|
8570
|
+
async function backupCall(label, call) {
|
|
8571
|
+
let timer;
|
|
8572
|
+
try {
|
|
8573
|
+
return await Promise.race([
|
|
8574
|
+
call(),
|
|
8575
|
+
new Promise((_, reject) => {
|
|
8576
|
+
timer = setTimeout(() => reject(new Error(`backup of ${label}: Coolify did not answer within ${BACKUP_CALL_TIMEOUT_MS / 1000} seconds`)), BACKUP_CALL_TIMEOUT_MS);
|
|
8577
|
+
}),
|
|
8578
|
+
]);
|
|
8579
|
+
}
|
|
8580
|
+
finally {
|
|
8581
|
+
clearTimeout(timer);
|
|
8045
8582
|
}
|
|
8046
|
-
return undefined;
|
|
8047
8583
|
}
|
|
8048
8584
|
/**
|
|
8049
8585
|
* #540: reconciles the live Healthchecks checks against the ones foundry
|
|
@@ -8126,7 +8662,10 @@ async function provisionPlatformHeartbeats(context, deps) {
|
|
|
8126
8662
|
// Last: so a run that failed anywhere above (an unreconciled create or
|
|
8127
8663
|
// delete) leaves this unset and `planHeartbeats` plans the step again --
|
|
8128
8664
|
// the same "record markers last" rule `provisionPlatformHc` follows.
|
|
8129
|
-
|
|
8665
|
+
// foundry#544: the provider is folded in, matching `planHeartbeats`'s own
|
|
8666
|
+
// `desired` -- a provider switch with the same check names otherwise
|
|
8667
|
+
// desired is still a change from this marker.
|
|
8668
|
+
context.recordScopeId(PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, `${context.platform.heartbeatsProvider}:${[...desiredNames].sort().join(',')}`);
|
|
8130
8669
|
return undefined;
|
|
8131
8670
|
}
|
|
8132
8671
|
/**
|