@wtfalch/foundry 0.60.0 → 0.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/apps/cloudflare.d.ts +173 -0
- package/dist/apps/cloudflare.js +17 -0
- package/dist/apps/deploy.d.ts +116 -0
- package/dist/apps/deploy.js +791 -0
- package/dist/apps/github.d.ts +47 -0
- package/dist/apps/github.js +201 -0
- package/dist/apps/index.d.ts +9 -0
- package/dist/apps/index.js +9 -0
- package/dist/apps/migrations.d.ts +110 -0
- package/dist/apps/migrations.js +458 -0
- package/dist/audit/connector.d.ts +43 -0
- package/dist/audit/connector.js +22 -0
- package/dist/audit/evidence.d.ts +49 -0
- package/dist/audit/evidence.js +56 -0
- package/dist/audit/index.d.ts +9 -0
- package/dist/audit/index.js +9 -0
- package/dist/audit/migrations.d.ts +5 -0
- package/dist/audit/migrations.js +5 -0
- package/dist/audit/registry.d.ts +49 -0
- package/dist/audit/registry.js +44 -0
- package/dist/audit/seal.d.ts +49 -0
- package/dist/audit/seal.js +131 -0
- package/dist/audit/sealer-role.d.ts +33 -0
- package/dist/audit/sealer-role.js +56 -0
- package/dist/audit/signer.d.ts +40 -0
- package/dist/audit/signer.js +78 -0
- package/dist/audit/tick.d.ts +36 -0
- package/dist/audit/tick.js +87 -0
- package/dist/catalogue/copy-en.d.ts +1 -0
- package/dist/catalogue/copy-en.js +129 -0
- package/dist/catalogue/index.d.ts +9 -0
- package/dist/catalogue/index.js +21 -0
- package/dist/catalogue/integrations.d.ts +4 -0
- package/dist/catalogue/integrations.js +104 -0
- package/dist/catalogue/systems.d.ts +7 -0
- package/dist/catalogue/systems.js +31 -0
- package/dist/catalogue/types.d.ts +110 -0
- package/dist/catalogue/types.js +1 -0
- package/dist/entry-rules.d.ts +53 -0
- package/dist/entry-rules.js +71 -0
- package/dist/hosted-label.d.ts +13 -0
- package/dist/hosted-label.js +81 -0
- package/dist/index.d.ts +10 -5
- package/dist/index.js +5 -3
- package/dist/migrations/0062_hosting_github.sql +6 -0
- package/dist/migrations/0063_boule_apps.sql +7 -0
- package/dist/migrations/0064_requested_changes_boule_apps.sql +6 -0
- package/dist/modules/ai.d.ts +3 -0
- package/dist/modules/ai.js +64 -0
- package/dist/modules/assistants.d.ts +3 -0
- package/dist/modules/assistants.js +72 -0
- package/dist/modules/audit-ledger.d.ts +29 -0
- package/dist/modules/audit-ledger.js +49 -0
- package/dist/modules/audit.d.ts +7 -0
- package/dist/modules/audit.js +44 -0
- package/dist/modules/database.d.ts +23 -0
- package/dist/modules/database.js +189 -0
- package/dist/modules/email.d.ts +3 -0
- package/dist/modules/email.js +18 -0
- package/dist/modules/entry-parts.d.ts +23 -0
- package/dist/modules/entry-parts.js +63 -0
- package/dist/modules/files.d.ts +3 -0
- package/dist/modules/files.js +64 -0
- package/dist/modules/index.d.ts +74 -0
- package/dist/modules/index.js +774 -0
- package/dist/modules/jobs.d.ts +3 -0
- package/dist/modules/jobs.js +23 -0
- package/dist/modules/organisations.d.ts +3 -0
- package/dist/modules/organisations.js +20 -0
- package/dist/modules/outputs.d.ts +9 -0
- package/dist/modules/outputs.js +38 -0
- package/dist/modules/sign-in.d.ts +3 -0
- package/dist/modules/sign-in.js +197 -0
- package/dist/modules/signed-records.d.ts +3 -0
- package/dist/modules/signed-records.js +32 -0
- package/dist/modules/types.d.ts +194 -0
- package/dist/modules/types.js +1 -0
- package/dist/modules/workspaces.d.ts +3 -0
- package/dist/modules/workspaces.js +75 -0
- package/dist/org.d.ts +163 -22
- package/dist/org.js +419 -47
- package/dist/plan/apps-db-route.d.ts +90 -0
- package/dist/plan/apps-db-route.js +168 -0
- package/dist/plan/apps-zones.d.ts +56 -0
- package/dist/plan/apps-zones.js +118 -0
- package/dist/plan/archon-blue-green.js +11 -1
- package/dist/plan/blue-green-apply.d.ts +37 -1
- package/dist/plan/blue-green-apply.js +55 -15
- package/dist/plan/blue-green.d.ts +41 -1
- package/dist/plan/blue-green.js +80 -2
- package/dist/plan/boule.d.ts +23 -1
- package/dist/plan/boule.js +35 -2
- package/dist/plan/broker-entries.d.ts +28 -3
- package/dist/plan/broker-entries.js +74 -3
- package/dist/plan/broker-registrar.d.ts +168 -0
- package/dist/plan/broker-registrar.js +440 -0
- package/dist/plan/broker-workspaces-database.d.ts +48 -0
- package/dist/plan/broker-workspaces-database.js +87 -0
- package/dist/plan/desktop-callback.js +12 -7
- package/dist/plan/diff.d.ts +8 -2
- package/dist/plan/diff.js +373 -42
- package/dist/plan/edge-access.d.ts +6 -0
- package/dist/plan/edge-dns.d.ts +6 -0
- package/dist/plan/edge-dns.js +9 -7
- package/dist/plan/entry-ai-key.d.ts +1 -1
- package/dist/plan/entry-ai-key.js +2 -2
- package/dist/plan/entry-audit.d.ts +58 -0
- package/dist/plan/entry-audit.js +122 -0
- package/dist/plan/entry-customer-grants.d.ts +75 -0
- package/dist/plan/entry-customer-grants.js +154 -0
- package/dist/plan/entry-edge-key.d.ts +26 -1
- package/dist/plan/entry-edge-key.js +42 -3
- package/dist/plan/entry-jobs.d.ts +64 -0
- package/dist/plan/entry-jobs.js +92 -0
- package/dist/plan/entry-repo-key-apply.d.ts +55 -0
- package/dist/plan/entry-repo-key-apply.js +185 -0
- package/dist/plan/entry-repo-key.d.ts +92 -0
- package/dist/plan/entry-repo-key.js +235 -0
- package/dist/plan/entry-workspaces.d.ts +55 -0
- package/dist/plan/entry-workspaces.js +90 -0
- package/dist/plan/executor.d.ts +191 -1
- package/dist/plan/executor.js +1505 -90
- package/dist/plan/gates.d.ts +1 -1
- package/dist/plan/gates.js +62 -9
- package/dist/plan/github-deploy-keys.d.ts +78 -0
- package/dist/plan/github-deploy-keys.js +305 -0
- package/dist/plan/github-source.d.ts +53 -0
- package/dist/plan/github-source.js +140 -0
- package/dist/plan/held-key-refs.d.ts +12 -0
- package/dist/plan/held-key-refs.js +12 -0
- package/dist/plan/hostnames.d.ts +9 -0
- package/dist/plan/hostnames.js +28 -1
- package/dist/plan/index.d.ts +9 -0
- package/dist/plan/index.js +6 -0
- package/dist/plan/jobs-scheduler-http.js +3 -0
- package/dist/plan/keys-registrar-http.d.ts +32 -0
- package/dist/plan/keys-registrar-http.js +105 -0
- package/dist/plan/keys-registrar.d.ts +67 -0
- package/dist/plan/keys-registrar.js +103 -0
- package/dist/plan/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.d.ts +2 -2
- package/dist/plan/locked-hostnames.js +2 -2
- package/dist/plan/login-host.js +11 -5
- package/dist/plan/machine-paths.d.ts +36 -0
- package/dist/plan/machine-paths.js +137 -0
- package/dist/plan/mcp.d.ts +1 -1
- package/dist/plan/mcp.js +49 -17
- package/dist/plan/org-apps-admin.d.ts +108 -0
- package/dist/plan/org-apps-admin.js +434 -0
- package/dist/plan/org-names.js +12 -1
- package/dist/plan/own-diff.d.ts +23 -0
- package/dist/plan/own-diff.js +53 -0
- package/dist/plan/own-hostnames.d.ts +2 -0
- package/dist/plan/own-hostnames.js +1 -0
- package/dist/plan/own-jobs-route.d.ts +47 -0
- package/dist/plan/own-jobs-route.js +91 -0
- package/dist/plan/own-services.d.ts +4 -0
- package/dist/plan/own-services.js +4 -0
- package/dist/plan/plan-apply.d.ts +39 -0
- package/dist/plan/plan-apply.js +163 -9
- package/dist/plan/platform-diff.d.ts +2 -2
- package/dist/plan/platform-diff.js +7 -2
- package/dist/plan/platform-org.d.ts +30 -0
- package/dist/plan/platform-org.js +12 -1
- package/dist/plan/platform-providers.d.ts +2 -0
- package/dist/plan/platform-providers.js +7 -2
- package/dist/plan/post-logout-uris.js +7 -1
- package/dist/plan/private-pages.d.ts +26 -0
- package/dist/plan/private-pages.js +39 -0
- package/dist/plan/proxy-labels.d.ts +12 -1
- package/dist/plan/proxy-labels.js +32 -2
- package/dist/plan/release-host.d.ts +6 -2
- package/dist/plan/release-host.js +36 -8
- package/dist/plan/stage-env.d.ts +19 -1
- package/dist/plan/stage-env.js +25 -1
- package/dist/plan/stage-guards.d.ts +20 -0
- package/dist/plan/stage-guards.js +24 -0
- package/dist/plan/stage-isolation.d.ts +24 -0
- package/dist/plan/stage-isolation.js +29 -1
- package/dist/plan/worker-app-database.d.ts +46 -42
- package/dist/plan/worker-app-database.js +150 -153
- package/dist/plan/worker-app.d.ts +5 -65
- package/dist/plan/worker-app.js +28 -105
- package/dist/plan/worker.d.ts +2 -2
- package/dist/plan/worker.js +2 -1
- package/dist/plan/workspace-client.d.ts +25 -0
- package/dist/plan/workspace-client.js +48 -0
- package/dist/plan/workspace-sign-in.d.ts +55 -0
- package/dist/plan/workspace-sign-in.js +273 -0
- package/dist/plan/workspace-worker.d.ts +4 -0
- package/dist/plan/workspace-worker.js +13 -0
- package/dist/plan/workspace.d.ts +101 -0
- package/dist/plan/workspace.js +305 -0
- package/dist/release/decide.d.ts +30 -1
- package/dist/release/decide.js +73 -11
- package/dist/release/health.d.ts +8 -3
- package/dist/release/health.js +58 -5
- package/dist/release/run.d.ts +2 -5
- package/dist/release/run.js +4 -0
- package/dist/release/state.d.ts +3 -1
- package/dist/release/state.js +6 -0
- package/dist/release/types.d.ts +20 -0
- package/dist/requested-changes.d.ts +11 -2
- package/dist/requested-changes.js +11 -0
- package/dist/service-labels.d.ts +4 -0
- package/dist/service-labels.js +11 -0
- package/dist/slug.d.ts +2 -0
- package/dist/slug.js +10 -6
- package/dist/store/postgres.js +12 -2
- package/dist/store/schema.d.ts +37 -1
- package/dist/store/schema.js +4 -0
- package/dist/worker-app-config.d.ts +1 -11
- package/dist/worker-app-config.js +4 -17
- package/dist/worker-app-migrations.d.ts +105 -0
- package/dist/worker-app-migrations.js +166 -0
- package/dist/worker-app-upload.d.ts +103 -0
- package/dist/worker-app-upload.js +128 -0
- package/package.json +18 -1
package/dist/plan/executor.js
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
2
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
3
3
|
import { URL } from 'node:url';
|
|
4
|
+
import { EdgeApiError } from '@wtfalch/edge';
|
|
4
5
|
import { coolifyImageFor } from '../bootstrap/versions.js';
|
|
5
6
|
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
6
7
|
import { resolveCallsSfuUrls } from '../calls-sfu.js';
|
|
7
|
-
import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, filesCollectionEnvName, heldEnvRef, isMainEntry, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
|
|
8
|
+
import { APPS_DOMAIN, APP_ENTRY_KEY, FULL_COMMIT_SHA, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, appsOn, companyGithubFor, filesCollectionEnvName, heldEnvRef, isLiveEntry, isMainEntry, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
|
|
8
9
|
import { adoptedKeysUnreadable, parseAdoptSnapshot } from './adopt.js';
|
|
9
10
|
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, DESKTOP_AUTH_PORT, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatCallsApplied, chatHasUpstreams, chatIntrospectionAppName, desiredAgoraEnv, desktopAuthInternalName, entryClientIdScopeKey, } from './agora.js';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
11
|
+
import { APPS_DATABASE_ADMIN_URL_ENV, applyAppsDbRoute, retireAppsDbRoute, } from './apps-db-route.js';
|
|
12
|
+
import { APPS_ZONES_APPLIED_SCOPE_KEY, applyAppsZones, retireAppsZones } from './apps-zones.js';
|
|
13
|
+
import { applyBlueGreenConnectors, applyBlueGreenServer, applyBlueGreenWeights, applyWorkspaceServer, } from './blue-green-apply.js';
|
|
14
|
+
import { actionsKeyName, bgKey, blueGreenSubjects, colorAppName, healthTokenName, liveAppNameFor, mintActionsKey, mintPinKey, pinKeyName, splitServers, } from './blue-green.js';
|
|
12
15
|
import { BouleOwnersError, httpBouleOwners } from './boule-owners-http.js';
|
|
13
|
-
import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_CUSTOMERS_ORG_ENV, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_OWNER_SEED_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, CUSTOMERS_ROOM_METADATA_KEY, CUSTOMERS_ROOM_SCOPE_KEY, CUSTOMERS_SIGN_IN_METADATA_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleArchonCredentialRef, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, bouleOwnerRefusedScopeKey, bouleOwnerSeededScopeKey, bouleOwnerSeeds, customersDirectoryRef, customersOrgIdOf, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
14
|
-
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, mcpCallbackUri, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
16
|
+
import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_CUSTOMERS_ORG_ENV, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_OWNER_SEED_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, BOULE_TEST_CUSTOMERS_ORG_ENV, CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, CUSTOMERS_ROOM_METADATA_KEY, CUSTOMERS_ROOM_SCOPE_KEY, CUSTOMERS_SIGN_IN_METADATA_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleArchonCredentialRef, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, bouleOwnerRefusedScopeKey, bouleOwnerSeededScopeKey, bouleOwnerSeeds, customersDirectoryRef, customersOrgIdOf, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
17
|
+
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, entryMcp, entryMcpProjectScopeKey, mcpCallbackUri, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
18
|
+
import { AUTH_REGISTRAR_KEY_ENV, AUTH_REGISTRAR_URL_ENV, REGISTRAR_APPLIED_SCOPE_KEY, REGISTRAR_DELIVERED_SCOPE_KEY, REGISTRAR_DEPLOY_WAITS_SCOPE_KEY, REGISTRAR_MINTED_SCOPE_KEY, REGISTRAR_REMOVAL_HELD_SCOPE_KEY, appliedRegistrarOrganization, ensureRegistrarKey, registrarAppliedValue, registrarRef, registrarWanted, wantedRegistrar, writeRegistrar, } from './broker-registrar.js';
|
|
19
|
+
import { BROKER_WORKSPACES_DATABASE_SCOPE_KEY, canHoldWorkspacesDatabase, ensureBrokerWorkspacesDatabase, } from './broker-workspaces-database.js';
|
|
15
20
|
import { recordBuiltCommit } from './build-record.js';
|
|
21
|
+
import { CallError, withTimeout as withLabelledTimeout } from './call-timeout.js';
|
|
16
22
|
import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, masDockerfile, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
17
23
|
import { CHAT_MAS_PORT, CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, assertChatServerName, chatInternalNames, chatMasConfigYaml, chatRekeyedInternalNames, chatServerName, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
|
|
18
24
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
@@ -23,7 +29,7 @@ import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FI
|
|
|
23
29
|
import { waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
|
|
24
30
|
import { DESKTOP_AUTH_APP_SCOPE_KEY, DESKTOP_AUTH_DEPLOYED_SCOPE_KEY, DESKTOP_AUTH_DOCKERFILE, DESKTOP_AUTH_HOME, DESKTOP_AUTH_ORIGIN_SCOPE_KEY, DESKTOP_AUTH_READY_PATH, DESKTOP_AUTH_READY_SCOPE_KEY, DESKTOP_AUTH_REDIRECT_SCOPE_KEY, DESKTOP_AUTH_REVISION_SCOPE_KEY, desktopAuthCallbackUri, desktopAuthEnv, desktopAuthInputs, desktopAuthRedirectUnsupported, desktopAuthRevision, } from './desktop-auth.js';
|
|
25
31
|
import { signInCallbackAppliedValue, signInRedirectUris, unionRedirectUris, zitadelAppDesktopCallbackAppliedScopeKey, } from './desktop-callback.js';
|
|
26
|
-
import { PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, onPlatformNetwork, placed, placedByScope, } from './destinations.js';
|
|
32
|
+
import { PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, onPlatformNetwork, placed, placedByScope, placedOnPlatform, } from './destinations.js';
|
|
27
33
|
import { DEFAULT_AI_LIMIT_MICROS } from './diff.js';
|
|
28
34
|
import { createEdgeAccess, isNotFound as isEdgeNotFound, } from './edge-access.js';
|
|
29
35
|
import { EDGE_DNS_WRITE_CONCURRENCY, createEdgeDns, limiter, } from './edge-dns.js';
|
|
@@ -31,29 +37,39 @@ import { createEdgeRateLimits, } from './edge-ratelimits.js';
|
|
|
31
37
|
import { createEdgeTunnels } from './edge-tunnels.js';
|
|
32
38
|
import { createEdgeZones } from './edge-zones.js';
|
|
33
39
|
import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScopeKey, entryAiExpiresScopeKey, entryAiPendingExpiresScopeKey, entryAiRetiredScopeKey, entryAiUndeliveredScopeKey, entryAiUnrevokedScopeKey, entryAiWebhookPendingScopeKey, entryAiWrittenScopeKey, parseIdList, } from './entry-ai-key.js';
|
|
34
|
-
import {
|
|
40
|
+
import { applyEntryAudit } from './entry-audit.js';
|
|
41
|
+
import { CUSTOMER_GRANTS_CREDENTIAL_ENV, CUSTOMER_GRANTS_ENV, CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY, customerGrantsEntries, customerGrantsJson, customerGrantsMintedScopeKey, customerGrantsRef, customerGrantsToDrop, ensureCustomerGrantsCredential, wantedCustomerGrants, } from './entry-customer-grants.js';
|
|
42
|
+
import { BOULE_EDGE_ENTRY_KEY, EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, EDGE_SLUG_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, bouleEdgeKeyWanted, edgeKeyAppRecordKey, edgeKeyDomain, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
|
|
35
43
|
import { FILES_AGORA_KEY_SCOPE_KEY, FILES_AGORA_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, FILES_ORG_OWNER_SCOPE_KEY, FILES_ROOT_EXPIRES_SCOPE_KEY, FILES_ROOT_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, entryFilesAppliedScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, parseIdList as parseFilesIdList, } from './entry-files-key.js';
|
|
44
|
+
import { entryJobsAppliedScopeKey, entryJobsRefs, entryJobsUndeliveredScopeKey, entryJobsWantMarker, } from './entry-jobs.js';
|
|
45
|
+
import { ensureRepoKey, removeRepoKey } from './entry-repo-key-apply.js';
|
|
46
|
+
import { BROKER_WORKSPACES_DATABASE_ENV, ensureWorkspacesKey, heldWorkspacesDatabase, heldWorkspacesKey, workspacesDatabaseRef, workspacesKeyHash, workspacesKeyRef, } from './entry-workspaces.js';
|
|
36
47
|
import { GATE_CLAIM_NAME, gateHostnamesScopeKey, gateIdRecordKey, isBehindGate } from './gates.js';
|
|
48
|
+
import { GITHUB_SOURCE_INSTALLATION_SCOPE_KEY, GITHUB_SOURCE_TIMEOUT_MS, ensureGithubSource, githubSourceResourceKey, } from './github-source.js';
|
|
37
49
|
import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, wgetProbe, } from './health-check-settings.js';
|
|
38
50
|
import { PROXY_LABEL_TOKEN_REF } from './held-key-refs.js';
|
|
39
|
-
import { appHostname, cmsStudioHostnameFor, coolifyTunnelDomains, entryHostnames, siteHostnameFor, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
|
|
51
|
+
import { appHostname, cmsStudioHostnameFor, coolifyTunnelDomains, entryHostnames, siteHostnameFor, tunnelDnsHostnames, tunnelIngressHostnames, workspaceHost, } from './hostnames.js';
|
|
52
|
+
import { KEYS_REGISTRAR_DELIVERY_ATTEMPTS, KEYS_REGISTRAR_KEY_DAYS, KEYS_REGISTRAR_KEY_ENV, KEYS_REGISTRAR_MINTED_AT_SCOPE_KEY, KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY, KEYS_REGISTRAR_SCOPE_KEY, KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY, KEYS_REGISTRAR_URL_APPLIED_SCOPE_KEY, KEYS_REGISTRAR_URL_ENV, } from './keys-registrar.js';
|
|
40
53
|
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_CLIENT_ORGS_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, OWN_CUSTOMERS_BASE_URI_SCOPE_KEY, clientOrganizationIds, clientOrganizationsState, customersClientOrganizations, customersEntryKeys, loginBindingState, loginHostClientIds, ownAuthApplied, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
54
|
+
import { machineBypassScopeKey } from './machine-paths.js';
|
|
41
55
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
42
56
|
import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_SCOPE_KEY, isOwnMailSmtpPort, normalizeMailTarget, } from './mail-relay.js';
|
|
43
57
|
import { applyAccessTokenLifetime, applyMcpAccess, applyMcpProject } from './mcp.js';
|
|
44
58
|
import { NETWORK_APPLIED_SCOPE_KEY } from './network-move.js';
|
|
59
|
+
import { provisionOrgAppsAdmin, retireOrgAppsAdmin, } from './org-apps-admin.js';
|
|
45
60
|
import { ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationNotConfirmedError, migrateHomeOf, migrateOrgPostgresHome, parseMigrationConfirmation, } from './org-postgres-migrate.js';
|
|
46
61
|
import { orgPostgresSecretRef, provisionOrgPostgresDatabases, provisionOrgPostgresServer, serviceEndpoint, serviceOnServer, serviceUrls, } from './org-postgres.js';
|
|
47
62
|
import { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, } from './own-diff.js';
|
|
48
63
|
import { NO_DOMAIN_ZONE, ownHostnames } from './own-hostnames.js';
|
|
64
|
+
import { provisionJobsRoute } from './own-jobs-route.js';
|
|
49
65
|
import { LIMITS, limited } from './own-limits.js';
|
|
50
|
-
import { OWN_FILES_R2_SCOPE_KEY, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, OwnServicesBlockedError, orgNetwork, orgTarget, scopeFromOrg, } from './own-services.js';
|
|
66
|
+
import { OWN_FILES_R2_SCOPE_KEY, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, OwnServicesBlockedError, orgNetwork, orgTarget, platformTarget, scopeFromOrg, } from './own-services.js';
|
|
51
67
|
import { ownSfuUrls, provisionSfuApp } from './own-sfu.js';
|
|
52
|
-
import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, } from './platform-diff.js';
|
|
68
|
+
import { JOBS_INTERNAL_URL_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, } from './platform-diff.js';
|
|
53
69
|
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appFolderDiffers, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, orgDeclaresMcp, resolveAppEnv, serviceDbEnvAppliedScopeKey, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
|
|
54
|
-
import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_MAIL_SERVER_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionDatabaseBackup, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
|
|
70
|
+
import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_AUTH_PROJECT, PLATFORM_MAIL_SERVER_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionDatabaseBackup, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
|
|
55
71
|
import { companyAppPostLogoutUris, postLogoutAppliedValue, zitadelAppPostLogoutAppliedScopeKey, } from './post-logout-uris.js';
|
|
56
|
-
import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, ENTRY_CONTAINER_LABELS, appLabelsAppliedScopeKey, builtinRoutes, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryProxyLabels, entryRoutes, entryRoutesMarker, ownsOikosName, routesWithoutToken, sameLabelSets, } from './proxy-labels.js';
|
|
72
|
+
import { CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY, CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, ENTRY_CONTAINER_LABELS, appLabelsAppliedScopeKey, builtinRoutes, entryAppsHostnames, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryProxyLabels, entryRoutes, entryRoutesMarker, ownsAppsZoneName, ownsOikosName, routesWithoutToken, sameLabelSets, } from './proxy-labels.js';
|
|
57
73
|
import { createReleaseHost } from './release-host.js';
|
|
58
74
|
import { TUNNEL_HTTP_INGRESS_SCOPE_KEY, applyRemoteDatabase, mergeTcpIngress, recordedTcpRoutes, } from './remote-database.js';
|
|
59
75
|
import { editSignInBindings } from './sign-in-bindings.js';
|
|
@@ -64,6 +80,9 @@ import { stageGateKey, stageHostname, } from './stage-guards.js';
|
|
|
64
80
|
import { WORKER_APP_DB_SCOPE_KEYS, provisionWorkerAppDb, workerAppDbCaPem, workerAppDbOwnerUrl, } from './worker-app-db.js';
|
|
65
81
|
import { WORKER_APP_CALL_TIMEOUT_MS, applyWorkerApp, heldBrokerKey, teardownWorkerApp, withCallTimeout, } from './worker-app.js';
|
|
66
82
|
import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
|
|
83
|
+
import { TEST_CUSTOMERS_SIGN_IN_SCOPE_KEY, TEST_CUSTOMERS_SIGN_UP_SCOPE_KEY, testCustomersOrgName, workspaceAuthEntryOf, workspaceClientHosts, workspaceClientKey, } from './workspace-client.js';
|
|
84
|
+
import { liveClientIds, wantedWorkspaceBrokerEntry, workspaceAuthEnv, workspaceSignInMarker, } from './workspace-sign-in.js';
|
|
85
|
+
import { gatePeopleScopeKey, isServerWorkspace, peopleHash, readWorkspaceCommit, withWorkspaceWorkers, workspaceGateKey, workspaceStructureHash, workspaceSubject, wsKey, } from './workspace.js';
|
|
67
86
|
/**
|
|
68
87
|
* The real {@link StepExecutor} (#20 "provision Company Factory end to
|
|
69
88
|
* end"): makes each {@link StepAction} `diff.ts` plans true against a real
|
|
@@ -116,9 +135,24 @@ export function adaptZitadelListApps(client) {
|
|
|
116
135
|
}
|
|
117
136
|
/** An outside call for Boule's credentials that has not answered in this long fails the step; the next apply repairs it. */
|
|
118
137
|
const BOULE_CREDENTIAL_TIMEOUT_MS = 60_000;
|
|
138
|
+
/** One read of the broker's env. */
|
|
139
|
+
const BROKER_ENV_READ_TIMEOUT_MS = 15_000;
|
|
140
|
+
const HELD_KEY_READ_TIMEOUT_MS = 15_000;
|
|
119
141
|
const BOULE_CREDENTIAL_ATTEMPTS = 3;
|
|
120
142
|
/** The ZITADEL machine user that reads one company's customers organisation. */
|
|
121
143
|
const CUSTOMERS_DIRECTORY_USER_NAME = 'foundry-customers-directory';
|
|
144
|
+
const LIVE_CUSTOMERS = {
|
|
145
|
+
step: 'zitadel:customers',
|
|
146
|
+
name: (slug) => `${slug} customers`,
|
|
147
|
+
signUpKey: CUSTOMERS_SIGN_UP_SCOPE_KEY,
|
|
148
|
+
signInKey: CUSTOMERS_SIGN_IN_SCOPE_KEY,
|
|
149
|
+
};
|
|
150
|
+
const TEST_CUSTOMERS = {
|
|
151
|
+
step: 'zitadel:customers-test',
|
|
152
|
+
name: testCustomersOrgName,
|
|
153
|
+
signUpKey: TEST_CUSTOMERS_SIGN_UP_SCOPE_KEY,
|
|
154
|
+
signInKey: TEST_CUSTOMERS_SIGN_IN_SCOPE_KEY,
|
|
155
|
+
};
|
|
122
156
|
// ---------------------------------------------------------------------------
|
|
123
157
|
// Errors
|
|
124
158
|
// ---------------------------------------------------------------------------
|
|
@@ -859,6 +893,10 @@ async function withTimeout(work, ms) {
|
|
|
859
893
|
clearTimeout(timer);
|
|
860
894
|
}
|
|
861
895
|
}
|
|
896
|
+
/** How long one call to the jobs scheduler or the held keys, made for an entry's project, may take. */
|
|
897
|
+
const JOBS_PROJECT_CALL_TIMEOUT_MS = 30_000;
|
|
898
|
+
/** How many times an entry's project credentials are tried against the held keys. */
|
|
899
|
+
const JOBS_PROJECT_HOLD_ATTEMPTS = 3;
|
|
862
900
|
/** How long one Lettermint call of an own company's sign-in mint may take. */
|
|
863
901
|
const LETTERMINT_TIMEOUT_MS = 60_000;
|
|
864
902
|
const OWN_SERVICES_PORT_ABSENT = 'this org runs its own services and the host has no ownServices port for it';
|
|
@@ -889,7 +927,11 @@ function limitProfileOf(action) {
|
|
|
889
927
|
case 'coolify:app':
|
|
890
928
|
case 'coolify:database':
|
|
891
929
|
return LIMITS.companyApp;
|
|
930
|
+
case 'coolify:github-source':
|
|
931
|
+
return undefined;
|
|
892
932
|
case 'coolify:broker-entry':
|
|
933
|
+
case 'coolify:workspace-broker-entry':
|
|
934
|
+
case 'coolify:workspaces-database':
|
|
893
935
|
return LIMITS.brokerEntry;
|
|
894
936
|
case 'cms:site':
|
|
895
937
|
return LIMITS.cms;
|
|
@@ -905,8 +947,14 @@ function limitProfileOf(action) {
|
|
|
905
947
|
case 'coolify:boule':
|
|
906
948
|
case 'coolify:boule-broker-entry':
|
|
907
949
|
return LIMITS.boule;
|
|
950
|
+
case 'coolify:broker-registrar':
|
|
951
|
+
return LIMITS.brokerEntry;
|
|
952
|
+
case 'coolify:keys-registrar':
|
|
953
|
+
return LIMITS.boule;
|
|
908
954
|
case 'coolify:bluegreen':
|
|
909
955
|
case 'coolify:bluegreen-lb':
|
|
956
|
+
case 'coolify:workspace':
|
|
957
|
+
case 'coolify:promote':
|
|
910
958
|
return LIMITS.companyApp;
|
|
911
959
|
case 'cloudflare:tunnel':
|
|
912
960
|
case 'coolify:bluegreen-connector':
|
|
@@ -1087,6 +1135,58 @@ export class FoundryStepExecutor {
|
|
|
1087
1135
|
throw e;
|
|
1088
1136
|
}
|
|
1089
1137
|
}
|
|
1138
|
+
/**
|
|
1139
|
+
* Where the workspaces database of the broker this org's entries are written to already is: in the broker's
|
|
1140
|
+
* `WORKSPACES_DATABASE_URL` (`inEnv`) or as a URL held at the org's ref (`held`). The company's own broker for an
|
|
1141
|
+
* org that owns its services, the shared one otherwise. Throws when the broker is not wired or a read fails,
|
|
1142
|
+
* times out, or finds a held value that is no postgres URL, so a caller never takes "unknown" for "absent".
|
|
1143
|
+
*/
|
|
1144
|
+
async workspacesDatabaseSources(org, recordedIds) {
|
|
1145
|
+
const broker = ownsServices(org)
|
|
1146
|
+
? (await this.ports.ownServices?.portsFor(org, recordedIds))?.broker
|
|
1147
|
+
: this.ports.broker;
|
|
1148
|
+
if (!broker)
|
|
1149
|
+
throw new Error('the broker port is not wired');
|
|
1150
|
+
const envs = await withLabelledTimeout('the broker env read', BROKER_ENV_READ_TIMEOUT_MS, () => broker.listEnvs());
|
|
1151
|
+
if (envs.some((e) => e.key === BROKER_WORKSPACES_DATABASE_ENV && !e.is_preview && e.value)) {
|
|
1152
|
+
return { inEnv: true, held: false };
|
|
1153
|
+
}
|
|
1154
|
+
const held = await withLabelledTimeout('the held key read', HELD_KEY_READ_TIMEOUT_MS, () => heldWorkspacesDatabase(this.ports.heldKeys, workspacesDatabaseRef(org)));
|
|
1155
|
+
return { inEnv: false, held: Boolean(held) };
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Whether the broker this org's entries are written to has `WORKSPACES_DATABASE_URL`, or a URL is held to give it.
|
|
1159
|
+
* A read that fails or times out answers `false`, so the plan does not write an entry the broker cannot start
|
|
1160
|
+
* with.
|
|
1161
|
+
*/
|
|
1162
|
+
async brokerHasWorkspacesDatabase(org, recordedIds) {
|
|
1163
|
+
try {
|
|
1164
|
+
const { inEnv, held } = await this.workspacesDatabaseSources(org, recordedIds);
|
|
1165
|
+
return inEnv || held;
|
|
1166
|
+
}
|
|
1167
|
+
catch {
|
|
1168
|
+
// Not readable (blocked own services, a timeout, a failed call, a held value that is no URL): the plan reports the entry blocked.
|
|
1169
|
+
return false;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Whether foundry may make the broker's workspaces database: it can hold the URL, and a read just showed that
|
|
1174
|
+
* neither the broker nor the held keys have one. A read that fails answers `false`, never "make one": a database
|
|
1175
|
+
* made over a broker whose own could not be read would replace it.
|
|
1176
|
+
*/
|
|
1177
|
+
async canProvisionWorkspacesDatabase(org, recordedIds) {
|
|
1178
|
+
if (!canHoldWorkspacesDatabase(this.ports.heldKeys))
|
|
1179
|
+
return false;
|
|
1180
|
+
if (ownsServices(org) && !this.ports.ownServices)
|
|
1181
|
+
return false;
|
|
1182
|
+
try {
|
|
1183
|
+
const { inEnv, held } = await this.workspacesDatabaseSources(org, recordedIds);
|
|
1184
|
+
return !inEnv && !held;
|
|
1185
|
+
}
|
|
1186
|
+
catch {
|
|
1187
|
+
return false;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1090
1190
|
/** #555: the edge-routed ports this executor was built with itself (they win over edge). */
|
|
1091
1191
|
ownEdgePorts() {
|
|
1092
1192
|
return {
|
|
@@ -1165,6 +1265,8 @@ export class FoundryStepExecutor {
|
|
|
1165
1265
|
return this.zitadelDesktopCallback(action, org, context);
|
|
1166
1266
|
case 'zitadel:customers':
|
|
1167
1267
|
return this.zitadelCustomers(action, org, context);
|
|
1268
|
+
case 'zitadel:customers-test':
|
|
1269
|
+
return this.zitadelCustomers(action, org, context, TEST_CUSTOMERS);
|
|
1168
1270
|
case 'zitadel:mcp-project':
|
|
1169
1271
|
return this.zitadelMcpProject(action, org, context);
|
|
1170
1272
|
case 'zitadel:mcp-access':
|
|
@@ -1173,14 +1275,24 @@ export class FoundryStepExecutor {
|
|
|
1173
1275
|
return this.zitadelAccessTokenLifetime(context);
|
|
1174
1276
|
case 'coolify:app':
|
|
1175
1277
|
return this.coolifyApp(action, org, context);
|
|
1278
|
+
case 'coolify:github-source':
|
|
1279
|
+
return this.coolifyGithubSource(org, context);
|
|
1176
1280
|
case 'coolify:network':
|
|
1177
1281
|
return this.coolifyNetwork(action, org, context);
|
|
1178
1282
|
case 'coolify:bluegreen':
|
|
1179
1283
|
case 'coolify:bluegreen-lb':
|
|
1180
1284
|
case 'coolify:bluegreen-connector':
|
|
1181
1285
|
return this.coolifyBlueGreen(action, org, context);
|
|
1286
|
+
case 'coolify:workspace':
|
|
1287
|
+
return this.coolifyWorkspace(action, org, context);
|
|
1288
|
+
case 'coolify:promote':
|
|
1289
|
+
return this.coolifyPromote(action, org, context);
|
|
1182
1290
|
case 'coolify:broker-entry':
|
|
1183
1291
|
return this.coolifyBrokerEntry(action, org, context);
|
|
1292
|
+
case 'coolify:workspace-broker-entry':
|
|
1293
|
+
return this.coolifyWorkspaceBrokerEntry(action, org, context);
|
|
1294
|
+
case 'coolify:workspaces-database':
|
|
1295
|
+
return this.coolifyWorkspacesDatabase(org, context);
|
|
1184
1296
|
case 'coolify:database':
|
|
1185
1297
|
return this.coolifyDatabase(action, org, context);
|
|
1186
1298
|
case 'coolify:org-postgres':
|
|
@@ -1189,6 +1301,12 @@ export class FoundryStepExecutor {
|
|
|
1189
1301
|
return this.orgPostgresDatabases(action, org, context);
|
|
1190
1302
|
case 'coolify:org-postgres-migrate':
|
|
1191
1303
|
return this.orgPostgresMigrate(action, org, context);
|
|
1304
|
+
case 'coolify:org-apps-admin':
|
|
1305
|
+
return this.orgAppsAdmin(action, org, context);
|
|
1306
|
+
case 'cloudflare:apps-db-route':
|
|
1307
|
+
return this.appsDbRoute(action, org, context);
|
|
1308
|
+
case 'cloudflare:apps-zones':
|
|
1309
|
+
return this.appsZones(action, org, context);
|
|
1192
1310
|
case 'coolify:backup':
|
|
1193
1311
|
return this.coolifyBackup(action, org, context);
|
|
1194
1312
|
case 'coolify:calls':
|
|
@@ -1202,7 +1320,7 @@ export class FoundryStepExecutor {
|
|
|
1202
1320
|
case 'cloudflare:zone':
|
|
1203
1321
|
return this.cloudflareZone(action, org, context);
|
|
1204
1322
|
case 'cloudflare:access':
|
|
1205
|
-
return this.cloudflareAccess(action, org);
|
|
1323
|
+
return this.cloudflareAccess(action, org, context);
|
|
1206
1324
|
case 'cloudflare:gate':
|
|
1207
1325
|
return this.cloudflareGate(action, org, context);
|
|
1208
1326
|
case 'cloudflare:ratelimit':
|
|
@@ -1214,7 +1332,7 @@ export class FoundryStepExecutor {
|
|
|
1214
1332
|
if (!workers) {
|
|
1215
1333
|
throw new Error(`cloudflare:worker-app for ${org.slug}: this executor was built without a WorkerAppPort`);
|
|
1216
1334
|
}
|
|
1217
|
-
return applyWorkerApp(this.workerAppDeps(workers, org, () => context.recordedIds()), org, action, context);
|
|
1335
|
+
return applyWorkerApp(this.workerAppDeps(workers, org, () => context.recordedIds()), withWorkspaceWorkers(org), action, context);
|
|
1218
1336
|
}
|
|
1219
1337
|
case 'files:organisation':
|
|
1220
1338
|
return this.filesOrganisation(action, org, context);
|
|
@@ -1236,6 +1354,14 @@ export class FoundryStepExecutor {
|
|
|
1236
1354
|
return this.edgeEntryKey(action, org, context);
|
|
1237
1355
|
case 'edge:reserved-labels':
|
|
1238
1356
|
return this.edgeReservedLabels(action, org, context);
|
|
1357
|
+
case 'edge:slug':
|
|
1358
|
+
return this.edgeSlug(action, org, context);
|
|
1359
|
+
case 'jobs:project':
|
|
1360
|
+
return this.jobsProject(action, org, context);
|
|
1361
|
+
case 'github:deploy-key':
|
|
1362
|
+
return this.githubDeployKey(action, org, context);
|
|
1363
|
+
case 'audit:ledger':
|
|
1364
|
+
return this.auditLedger(action, org, context);
|
|
1239
1365
|
case 'coolify:mail':
|
|
1240
1366
|
return this.coolifyMail(action, org, context);
|
|
1241
1367
|
case 'coolify:mail-copy':
|
|
@@ -1254,6 +1380,10 @@ export class FoundryStepExecutor {
|
|
|
1254
1380
|
return this.bouleOwners(org, context);
|
|
1255
1381
|
case 'coolify:boule-broker-entry':
|
|
1256
1382
|
return this.bouleBrokerEntry(org, context);
|
|
1383
|
+
case 'coolify:broker-registrar':
|
|
1384
|
+
return this.brokerRegistrar(org, context);
|
|
1385
|
+
case 'coolify:keys-registrar':
|
|
1386
|
+
return this.keysRegistrar(action, org, context);
|
|
1257
1387
|
case 'jobs:registration':
|
|
1258
1388
|
return this.jobsRegistration(action, org, context);
|
|
1259
1389
|
case 'cloudflare:db-route':
|
|
@@ -1266,6 +1396,8 @@ export class FoundryStepExecutor {
|
|
|
1266
1396
|
return this.workerAppDb(org, context);
|
|
1267
1397
|
case 'coolify:own-jobs':
|
|
1268
1398
|
return this.ownJobs(action, org, context);
|
|
1399
|
+
case 'coolify:own-jobs-route':
|
|
1400
|
+
return this.ownJobsRoute(action, org, context);
|
|
1269
1401
|
case 'coolify:own-mail':
|
|
1270
1402
|
return this.ownMail(action, org, context);
|
|
1271
1403
|
case 'coolify:own-sfu':
|
|
@@ -1288,7 +1420,10 @@ export class FoundryStepExecutor {
|
|
|
1288
1420
|
* app carries any policy but foundry's, since that policy could let others
|
|
1289
1421
|
* in.
|
|
1290
1422
|
*/
|
|
1291
|
-
async cloudflareAccess(action, org) {
|
|
1423
|
+
async cloudflareAccess(action, org, context) {
|
|
1424
|
+
const machine = action.desired.machineBypass;
|
|
1425
|
+
if (machine)
|
|
1426
|
+
return this.cloudflareMachineBypass(action, org, context);
|
|
1292
1427
|
const rule = (org.hosting.access ?? []).find((r) => appStepKey(org, r.hostname) === action.key);
|
|
1293
1428
|
const owned = action.desired.ownedBypass;
|
|
1294
1429
|
if (!rule && owned && appStepKey(org, owned) === action.key) {
|
|
@@ -1310,6 +1445,41 @@ export class FoundryStepExecutor {
|
|
|
1310
1445
|
policy: await this.accessPolicyFor(org, rule),
|
|
1311
1446
|
});
|
|
1312
1447
|
}
|
|
1448
|
+
/**
|
|
1449
|
+
* F11 (`plan/machine-paths.ts`): one path-scoped Access application (`<host><path>`) with foundry's bypass
|
|
1450
|
+
* policy, so a machine caller is not stopped by the host's gate; or its removal. The stem of the step key
|
|
1451
|
+
* (`mp-<hash>`) names it; the recorded `host/path` is kept only to word the removal.
|
|
1452
|
+
*/
|
|
1453
|
+
async cloudflareMachineBypass(action, org, context) {
|
|
1454
|
+
const stem = action.key.slice(org.id.length + 1);
|
|
1455
|
+
const desired = action.desired;
|
|
1456
|
+
if (desired.machineBypass === 'delete') {
|
|
1457
|
+
const id = context.recordedIds()[`cloudflare:access:${action.key}`];
|
|
1458
|
+
if (id) {
|
|
1459
|
+
try {
|
|
1460
|
+
await this.accessPort().deleteAccessApp(id);
|
|
1461
|
+
}
|
|
1462
|
+
catch (error) {
|
|
1463
|
+
if (!isEdgeNotFound(error))
|
|
1464
|
+
throw error;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
forgetScopeId(context, `cloudflare:access:${action.key}`);
|
|
1468
|
+
forgetScopeId(context, machineBypassScopeKey(stem));
|
|
1469
|
+
return {};
|
|
1470
|
+
}
|
|
1471
|
+
if (!desired.domain) {
|
|
1472
|
+
throw new Error(`cloudflare:access for ${org.slug}: machine bypass '${stem}' names no host and path`);
|
|
1473
|
+
}
|
|
1474
|
+
const result = await this.cloudflareAccessApp(org, {
|
|
1475
|
+
appName: `foundry:${org.id}:${desired.domain}`,
|
|
1476
|
+
domain: desired.domain,
|
|
1477
|
+
policyName: ACCESS_RULE_POLICY_NAMES.bypass,
|
|
1478
|
+
policy: await this.accessPolicyFor(org, { kind: 'bypass', hostname: desired.domain }),
|
|
1479
|
+
});
|
|
1480
|
+
context.recordScopeId(machineBypassScopeKey(stem), desired.domain);
|
|
1481
|
+
return result;
|
|
1482
|
+
}
|
|
1313
1483
|
/**
|
|
1314
1484
|
* `plan/diff.ts`'s `planGates`: makes, changes or deletes one declared
|
|
1315
1485
|
* gate through edge. A create needs the platform's recorded Access provider,
|
|
@@ -1326,13 +1496,30 @@ export class FoundryStepExecutor {
|
|
|
1326
1496
|
const recorded = context.recordedIds();
|
|
1327
1497
|
const gateId = recorded[gateIdRecordKey(org.id, gateKey)];
|
|
1328
1498
|
const hostnamesKey = gateHostnamesScopeKey(gateKey);
|
|
1499
|
+
const peopleKey = gatePeopleScopeKey(gateKey);
|
|
1329
1500
|
if (desired.mode === 'delete') {
|
|
1330
1501
|
if (gateId)
|
|
1331
1502
|
await this.accessPort().deleteGate(gateId);
|
|
1332
1503
|
if (context.forgetId) {
|
|
1333
1504
|
context.forgetId(gateIdRecordKey(org.id, gateKey));
|
|
1334
1505
|
context.forgetId(hostnamesKey);
|
|
1506
|
+
context.forgetId(peopleKey);
|
|
1507
|
+
}
|
|
1508
|
+
return {};
|
|
1509
|
+
}
|
|
1510
|
+
if (desired.mode === 'people') {
|
|
1511
|
+
const emails = desired.emails ?? [];
|
|
1512
|
+
if (!gateId)
|
|
1513
|
+
throw new Error(`${at}: no gate is recorded, so there is no one to change`);
|
|
1514
|
+
if (emails.length === 0) {
|
|
1515
|
+
throw new Error(`${at}: a gate nobody can pass is a mistake, and the list is empty`);
|
|
1335
1516
|
}
|
|
1517
|
+
const access = this.accessPort();
|
|
1518
|
+
if (!access.setGatePeople) {
|
|
1519
|
+
throw new Error(`${at}: this host's Access port cannot change who passes a gate`);
|
|
1520
|
+
}
|
|
1521
|
+
await access.setGatePeople(gateId, emails);
|
|
1522
|
+
context.recordScopeId(peopleKey, peopleHash(emails));
|
|
1336
1523
|
return {};
|
|
1337
1524
|
}
|
|
1338
1525
|
const hostnames = desired.hostnames ?? [];
|
|
@@ -1346,6 +1533,7 @@ export class FoundryStepExecutor {
|
|
|
1346
1533
|
// The gate is gone at edge: forget it, so the next plan creates it again.
|
|
1347
1534
|
context.forgetId?.(gateIdRecordKey(org.id, gateKey));
|
|
1348
1535
|
context.forgetId?.(hostnamesKey);
|
|
1536
|
+
context.forgetId?.(peopleKey);
|
|
1349
1537
|
return {};
|
|
1350
1538
|
}
|
|
1351
1539
|
context.recordScopeId(hostnamesKey, JSON.stringify(hostnames));
|
|
@@ -1358,7 +1546,7 @@ export class FoundryStepExecutor {
|
|
|
1358
1546
|
if (!claimValue) {
|
|
1359
1547
|
throw new Error(`${at}: ${org.slug} has no sign-in organisation recorded yet`);
|
|
1360
1548
|
}
|
|
1361
|
-
const emails = org.owners.map((owner) => owner.email);
|
|
1549
|
+
const emails = desired.emails ?? org.owners.map((owner) => owner.email);
|
|
1362
1550
|
if (emails.length === 0) {
|
|
1363
1551
|
throw new Error(`${at}: ${org.slug} declares no owner with an email, and a gate nobody can pass is a mistake`);
|
|
1364
1552
|
}
|
|
@@ -1373,6 +1561,8 @@ export class FoundryStepExecutor {
|
|
|
1373
1561
|
emails,
|
|
1374
1562
|
});
|
|
1375
1563
|
context.recordScopeId(hostnamesKey, JSON.stringify(hostnames));
|
|
1564
|
+
if (desired.emails)
|
|
1565
|
+
context.recordScopeId(peopleKey, peopleHash(desired.emails));
|
|
1376
1566
|
return { resourceId: created.id };
|
|
1377
1567
|
}
|
|
1378
1568
|
/** The Cloudflare Access policy body for one `hosting.access` rule, by kind (#246). */
|
|
@@ -1535,6 +1725,7 @@ export class FoundryStepExecutor {
|
|
|
1535
1725
|
return db.internal_db_url;
|
|
1536
1726
|
},
|
|
1537
1727
|
secret: (value) => void this.handledSecrets.add(value),
|
|
1728
|
+
log: (line) => console.info(redactSecrets(line, [...this.handledSecrets])),
|
|
1538
1729
|
},
|
|
1539
1730
|
}
|
|
1540
1731
|
: {}),
|
|
@@ -2488,13 +2679,13 @@ export class FoundryStepExecutor {
|
|
|
2488
2679
|
* every issuer org, which is named by a slug and a slug has no spaces. Every call here is idempotent, so
|
|
2489
2680
|
* a re-run after a crash finishes the job.
|
|
2490
2681
|
*/
|
|
2491
|
-
async zitadelCustomers(action, org, context) {
|
|
2682
|
+
async zitadelCustomers(action, org, context, variant = LIVE_CUSTOMERS) {
|
|
2492
2683
|
const zitadelOrgId = context.recordedIds().zitadel_org;
|
|
2493
2684
|
if (!zitadelOrgId) {
|
|
2494
|
-
throw new Error(
|
|
2685
|
+
throw new Error(`${variant.step} for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
|
|
2495
2686
|
}
|
|
2496
2687
|
const projectId = await this.ensureZitadelProject(org, zitadelOrgId, context);
|
|
2497
|
-
const { org: customers } = await this.ports.zitadel.findOrCreateOrg(
|
|
2688
|
+
const { org: customers } = await this.ports.zitadel.findOrCreateOrg(variant.name(org.slug));
|
|
2498
2689
|
// Self-registration follows `customers.signUp` (off by default). The marker is written last, so a
|
|
2499
2690
|
// run that failed before here is planned again.
|
|
2500
2691
|
const signUp = action.desired.signUp === true;
|
|
@@ -2504,11 +2695,14 @@ export class FoundryStepExecutor {
|
|
|
2504
2695
|
await this.ports.zitadel.closeSelfRegistration(customers.id);
|
|
2505
2696
|
await this.ports.zitadel.findOrCreateProjectGrant(zitadelOrgId, projectId, customers.id);
|
|
2506
2697
|
// The sign-in and room metadata come before every marker: a failed write leaves both markers behind, so the next plan repeats the step.
|
|
2507
|
-
const signIn = await this.writeCustomersSignInMetadata(org, customers.id, action, context);
|
|
2508
|
-
|
|
2509
|
-
|
|
2698
|
+
const signIn = await this.writeCustomersSignInMetadata(org, customers.id, action, context, variant);
|
|
2699
|
+
// The sign-in window text is the live customers organisation's; the test customers planner plans none.
|
|
2700
|
+
const room = variant === LIVE_CUSTOMERS
|
|
2701
|
+
? await this.writeCustomersRoomMetadata(org, customers.id, action, context)
|
|
2702
|
+
: null;
|
|
2703
|
+
context.recordScopeId(variant.signUpKey, signUp ? '1' : '0');
|
|
2510
2704
|
if (signIn)
|
|
2511
|
-
context.recordScopeId(
|
|
2705
|
+
context.recordScopeId(variant.signInKey, signIn);
|
|
2512
2706
|
if (room !== null)
|
|
2513
2707
|
context.recordScopeId(CUSTOMERS_ROOM_SCOPE_KEY, room);
|
|
2514
2708
|
return { resourceId: customers.id };
|
|
@@ -2550,13 +2744,13 @@ export class FoundryStepExecutor {
|
|
|
2550
2744
|
* only when the recorded marker differs, so a step for another change touches
|
|
2551
2745
|
* nothing here. Returns the marker value to record, or null for no change.
|
|
2552
2746
|
*/
|
|
2553
|
-
async writeCustomersSignInMetadata(org, customersOrgId, action, context) {
|
|
2747
|
+
async writeCustomersSignInMetadata(org, customersOrgId, action, context, variant = LIVE_CUSTOMERS) {
|
|
2554
2748
|
const want = action.desired.signIn;
|
|
2555
2749
|
if (want === undefined)
|
|
2556
2750
|
return null;
|
|
2557
2751
|
const { setOrgMetadata, removeOrgMetadata } = this.ports.zitadel;
|
|
2558
2752
|
if (!setOrgMetadata || !removeOrgMetadata) {
|
|
2559
|
-
throw new Error(
|
|
2753
|
+
throw new Error(`${variant.step} for ${org.slug}: the ZITADEL client cannot set organisation metadata`);
|
|
2560
2754
|
}
|
|
2561
2755
|
try {
|
|
2562
2756
|
if (want === 'email-code') {
|
|
@@ -2564,13 +2758,13 @@ export class FoundryStepExecutor {
|
|
|
2564
2758
|
return 'email-code';
|
|
2565
2759
|
}
|
|
2566
2760
|
// Nothing to remove when it was never written.
|
|
2567
|
-
if (context.recordedIds()[
|
|
2761
|
+
if (context.recordedIds()[variant.signInKey] !== 'email-code')
|
|
2568
2762
|
return null;
|
|
2569
2763
|
await removeOrgMetadata.call(this.ports.zitadel, customersOrgId, CUSTOMERS_SIGN_IN_METADATA_KEY);
|
|
2570
2764
|
return 'password';
|
|
2571
2765
|
}
|
|
2572
2766
|
catch {
|
|
2573
|
-
throw new Error(
|
|
2767
|
+
throw new Error(`${variant.step} for ${org.slug}: writing the sign-in metadata of organisation ${customersOrgId} failed`);
|
|
2574
2768
|
}
|
|
2575
2769
|
}
|
|
2576
2770
|
async zitadelApp(action, org, context) {
|
|
@@ -2585,13 +2779,15 @@ export class FoundryStepExecutor {
|
|
|
2585
2779
|
const isAgoraClient = action.key === 'agora';
|
|
2586
2780
|
const isBouleClient = action.key === 'boule';
|
|
2587
2781
|
const authEntry = org.hosting.entries.find((e) => e.key === action.key && e.uses?.auth);
|
|
2782
|
+
// A workspace's own client `<key>-work`, signing in at the workspace host.
|
|
2783
|
+
const workspaceEntry = authEntry ? undefined : workspaceAuthEntryOf(org, action.key);
|
|
2588
2784
|
const clientIdScopeKey = isCmsClient
|
|
2589
2785
|
? CMS_CLIENT_ID_SCOPE_KEY
|
|
2590
2786
|
: isAgoraClient
|
|
2591
2787
|
? AGORA_CLIENT_ID_SCOPE_KEY
|
|
2592
2788
|
: isBouleClient
|
|
2593
2789
|
? BOULE_CLIENT_ID_SCOPE_KEY
|
|
2594
|
-
: authEntry
|
|
2790
|
+
: authEntry || workspaceEntry
|
|
2595
2791
|
? entryClientIdScopeKey(action.key)
|
|
2596
2792
|
: APP_CLIENT_ID_SCOPE_KEY;
|
|
2597
2793
|
const priorAppId = recorded[`zitadel:app:${action.key}`];
|
|
@@ -2936,6 +3132,8 @@ export class FoundryStepExecutor {
|
|
|
2936
3132
|
coolify: this.ports.coolify,
|
|
2937
3133
|
healthToken: async () => (await this.ports.heldKeys?.resolve(sharedSecretRef(org.id, healthTokenName(subject.id)))) ?? null,
|
|
2938
3134
|
secret: (value) => void this.handledSecrets.add(value),
|
|
3135
|
+
pinRef: sharedSecretRef(org.id, pinKeyName(subject.id)),
|
|
3136
|
+
...(this.ports.heldKeys ? { heldKeys: this.ports.heldKeys } : {}),
|
|
2939
3137
|
primaryUuid,
|
|
2940
3138
|
primaryBlueAppId,
|
|
2941
3139
|
runtime: this.ports.runtime ?? {},
|
|
@@ -3035,6 +3233,16 @@ export class FoundryStepExecutor {
|
|
|
3035
3233
|
this.handledSecrets.add(token);
|
|
3036
3234
|
return token;
|
|
3037
3235
|
},
|
|
3236
|
+
pinKey: () => mintPinKey({
|
|
3237
|
+
id: subject.id,
|
|
3238
|
+
ref: sharedSecretRef(org.id, pinKeyName(subject.id)),
|
|
3239
|
+
recorded,
|
|
3240
|
+
record: (k, v) => context.recordScopeId(k, v),
|
|
3241
|
+
persist: () => context.persistScopeIds(),
|
|
3242
|
+
heldKeys: this.ports.heldKeys ?? { resolve: async () => null },
|
|
3243
|
+
generate: () => randomBytes(32).toString('hex'),
|
|
3244
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
3245
|
+
}),
|
|
3038
3246
|
actionsKey: async () => {
|
|
3039
3247
|
const heldKeys = this.ports.heldKeys;
|
|
3040
3248
|
if (typeof heldKeys?.put !== 'function') {
|
|
@@ -3057,6 +3265,7 @@ export class FoundryStepExecutor {
|
|
|
3057
3265
|
return key;
|
|
3058
3266
|
},
|
|
3059
3267
|
secret: (value) => void this.handledSecrets.add(value),
|
|
3268
|
+
githubAppUuid: (repo) => this.githubAppUuidFor(org, repo, recorded()),
|
|
3060
3269
|
runtime,
|
|
3061
3270
|
sleep: runtime.sleep ?? defaultSleep,
|
|
3062
3271
|
label: `${action.system}:${action.kind}:${subject.id}`,
|
|
@@ -3074,6 +3283,296 @@ export class FoundryStepExecutor {
|
|
|
3074
3283
|
}
|
|
3075
3284
|
return undefined;
|
|
3076
3285
|
}
|
|
3286
|
+
/**
|
|
3287
|
+
* `coolify:workspace` (`plan/workspace.ts`): a server entry's private workspace on the primary server. It is
|
|
3288
|
+
* the blue/green stage (the stage app, its own empty database, the stage mail sink and the stage guards) at
|
|
3289
|
+
* the workspace host, with the Access gate `planGates` made first. Nothing here makes a color, a balancer or
|
|
3290
|
+
* a public name. The live app, when the entry is live, is only read for the names stage must not share.
|
|
3291
|
+
*/
|
|
3292
|
+
async coolifyWorkspace(action, org, context) {
|
|
3293
|
+
const recorded = () => context.recordedIds();
|
|
3294
|
+
const key = String(action.desired.entry ?? '');
|
|
3295
|
+
const entry = org.hosting.entries.find((e) => e.key === key && isServerWorkspace(e) && e.repo !== null);
|
|
3296
|
+
if (!entry) {
|
|
3297
|
+
throw new Error(`coolify:workspace for ${org.slug}: no server workspace '${key}'`);
|
|
3298
|
+
}
|
|
3299
|
+
const serverUuid = String(action.desired.server ?? '');
|
|
3300
|
+
const primaryUuid = String(action.desired.primary ?? '');
|
|
3301
|
+
const servers = await this.resolveServers(org, context);
|
|
3302
|
+
if (!servers.servers.some((s) => s.uuid === serverUuid) ||
|
|
3303
|
+
servers.primary.uuid !== primaryUuid) {
|
|
3304
|
+
throw new Error(`coolify:workspace for ${org.slug}: Coolify server ${serverUuid} is not the usable, recorded primary`);
|
|
3305
|
+
}
|
|
3306
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
3307
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
3308
|
+
const subject = workspaceSubject(org, entry);
|
|
3309
|
+
const host = workspaceHost(org.slug, entry.key);
|
|
3310
|
+
const gateId = recorded()[gateIdRecordKey(org.id, workspaceGateKey(entry.key))];
|
|
3311
|
+
const guards = this.ports.stageProbes ? { probes: this.ports.stageProbes } : {};
|
|
3312
|
+
if (gateId)
|
|
3313
|
+
guards.gate = { hostname: host, create: async () => ({ id: gateId }) };
|
|
3314
|
+
const runtime = this.ports.runtime ?? {};
|
|
3315
|
+
const authIssuer = action.desired.authIssuer ?? null;
|
|
3316
|
+
const signIn = await this.workspaceStageSignIn(org, entry, authIssuer, context);
|
|
3317
|
+
await applyWorkspaceServer({
|
|
3318
|
+
coolify: this.ports.coolify,
|
|
3319
|
+
recorded,
|
|
3320
|
+
record: (k, v) => context.recordScopeId(k, v),
|
|
3321
|
+
persist: () => context.persistScopeIds(),
|
|
3322
|
+
projectUuid,
|
|
3323
|
+
environment,
|
|
3324
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
3325
|
+
githubAppUuid: (repo) => this.githubAppUuidFor(org, repo, recorded()),
|
|
3326
|
+
runtime,
|
|
3327
|
+
sleep: runtime.sleep ?? defaultSleep,
|
|
3328
|
+
label: `${action.system}:${action.kind}:${entry.key}`,
|
|
3329
|
+
// The live app, when there is one: stage's names are checked against its env and nothing is copied from it.
|
|
3330
|
+
primaryBlueAppId: isLiveEntry(entry) ? (recorded()[subject.blueAppKey] ?? null) : null,
|
|
3331
|
+
logDir: LOG_DIR,
|
|
3332
|
+
isRunning: isRunningOrStarting,
|
|
3333
|
+
stageGuards: guards,
|
|
3334
|
+
stageSink: { slug: org.slug },
|
|
3335
|
+
stageAppUrl: `https://${host}`,
|
|
3336
|
+
...(signIn ? { signIn } : {}),
|
|
3337
|
+
}, subject, serverUuid);
|
|
3338
|
+
// Only a stage that took the values has applied them; otherwise the next plan tries again.
|
|
3339
|
+
if (signIn && recorded()[bgKey.stageState(entry.key)] === 'ok') {
|
|
3340
|
+
const marker = workspaceSignInMarker(org, entry, recorded(), authIssuer);
|
|
3341
|
+
if (marker)
|
|
3342
|
+
context.recordScopeId(wsKey.signIn(entry.key), marker);
|
|
3343
|
+
}
|
|
3344
|
+
context.recordScopeId(wsKey.applied(entry.key), workspaceStructureHash(org, entry));
|
|
3345
|
+
await context.persistScopeIds();
|
|
3346
|
+
return undefined;
|
|
3347
|
+
}
|
|
3348
|
+
/**
|
|
3349
|
+
* The stage's own sign-in, for a workspace of an entry with `uses.auth`: the recorded organisation, client and
|
|
3350
|
+
* issuer, its own workspaces key (made and held first, once) and what it must never hold, which is what the
|
|
3351
|
+
* live app holds. A key is registered for redaction before it goes anywhere. Errors name the entry and the ref,
|
|
3352
|
+
* never a value; every held-key read has a timeout.
|
|
3353
|
+
*/
|
|
3354
|
+
async workspaceStageSignIn(org, entry, authIssuer, context) {
|
|
3355
|
+
const auth = entry.uses?.auth;
|
|
3356
|
+
if (!auth)
|
|
3357
|
+
return undefined;
|
|
3358
|
+
const at = `coolify:workspace for ${org.slug}`;
|
|
3359
|
+
const recorded = context.recordedIds();
|
|
3360
|
+
const authEnv = workspaceAuthEnv(org, entry, recorded, authIssuer);
|
|
3361
|
+
if (!authEnv) {
|
|
3362
|
+
throw new Error(`${at}: the sign-in organisation, client or issuer of entry '${entry.key}' is not recorded yet`);
|
|
3363
|
+
}
|
|
3364
|
+
const env = Object.entries(authEnv).map(([key, value]) => ({ key, value }));
|
|
3365
|
+
const held = this.ports.heldKeys;
|
|
3366
|
+
const heldKeys = held && {
|
|
3367
|
+
resolve: (ref) => withLabelledTimeout('held key read', HELD_KEY_READ_TIMEOUT_MS, () => held.resolve(ref)),
|
|
3368
|
+
...(typeof held.put === 'function'
|
|
3369
|
+
? {
|
|
3370
|
+
put: (ref, value) => withLabelledTimeout('held key write', HELD_KEY_READ_TIMEOUT_MS, () => held.put(ref, value)),
|
|
3371
|
+
}
|
|
3372
|
+
: {}),
|
|
3373
|
+
};
|
|
3374
|
+
if (auth.workspaces) {
|
|
3375
|
+
const workspacesKey = await ensureWorkspacesKey({
|
|
3376
|
+
orgId: org.id,
|
|
3377
|
+
slug: org.slug,
|
|
3378
|
+
entryKey: workspaceClientKey(entry.key),
|
|
3379
|
+
heldKeys,
|
|
3380
|
+
recorded: () => context.recordedIds(),
|
|
3381
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
3382
|
+
persist: () => context.persistScopeIds(),
|
|
3383
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
3384
|
+
});
|
|
3385
|
+
env.push({ key: 'WORKSPACES_API_KEY', value: workspacesKey });
|
|
3386
|
+
}
|
|
3387
|
+
const workspacesKeys = [];
|
|
3388
|
+
for (const live of org.hosting.entries.filter((e) => e.uses?.auth?.workspaces)) {
|
|
3389
|
+
const value = await heldKeys?.resolve(workspacesKeyRef(org.id, live.key));
|
|
3390
|
+
if (value) {
|
|
3391
|
+
this.handledSecrets.add(value);
|
|
3392
|
+
workspacesKeys.push(value);
|
|
3393
|
+
}
|
|
3394
|
+
}
|
|
3395
|
+
const liveMcp = org.hosting.entries.filter((live) => live.uses?.auth?.mcp);
|
|
3396
|
+
return {
|
|
3397
|
+
env,
|
|
3398
|
+
customers: auth.users === 'customers',
|
|
3399
|
+
redirectUri: `https://${workspaceClientHosts(org, entry)[0]}/auth/callback`,
|
|
3400
|
+
live: {
|
|
3401
|
+
...(recorded[`zitadel:customers:${org.id}`]
|
|
3402
|
+
? { customersOrgId: recorded[`zitadel:customers:${org.id}`] }
|
|
3403
|
+
: {}),
|
|
3404
|
+
clientIds: liveClientIds(org, recorded),
|
|
3405
|
+
workspacesKeys,
|
|
3406
|
+
mcpAudiences: liveMcp.flatMap((m) => recorded[entryMcpProjectScopeKey(m.key)] ?? []),
|
|
3407
|
+
mcpResources: liveMcp.flatMap((m) => entryMcp(org, m, recorded)?.resource ?? []),
|
|
3408
|
+
},
|
|
3409
|
+
};
|
|
3410
|
+
}
|
|
3411
|
+
/**
|
|
3412
|
+
* `coolify:workspace-broker-entry` (`plan/workspace-sign-in.ts`): the broker entry `<slug>-<key>-work` of a
|
|
3413
|
+
* workspace with sign-in, written with the stage app's own `AUTH_APP_KEY` and the hash of its own workspaces
|
|
3414
|
+
* key. It never reads or writes the live entry. Records what it wrote, never a key.
|
|
3415
|
+
*/
|
|
3416
|
+
async coolifyWorkspaceBrokerEntry(action, org, context) {
|
|
3417
|
+
const at = `coolify:workspace-broker-entry for ${org.slug}`;
|
|
3418
|
+
const broker = this.ports.broker;
|
|
3419
|
+
if (!broker)
|
|
3420
|
+
throw new Error(`${at}: the broker port is not wired`);
|
|
3421
|
+
const recorded = context.recordedIds();
|
|
3422
|
+
const entry = org.hosting.entries.find((e) => appStepKey(org, e.key) === action.key && isServerWorkspace(e) && e.uses?.auth);
|
|
3423
|
+
if (!entry?.uses?.auth)
|
|
3424
|
+
throw new Error(`${at}: no workspace with sign-in for ${action.key}`);
|
|
3425
|
+
const wanted = wantedWorkspaceBrokerEntry(org, entry, recorded);
|
|
3426
|
+
if ('missing' in wanted) {
|
|
3427
|
+
throw new Error(`${at}: ${wanted.missing.map((m) => m.what).join(', ')} not recorded yet`);
|
|
3428
|
+
}
|
|
3429
|
+
const stagePrefix = `bg.${entry.key}.app.`;
|
|
3430
|
+
const stageId = Object.entries(recorded).find(([key, id]) => id && key.startsWith(stagePrefix) && key.endsWith('.stage'))?.[1];
|
|
3431
|
+
if (!stageId)
|
|
3432
|
+
throw new Error(`${at}: the workspace's app is not recorded yet`);
|
|
3433
|
+
const appKey = (await this.ports.coolify.listEnvs(stageId)).find((e) => e.key === 'AUTH_APP_KEY' && !e.is_preview)?.value;
|
|
3434
|
+
if (!appKey || appKey.length < 32) {
|
|
3435
|
+
throw new Error(`${at}: the workspace's app ${stageId} has no AUTH_APP_KEY yet`);
|
|
3436
|
+
}
|
|
3437
|
+
this.handledSecrets.add(appKey);
|
|
3438
|
+
const clientKey = workspaceClientKey(entry.key);
|
|
3439
|
+
await this.writeAppBrokerEntry(org, broker, clientKey, wanted.want, appKey, context, wanted.want.workspaces ? await this.workspacesInputs(org, clientKey) : {});
|
|
3440
|
+
return {};
|
|
3441
|
+
}
|
|
3442
|
+
/**
|
|
3443
|
+
* `coolify:workspaces-database` (`plan/broker-workspaces-database.ts`): the broker's workspaces database, made
|
|
3444
|
+
* beside the broker (the platform's project and network, or the company's own for an org that owns its services)
|
|
3445
|
+
* and held at `workspacesDatabaseRef(org)`. Reads the broker and the held keys again first and makes nothing when
|
|
3446
|
+
* either has a URL, so a database is never made over one. Records nothing: the held URL is the record, and a
|
|
3447
|
+
* database made but not held is found by name on the next apply. The database stays when the org is removed, as
|
|
3448
|
+
* the held keys do.
|
|
3449
|
+
*/
|
|
3450
|
+
async coolifyWorkspacesDatabase(org, context) {
|
|
3451
|
+
const at = `coolify:workspaces-database for ${org.slug}`;
|
|
3452
|
+
const sources = await this.workspacesDatabaseSources(org, context.recordedIds()).catch(() => {
|
|
3453
|
+
throw new Error(`${at}: the broker's env or the held URL could not be read; nothing was made`);
|
|
3454
|
+
});
|
|
3455
|
+
if (sources.inEnv || sources.held)
|
|
3456
|
+
return undefined;
|
|
3457
|
+
const own = ownsServices(org);
|
|
3458
|
+
const target = own ? orgTarget(org) : platformTarget();
|
|
3459
|
+
let coolify;
|
|
3460
|
+
let projectUuid;
|
|
3461
|
+
if (own) {
|
|
3462
|
+
const ownPorts = this.ports.ownServices;
|
|
3463
|
+
if (!ownPorts)
|
|
3464
|
+
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
3465
|
+
coolify = limited(this.placedForCompany(ownPorts.provision.auth.coolify, org, context), LIMITS.ownAuth);
|
|
3466
|
+
projectUuid = () => this.ensureCoolifyProject(org, context);
|
|
3467
|
+
}
|
|
3468
|
+
else {
|
|
3469
|
+
coolify = placedOnPlatform(this.ports.coolify);
|
|
3470
|
+
projectUuid = async () => (await this.ports.coolify.findProjectByName(PLATFORM_AUTH_PROJECT))?.uuid ?? null;
|
|
3471
|
+
}
|
|
3472
|
+
const known = context.recordedIds()[BROKER_WORKSPACES_DATABASE_SCOPE_KEY];
|
|
3473
|
+
await ensureBrokerWorkspacesDatabase({
|
|
3474
|
+
target,
|
|
3475
|
+
coolify,
|
|
3476
|
+
projectUuid,
|
|
3477
|
+
heldKeys: this.ports.heldKeys,
|
|
3478
|
+
...(known ? { knownUuid: known } : {}),
|
|
3479
|
+
recordUuid: async (uuid) => {
|
|
3480
|
+
context.recordScopeId(BROKER_WORKSPACES_DATABASE_SCOPE_KEY, uuid);
|
|
3481
|
+
await context.persistScopeIds();
|
|
3482
|
+
},
|
|
3483
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
3484
|
+
});
|
|
3485
|
+
return undefined;
|
|
3486
|
+
}
|
|
3487
|
+
/**
|
|
3488
|
+
* `coolify:promote`: moves a live workspace entry's app to `HostingEntry.liveCommit`. Patches `git_commit_sha`,
|
|
3489
|
+
* deploys and waits for the deployment, then records `ws.<key>.livecommit`. A failure after the patch leaves the
|
|
3490
|
+
* app configured for the new commit with the old container still running and no marker, so the next apply
|
|
3491
|
+
* patches and deploys again.
|
|
3492
|
+
*/
|
|
3493
|
+
async coolifyPromote(action, org, context) {
|
|
3494
|
+
const key = String(action.desired.entry ?? '');
|
|
3495
|
+
const entry = org.hosting.entries.find((e) => e.key === key && isServerWorkspace(e));
|
|
3496
|
+
const commit = entry?.liveCommit;
|
|
3497
|
+
if (!entry || commit === undefined || !FULL_COMMIT_SHA.test(commit)) {
|
|
3498
|
+
throw new Error(`coolify:promote for ${org.slug}: entry '${key}' has no valid liveCommit`);
|
|
3499
|
+
}
|
|
3500
|
+
const appId = context.recordedIds()[`coolify:app:${appStepKey(org, key)}`];
|
|
3501
|
+
if (!appId) {
|
|
3502
|
+
throw new Error(`coolify:promote for ${org.slug}: the live app for '${key}' is not recorded`);
|
|
3503
|
+
}
|
|
3504
|
+
await this.ports.coolify.updateApplication(appId, { git_commit_sha: commit });
|
|
3505
|
+
const runtime = this.ports.runtime ?? {};
|
|
3506
|
+
const built = await waitForNewDeployment(this.ports.coolify, appId, { kind: 'deployment-only' }, runtime, runtime.sleep ?? defaultSleep, `coolify:promote:${key}`, `${org.slug}'s ${key} app stays at its previous deploy`);
|
|
3507
|
+
await recordBuiltCommit(this.ports.coolify, { appId, deploymentUuid: built.deploymentUuid, name: key }, (k, v) => context.recordScopeId(k, v));
|
|
3508
|
+
context.recordScopeId(wsKey.liveCommit(key), commit);
|
|
3509
|
+
await context.persistScopeIds();
|
|
3510
|
+
return undefined;
|
|
3511
|
+
}
|
|
3512
|
+
/**
|
|
3513
|
+
* The commit a workspace entry's live app is made from: the newest finished deployment of its workspace's stage
|
|
3514
|
+
* app, else the commit recorded when the workspace was last applied. Recorded as `ws.<key>.commit`. Throws,
|
|
3515
|
+
* before any app is made, when there is none.
|
|
3516
|
+
*/
|
|
3517
|
+
async workspaceCommit(org, entry, serverUuid, context) {
|
|
3518
|
+
const stageId = context.recordedIds()[bgKey.app(entry.key, serverUuid, 'stage')];
|
|
3519
|
+
const commit = (stageId ? await readWorkspaceCommit(this.ports.coolify, stageId) : null) ??
|
|
3520
|
+
context.recordedIds()[wsKey.commit(entry.key)] ??
|
|
3521
|
+
null;
|
|
3522
|
+
if (!commit) {
|
|
3523
|
+
throw new Error(`coolify:app for ${org.slug}: workspace '${entry.key}' has not deployed a commit, so there is nothing to put live`);
|
|
3524
|
+
}
|
|
3525
|
+
context.recordScopeId(wsKey.commit(entry.key), commit);
|
|
3526
|
+
await context.persistScopeIds();
|
|
3527
|
+
return commit;
|
|
3528
|
+
}
|
|
3529
|
+
/**
|
|
3530
|
+
* The Coolify GitHub source an app of `repo` is created through: the company's own (`coolify:github-source`,
|
|
3531
|
+
* recorded by that step) when `repo` is under the company's GitHub account, else the platform's.
|
|
3532
|
+
*/
|
|
3533
|
+
async githubAppUuidFor(org, repo, recorded) {
|
|
3534
|
+
if (!companyGithubFor(org.hosting, repo)) {
|
|
3535
|
+
return (await this.ports.coolify.requireGithubApp()).uuid;
|
|
3536
|
+
}
|
|
3537
|
+
const uuid = recorded[githubSourceResourceKey(org.id)];
|
|
3538
|
+
if (!uuid) {
|
|
3539
|
+
throw new Error(`coolify:app for ${org.slug}: repo ${repo} is under the company's GitHub account but its GitHub source is not recorded yet`);
|
|
3540
|
+
}
|
|
3541
|
+
return uuid;
|
|
3542
|
+
}
|
|
3543
|
+
/** The company's Coolify GitHub source (`plan/github-source.ts`): made from the platform GitHub App's held keys and the company's installation id. */
|
|
3544
|
+
async coolifyGithubSource(org, context) {
|
|
3545
|
+
const github = org.hosting.github;
|
|
3546
|
+
if (!github)
|
|
3547
|
+
throw new Error(`coolify:github-source for ${org.slug}: no hosting.github declared`);
|
|
3548
|
+
const heldKeys = this.ports.heldKeys;
|
|
3549
|
+
if (!heldKeys) {
|
|
3550
|
+
throw new Error(`coolify:github-source for ${org.slug}: the executor was built without heldKeys`);
|
|
3551
|
+
}
|
|
3552
|
+
const { uuid } = await ensureGithubSource({
|
|
3553
|
+
coolify: this.ports.coolify,
|
|
3554
|
+
heldKeys,
|
|
3555
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
3556
|
+
label: `coolify:github-source for ${org.slug}`,
|
|
3557
|
+
slug: org.slug,
|
|
3558
|
+
github,
|
|
3559
|
+
});
|
|
3560
|
+
context.recordScopeId(GITHUB_SOURCE_INSTALLATION_SCOPE_KEY, String(github.installationId));
|
|
3561
|
+
await context.persistScopeIds();
|
|
3562
|
+
return { resourceId: uuid };
|
|
3563
|
+
}
|
|
3564
|
+
/** True when no Coolify GitHub source has this uuid. A failed read counts as not gone. */
|
|
3565
|
+
async githubSourceGone(uuid) {
|
|
3566
|
+
try {
|
|
3567
|
+
const sources = await withLabelledTimeout('coolify:github-source: listing sources', GITHUB_SOURCE_TIMEOUT_MS, () => this.ports.coolify.raw('GET', '/github-apps'));
|
|
3568
|
+
return (Array.isArray(sources) && !sources.some((s) => s?.uuid === uuid));
|
|
3569
|
+
}
|
|
3570
|
+
catch (error) {
|
|
3571
|
+
if (error instanceof CallError)
|
|
3572
|
+
return false;
|
|
3573
|
+
throw error;
|
|
3574
|
+
}
|
|
3575
|
+
}
|
|
3077
3576
|
/**
|
|
3078
3577
|
* Stage's guards (`plan/stage-guards.ts`): the probe port the host supplied, and the Access gate over the stage
|
|
3079
3578
|
* host. The gate is offered only when everything it needs is in hand (a domain, the platform's Access provider,
|
|
@@ -3201,6 +3700,8 @@ export class FoundryStepExecutor {
|
|
|
3201
3700
|
// (the marker is recorded only after the redeploy), e.g. a first deploy
|
|
3202
3701
|
// that failed: starting it builds nothing. A parked app has its marker.
|
|
3203
3702
|
let neverDeployed = false;
|
|
3703
|
+
// A workspace entry's live app is made from the commit its workspace runs.
|
|
3704
|
+
let pinnedCommit = null;
|
|
3204
3705
|
if (existing) {
|
|
3205
3706
|
appId = existing.uuid;
|
|
3206
3707
|
// Found by name (rather than just created) is started back up if not
|
|
@@ -3219,14 +3720,18 @@ export class FoundryStepExecutor {
|
|
|
3219
3720
|
}
|
|
3220
3721
|
else {
|
|
3221
3722
|
const server = await this.resolveServer(org, context);
|
|
3222
|
-
const
|
|
3723
|
+
const githubAppUuid = await this.githubAppUuidFor(org, appRepo, context.recordedIds());
|
|
3724
|
+
if (isServerWorkspace(entry)) {
|
|
3725
|
+
pinnedCommit =
|
|
3726
|
+
entry.liveCommit ?? (await this.workspaceCommit(org, entry, server.uuid, context));
|
|
3727
|
+
}
|
|
3223
3728
|
const created = await this.ports.coolify.createGithubAppApplication({
|
|
3224
3729
|
is_preview_deployments_enabled: false,
|
|
3225
3730
|
project_uuid: projectUuid,
|
|
3226
3731
|
server_uuid: server.uuid,
|
|
3227
3732
|
environment_name: environment.name,
|
|
3228
3733
|
environment_uuid: environment.uuid,
|
|
3229
|
-
github_app_uuid:
|
|
3734
|
+
github_app_uuid: githubAppUuid,
|
|
3230
3735
|
git_repository: appRepo,
|
|
3231
3736
|
git_branch: entry.branch,
|
|
3232
3737
|
build_pack: entry.buildPack,
|
|
@@ -3261,6 +3766,8 @@ export class FoundryStepExecutor {
|
|
|
3261
3766
|
// step for it). A created app has them off from the create.
|
|
3262
3767
|
if (previewsOn(live))
|
|
3263
3768
|
patch.is_preview_deployments_enabled = false;
|
|
3769
|
+
if (pinnedCommit)
|
|
3770
|
+
patch.git_commit_sha = pinnedCommit;
|
|
3264
3771
|
// #543: the health-check quartet too, every run -- the same reason
|
|
3265
3772
|
// Archon's own doc comment gives (without one, Coolify's rolling update
|
|
3266
3773
|
// has nothing to wait on and stops the old container immediately). The
|
|
@@ -3391,6 +3898,9 @@ export class FoundryStepExecutor {
|
|
|
3391
3898
|
context.recordScopeId(settingsKey, JSON.stringify({ ...want, port: want.port ?? have.port }));
|
|
3392
3899
|
context.recordScopeId(domainsKey, JSON.stringify(wantDomains));
|
|
3393
3900
|
context.recordScopeId(labelsKey, wantLabelsHash);
|
|
3901
|
+
if (pinnedCommit && pinnedCommit === entry.liveCommit) {
|
|
3902
|
+
context.recordScopeId(wsKey.liveCommit(entry.key), pinnedCommit);
|
|
3903
|
+
}
|
|
3394
3904
|
context.recordScopeId(appEnvAppliedScopeKey(entry.key), appEnv.applied);
|
|
3395
3905
|
if (entry.uses?.auth) {
|
|
3396
3906
|
context.recordScopeId(entryAuthEnvAppliedScopeKey(entry.key), appEnv.authApplied);
|
|
@@ -3404,7 +3914,8 @@ export class FoundryStepExecutor {
|
|
|
3404
3914
|
// actually resolved a mail domain for this org.
|
|
3405
3915
|
// One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
|
|
3406
3916
|
// second entry sharing the name would delete the first's live token.
|
|
3407
|
-
|
|
3917
|
+
// An entry that declares `uses.mail` shares that project's token and sender address.
|
|
3918
|
+
if (isMainEntry(entry) || entry.uses?.mail) {
|
|
3408
3919
|
await this.ensureAppLettermintMail(appId, org, context,
|
|
3409
3920
|
// Deployed or started above on every path, a created app included
|
|
3410
3921
|
// (#566), so it has read its env before the mail env is written.
|
|
@@ -3416,7 +3927,7 @@ export class FoundryStepExecutor {
|
|
|
3416
3927
|
// step planned before this field existed; `ensureAppLettermintMail`
|
|
3417
3928
|
// refuses rather than silently falling back once it knows there is
|
|
3418
3929
|
// a recorded mail domain to reason about.
|
|
3419
|
-
'platformApex' in action.desired, action.desired.platformApex ?? null, action.desired.platformLettermintProjectId ?? null);
|
|
3930
|
+
'platformApex' in action.desired, action.desired.platformApex ?? null, action.desired.platformLettermintProjectId ?? null, isMainEntry(entry) ? 'app' : 'entry');
|
|
3420
3931
|
}
|
|
3421
3932
|
return { resourceId: appId };
|
|
3422
3933
|
}
|
|
@@ -3537,6 +4048,150 @@ export class FoundryStepExecutor {
|
|
|
3537
4048
|
await provisionOrgPostgresDatabases(scopeFromOrg(context, {}), this.orgPostgresDeps(org, context), org, homes);
|
|
3538
4049
|
return {};
|
|
3539
4050
|
}
|
|
4051
|
+
/** F4 (#925): Boule's app and the calls on its env that the apps database steps make. */
|
|
4052
|
+
appsBoule(org, context) {
|
|
4053
|
+
const appId = context.recordedIds()[`coolify:boule:${org.id}`];
|
|
4054
|
+
if (!appId)
|
|
4055
|
+
return null;
|
|
4056
|
+
const coolify = this.ports.coolify;
|
|
4057
|
+
return {
|
|
4058
|
+
upsertEnvs: async (rows) => void (await coolify.bulkUpsertEnvs(appId, rows)),
|
|
4059
|
+
deleteEnvs: async (names) => {
|
|
4060
|
+
let rows;
|
|
4061
|
+
try {
|
|
4062
|
+
rows = await coolify.listEnvs(appId);
|
|
4063
|
+
}
|
|
4064
|
+
catch (error) {
|
|
4065
|
+
if (isNotFoundError(error))
|
|
4066
|
+
return;
|
|
4067
|
+
throw error;
|
|
4068
|
+
}
|
|
4069
|
+
for (const name of names) {
|
|
4070
|
+
const row = rows.find((e) => e.key === name && !e.is_preview);
|
|
4071
|
+
if (!row?.uuid)
|
|
4072
|
+
continue;
|
|
4073
|
+
try {
|
|
4074
|
+
await coolify.deleteEnv(appId, row.uuid);
|
|
4075
|
+
}
|
|
4076
|
+
catch (error) {
|
|
4077
|
+
if (!isNotFoundError(error))
|
|
4078
|
+
throw error;
|
|
4079
|
+
}
|
|
4080
|
+
}
|
|
4081
|
+
},
|
|
4082
|
+
restart: async () => void (await coolify.restart(appId)),
|
|
4083
|
+
};
|
|
4084
|
+
}
|
|
4085
|
+
/**
|
|
4086
|
+
* F4 (#925) `coolify:org-apps-admin`: with `org.boule.apps` on, the `<slug>_apps_admin` role on the
|
|
4087
|
+
* company's Postgres server (`plan/org-apps-admin.ts`); its URL is held and written to Boule's env as
|
|
4088
|
+
* `APPS_DATABASE_ADMIN_URL`, then Boule restarts. Apps off: the retire form (no login, env row gone,
|
|
4089
|
+
* nothing dropped).
|
|
4090
|
+
*/
|
|
4091
|
+
async orgAppsAdmin(action, org, context) {
|
|
4092
|
+
const at = `coolify:org-apps-admin for ${org.slug}`;
|
|
4093
|
+
const base = this.orgPostgresDeps(org, context);
|
|
4094
|
+
const boule = this.appsBoule(org, context);
|
|
4095
|
+
const { deliverSecret: _held, ...shared } = base;
|
|
4096
|
+
if (action.desired.mode === 'retire') {
|
|
4097
|
+
await retireOrgAppsAdmin(scopeFromOrg(context, {}), {
|
|
4098
|
+
...shared,
|
|
4099
|
+
clearHeldUrl: async (ref) => {
|
|
4100
|
+
// The deliver sink has no delete; the held-key store blanks a value (an empty one reads as not held).
|
|
4101
|
+
if (typeof this.ports.heldKeys?.put !== 'function') {
|
|
4102
|
+
console.info(`${at}: the held URL ${ref} was not blanked, the executor's held keys cannot put; blank it by hand`);
|
|
4103
|
+
return;
|
|
4104
|
+
}
|
|
4105
|
+
await this.ports.heldKeys.put(ref, '');
|
|
4106
|
+
},
|
|
4107
|
+
removeBouleEnv: async () => {
|
|
4108
|
+
await boule?.deleteEnvs([APPS_DATABASE_ADMIN_URL_ENV]);
|
|
4109
|
+
if (boule)
|
|
4110
|
+
await boule.restart();
|
|
4111
|
+
},
|
|
4112
|
+
}, org);
|
|
4113
|
+
return {};
|
|
4114
|
+
}
|
|
4115
|
+
if (!appsOn(org))
|
|
4116
|
+
throw new Error(`${at}: the company's Boule runs no apps (org.boule.apps)`);
|
|
4117
|
+
if (!boule)
|
|
4118
|
+
throw new Error(`${at}: Boule is not recorded yet`);
|
|
4119
|
+
const deps = {
|
|
4120
|
+
...shared,
|
|
4121
|
+
deliverSecret: async (ref, url) => {
|
|
4122
|
+
await this.ports.deliverSecret(ref, url);
|
|
4123
|
+
await boule.upsertEnvs([{ key: APPS_DATABASE_ADMIN_URL_ENV, value: url, literal: true }]);
|
|
4124
|
+
await boule.restart();
|
|
4125
|
+
},
|
|
4126
|
+
};
|
|
4127
|
+
await provisionOrgAppsAdmin(scopeFromOrg(context, {}), deps, org);
|
|
4128
|
+
return {};
|
|
4129
|
+
}
|
|
4130
|
+
/**
|
|
4131
|
+
* F4 (#925) `cloudflare:apps-db-route`: the tunnel route Hyperdrive reaches the company's Postgres server
|
|
4132
|
+
* by, and the `APPS_DATABASE_*` env on Boule (`plan/apps-db-route.ts`). Retire takes both away; the
|
|
4133
|
+
* databases stay.
|
|
4134
|
+
*/
|
|
4135
|
+
async appsDbRoute(action, org, context) {
|
|
4136
|
+
const at = `cloudflare:apps-db-route for ${org.slug}`;
|
|
4137
|
+
const heldKeys = this.ports.heldKeys;
|
|
4138
|
+
if (!heldKeys)
|
|
4139
|
+
throw new Error(`${at}: this executor was built without held keys`);
|
|
4140
|
+
const retire = action.desired.mode === 'retire';
|
|
4141
|
+
const boule = this.appsBoule(org, context);
|
|
4142
|
+
if (!boule && !retire)
|
|
4143
|
+
throw new Error(`${at}: Boule is not recorded yet`);
|
|
4144
|
+
const deps = {
|
|
4145
|
+
database: {
|
|
4146
|
+
tunnels: this.tunnelPort(),
|
|
4147
|
+
access: this.accessPort(),
|
|
4148
|
+
dns: this.dnsPort(),
|
|
4149
|
+
zones: this.zonePort(),
|
|
4150
|
+
},
|
|
4151
|
+
heldKeys,
|
|
4152
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
4153
|
+
upstreamHost: async (serverId) => {
|
|
4154
|
+
const server = await getDatabaseStarted(this.ports.coolify, serverId);
|
|
4155
|
+
if (!server.internal_db_url || !URL.canParse(server.internal_db_url))
|
|
4156
|
+
return '';
|
|
4157
|
+
this.handledSecrets.add(server.internal_db_url);
|
|
4158
|
+
return new URL(server.internal_db_url).hostname;
|
|
4159
|
+
},
|
|
4160
|
+
boule: boule ?? {
|
|
4161
|
+
upsertEnvs: async () => undefined,
|
|
4162
|
+
deleteEnvs: async () => undefined,
|
|
4163
|
+
restart: async () => undefined,
|
|
4164
|
+
},
|
|
4165
|
+
};
|
|
4166
|
+
if (retire)
|
|
4167
|
+
await retireAppsDbRoute(deps, org, context);
|
|
4168
|
+
else
|
|
4169
|
+
await applyAppsDbRoute(deps, org, context);
|
|
4170
|
+
return {};
|
|
4171
|
+
}
|
|
4172
|
+
/**
|
|
4173
|
+
* FZ `cloudflare:apps-zones`: the company slug and the zone ids Boule's managed backend names edge routes
|
|
4174
|
+
* by (`plan/apps-zones.ts`). Retire takes the rows away again.
|
|
4175
|
+
*/
|
|
4176
|
+
async appsZones(action, org, context) {
|
|
4177
|
+
const at = `cloudflare:apps-zones for ${org.slug}`;
|
|
4178
|
+
const retire = action.desired.mode === 'retire';
|
|
4179
|
+
const boule = this.appsBoule(org, context);
|
|
4180
|
+
if (retire) {
|
|
4181
|
+
if (boule)
|
|
4182
|
+
await retireAppsZones({ boule }, org, context);
|
|
4183
|
+
else {
|
|
4184
|
+
console.info(`${at}: Boule is not recorded, so no env rows were removed; marker cleared`);
|
|
4185
|
+
context.recordScopeId(APPS_ZONES_APPLIED_SCOPE_KEY, '');
|
|
4186
|
+
await context.persistScopeIds();
|
|
4187
|
+
}
|
|
4188
|
+
return {};
|
|
4189
|
+
}
|
|
4190
|
+
if (!boule)
|
|
4191
|
+
throw new Error(`${at}: Boule is not recorded yet`);
|
|
4192
|
+
await applyAppsZones({ zones: this.zonePort(), boule }, org, context);
|
|
4193
|
+
return {};
|
|
4194
|
+
}
|
|
3540
4195
|
/**
|
|
3541
4196
|
* `coolify:org-postgres-migrate` (#444): one home's data from its old Coolify database to the
|
|
3542
4197
|
* company's server. Refuses before any port call unless the staging confirmation is recorded.
|
|
@@ -3845,6 +4500,19 @@ export class FoundryStepExecutor {
|
|
|
3845
4500
|
const appKey = await this.ensureAuthAppKey(appId);
|
|
3846
4501
|
if (appKey)
|
|
3847
4502
|
envs.push({ key: 'AUTH_APP_KEY', value: appKey });
|
|
4503
|
+
if (entry.uses.auth.workspaces) {
|
|
4504
|
+
const workspacesKey = await ensureWorkspacesKey({
|
|
4505
|
+
orgId: org.id,
|
|
4506
|
+
slug: org.slug,
|
|
4507
|
+
entryKey: entry.key,
|
|
4508
|
+
heldKeys: this.ports.heldKeys,
|
|
4509
|
+
recorded: () => context.recordedIds(),
|
|
4510
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
4511
|
+
persist: () => context.persistScopeIds(),
|
|
4512
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
4513
|
+
});
|
|
4514
|
+
envs.push({ key: 'WORKSPACES_API_KEY', value: workspacesKey });
|
|
4515
|
+
}
|
|
3848
4516
|
}
|
|
3849
4517
|
else {
|
|
3850
4518
|
const zitadelOrgId = recorded.zitadel_org;
|
|
@@ -3901,6 +4569,8 @@ export class FoundryStepExecutor {
|
|
|
3901
4569
|
heldEnvs.push({ key: name, value: await this.readHeldEnv(org, entry.key, name) });
|
|
3902
4570
|
}
|
|
3903
4571
|
envs.push(...heldEnvs);
|
|
4572
|
+
// F13: the entry's customer-grants credential, or its removal when the flag went.
|
|
4573
|
+
await this.customerGrantsAppEnv(appId, org, entry, context, envs);
|
|
3904
4574
|
// A held value has no version the plan can read, so the live env decides whether it changed.
|
|
3905
4575
|
let heldChanged = false;
|
|
3906
4576
|
if (heldEnvs.length > 0) {
|
|
@@ -4039,9 +4709,24 @@ export class FoundryStepExecutor {
|
|
|
4039
4709
|
if (!appKey || appKey.length < 32) {
|
|
4040
4710
|
throw new Error(`coolify:broker-entry for ${org.slug}: app ${appId} has no AUTH_APP_KEY yet`);
|
|
4041
4711
|
}
|
|
4042
|
-
await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, appKey, context);
|
|
4712
|
+
await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, appKey, context, wanted.want.workspaces ? await this.workspacesInputs(org, entry.key) : {});
|
|
4043
4713
|
return {};
|
|
4044
4714
|
}
|
|
4715
|
+
/**
|
|
4716
|
+
* What the broker entry needs beyond its want when it declares workspaces: the hash of the held key (the key
|
|
4717
|
+
* itself is registered for redaction and goes no further) and the operator's held database connection string.
|
|
4718
|
+
*/
|
|
4719
|
+
async workspacesInputs(org, entryKey) {
|
|
4720
|
+
const key = await heldWorkspacesKey(this.ports.heldKeys, org, entryKey);
|
|
4721
|
+
this.handledSecrets.add(key);
|
|
4722
|
+
const database = await heldWorkspacesDatabase(this.ports.heldKeys, workspacesDatabaseRef(org));
|
|
4723
|
+
if (database)
|
|
4724
|
+
this.handledSecrets.add(database);
|
|
4725
|
+
return {
|
|
4726
|
+
workspacesKeyHash: workspacesKeyHash(key),
|
|
4727
|
+
...(database ? { workspacesDatabaseUrl: database } : {}),
|
|
4728
|
+
};
|
|
4729
|
+
}
|
|
4045
4730
|
/**
|
|
4046
4731
|
* #161: the org's Boule broker entry, `<slug>-boule`, written the way a
|
|
4047
4732
|
* hosting entry's is: with the `AUTH_APP_KEY` `coolifyBoule` put in the
|
|
@@ -4114,12 +4799,13 @@ export class FoundryStepExecutor {
|
|
|
4114
4799
|
return {};
|
|
4115
4800
|
}
|
|
4116
4801
|
/** The shared tail of both broker-entry steps: read the app's key, write the entry, record what was written. */
|
|
4117
|
-
async writeAppBrokerEntry(_org, broker, entryKey, want, appKey, context) {
|
|
4802
|
+
async writeAppBrokerEntry(_org, broker, entryKey, want, appKey, context, workspaces = {}) {
|
|
4118
4803
|
const previousRaw = context.recordedIds()[brokerEntryAppliedScopeKey(entryKey)];
|
|
4119
4804
|
const result = await writeBrokerEntry(broker, {
|
|
4120
4805
|
want,
|
|
4121
4806
|
appKey,
|
|
4122
4807
|
previous: previousRaw ? JSON.parse(previousRaw) : null,
|
|
4808
|
+
...workspaces,
|
|
4123
4809
|
});
|
|
4124
4810
|
context.recordScopeId(brokerEntryWarningsScopeKey(entryKey), JSON.stringify(result.invalidForeign));
|
|
4125
4811
|
// Last: a run that failed above plans the step again.
|
|
@@ -4220,7 +4906,7 @@ export class FoundryStepExecutor {
|
|
|
4220
4906
|
* mail domain was never even recorded for this org, the same as before
|
|
4221
4907
|
* this field existed (review round 2, finding 3).
|
|
4222
4908
|
*/
|
|
4223
|
-
async ensureAppLettermintMail(appId, org, context, running, hasPlatformApexField, platformApex, platformLettermintProjectId) {
|
|
4909
|
+
async ensureAppLettermintMail(appId, org, context, running, hasPlatformApexField, platformApex, platformLettermintProjectId, role = 'app') {
|
|
4224
4910
|
const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
|
|
4225
4911
|
if (!mailDomain)
|
|
4226
4912
|
return;
|
|
@@ -4273,7 +4959,7 @@ export class FoundryStepExecutor {
|
|
|
4273
4959
|
org,
|
|
4274
4960
|
context,
|
|
4275
4961
|
mailDomain,
|
|
4276
|
-
self: { role
|
|
4962
|
+
self: { role, id: appId, restart: running },
|
|
4277
4963
|
mailFrom: `${org.mail.noReplyLocalPart ?? 'no-reply'}@${mailDomain}`,
|
|
4278
4964
|
platformApex,
|
|
4279
4965
|
platformLettermintProjectId,
|
|
@@ -4307,12 +4993,26 @@ export class FoundryStepExecutor {
|
|
|
4307
4993
|
const { org, context, mailDomain, self, mailFrom } = args;
|
|
4308
4994
|
const appName = `${org.slug}-app`;
|
|
4309
4995
|
const recorded = context.recordedIds();
|
|
4310
|
-
const
|
|
4996
|
+
const mainId = self.role === 'app'
|
|
4311
4997
|
? self.id
|
|
4312
4998
|
: recorded[`coolify:app:${appStepKey(org, mainEntry(org.hosting)?.key)}`];
|
|
4313
4999
|
const bouleId = self.role === 'boule' ? self.id : recorded[`coolify:boule:${org.id}`];
|
|
4314
|
-
|
|
4315
|
-
|
|
5000
|
+
// The hosted app first, Boule second, then the other entries that send mail (`uses.mail`): an earlier token wins a disagreement.
|
|
5001
|
+
const ids = [];
|
|
5002
|
+
const add = (id) => {
|
|
5003
|
+
if (id && !ids.includes(id))
|
|
5004
|
+
ids.push(id);
|
|
5005
|
+
};
|
|
5006
|
+
add(mainId);
|
|
5007
|
+
add(bouleId);
|
|
5008
|
+
for (const entry of org.hosting.entries) {
|
|
5009
|
+
if (entry.uses?.mail && !isMainEntry(entry)) {
|
|
5010
|
+
add(recorded[`coolify:app:${appStepKey(org, entry.key)}`]);
|
|
5011
|
+
}
|
|
5012
|
+
}
|
|
5013
|
+
add(self.id);
|
|
5014
|
+
const envsOf = (id) => id === self.id
|
|
5015
|
+
? this.ports.coolify.listEnvs(id)
|
|
4316
5016
|
: // A recorded peer that Coolify no longer has is not a consumer: not
|
|
4317
5017
|
// read, written or restarted (same 404 rule as the retire step).
|
|
4318
5018
|
this.ports.coolify
|
|
@@ -4322,21 +5022,17 @@ export class FoundryStepExecutor {
|
|
|
4322
5022
|
throw error;
|
|
4323
5023
|
return null;
|
|
4324
5024
|
});
|
|
4325
|
-
const [projects,
|
|
5025
|
+
const [projects, envLists] = await Promise.all([
|
|
4326
5026
|
this.ports.lettermint.listProjects(),
|
|
4327
|
-
|
|
4328
|
-
self.role === 'app' ? Promise.resolve(null) : peerListEnvs(appId),
|
|
4329
|
-
self.role === 'boule' ? Promise.resolve(null) : peerListEnvs(bouleId),
|
|
5027
|
+
Promise.all(ids.map(envsOf)),
|
|
4330
5028
|
]);
|
|
4331
5029
|
const tokenOf = (envs) => envs.find((e) => e.key === 'LETTERMINT_API_TOKEN' && !e.is_preview)?.value || undefined;
|
|
4332
|
-
// The hosted app first, Boule second: the app's token wins a disagreement.
|
|
4333
5030
|
const consumers = [];
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
consumers.push({ id: bouleId, token: tokenOf(bouleSlot) });
|
|
5031
|
+
ids.forEach((id, at) => {
|
|
5032
|
+
const envs = envLists[at];
|
|
5033
|
+
if (envs)
|
|
5034
|
+
consumers.push({ id, token: tokenOf(envs) });
|
|
5035
|
+
});
|
|
4340
5036
|
const existingToken = consumers.find((c) => c.token)?.token;
|
|
4341
5037
|
const ours = projects.filter((p) => p.name === appName);
|
|
4342
5038
|
const held = ours.length === 1 ? ours[0] : undefined;
|
|
@@ -6558,6 +7254,7 @@ export class FoundryStepExecutor {
|
|
|
6558
7254
|
const credentialWants = {
|
|
6559
7255
|
archonCredential: action.desired.archonCredential === true,
|
|
6560
7256
|
customersDirectory: action.desired.customersDirectory === true,
|
|
7257
|
+
registrar: action.desired.registrar === true,
|
|
6561
7258
|
};
|
|
6562
7259
|
const { env, missing } = desiredBouleEnv(org, context.recordedIds(), issuer, archonUrl, mailWanted, credentialWants);
|
|
6563
7260
|
if (missing.length > 0) {
|
|
@@ -6646,11 +7343,34 @@ export class FoundryStepExecutor {
|
|
|
6646
7343
|
archonPushedAt = archon.pushedAt;
|
|
6647
7344
|
credentialEnvs.push({ key: BOULE_ARCHON_CREDENTIAL_ENV, value: archon.value });
|
|
6648
7345
|
}
|
|
7346
|
+
let directoryCredential = null;
|
|
6649
7347
|
if (credentialWants.customersDirectory) {
|
|
6650
|
-
|
|
6651
|
-
if (
|
|
6652
|
-
credentialEnvs.push({ key: BOULE_CUSTOMERS_CREDENTIAL_ENV, value:
|
|
6653
|
-
|
|
7348
|
+
directoryCredential = await this.ensureCustomersDirectoryCredential(org, context);
|
|
7349
|
+
if (directoryCredential) {
|
|
7350
|
+
credentialEnvs.push({ key: BOULE_CUSTOMERS_CREDENTIAL_ENV, value: directoryCredential });
|
|
7351
|
+
}
|
|
7352
|
+
}
|
|
7353
|
+
// F13: Boule's customer-grants entry, written with the rest and compared with the live value, so Boule
|
|
7354
|
+
// restarts only when the JSON itself changes.
|
|
7355
|
+
const grants = await this.customerGrantsForBoule(app.appId, org, context, action.desired.customerGrants === true, directoryCredential, liveRows);
|
|
7356
|
+
// Pushed with the credentials: the loop below registers the JSON for redaction and compares it with the live value.
|
|
7357
|
+
if (grants.value !== null)
|
|
7358
|
+
credentialEnvs.push({ key: CUSTOMER_GRANTS_ENV, value: grants.value });
|
|
7359
|
+
if (grants.removed)
|
|
7360
|
+
envChanged = true;
|
|
7361
|
+
// T3: the company's registrar key at the broker, the same held value the registrar step writes there.
|
|
7362
|
+
let registrarMint = null;
|
|
7363
|
+
// Only with the broker's address beside it: a key Boule cannot use is a key held for nothing.
|
|
7364
|
+
if (credentialWants.registrar && issuer) {
|
|
7365
|
+
const registrar = await this.registrarKey(org, context);
|
|
7366
|
+
registrarMint = registrar.mint;
|
|
7367
|
+
credentialEnvs.push({ key: AUTH_REGISTRAR_KEY_ENV, value: registrar.key });
|
|
7368
|
+
}
|
|
7369
|
+
if (await this.dropRegistrarEnv(app.appId, org, context, liveRows))
|
|
7370
|
+
envChanged = true;
|
|
7371
|
+
// A key Boule was given but has not run with yet (a restart that failed, or a key made again).
|
|
7372
|
+
const registrarUnstarted = registrarMint !== null &&
|
|
7373
|
+
context.recordedIds()[REGISTRAR_DELIVERED_SCOPE_KEY] !== registrarMint;
|
|
6654
7374
|
for (const credential of credentialEnvs) {
|
|
6655
7375
|
this.handledSecrets.add(credential.value);
|
|
6656
7376
|
envs.push(credential);
|
|
@@ -6664,6 +7384,11 @@ export class FoundryStepExecutor {
|
|
|
6664
7384
|
context.recordScopeId(BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, archonPushedAt);
|
|
6665
7385
|
await context.persistScopeIds();
|
|
6666
7386
|
}
|
|
7387
|
+
if (grants.value !== null) {
|
|
7388
|
+
context.recordScopeId(CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY, '1');
|
|
7389
|
+
await context.persistScopeIds();
|
|
7390
|
+
}
|
|
7391
|
+
await this.forgetDroppedCustomerGrants(org, context);
|
|
6667
7392
|
}
|
|
6668
7393
|
catch (error) {
|
|
6669
7394
|
const minted = [
|
|
@@ -6671,6 +7396,7 @@ export class FoundryStepExecutor {
|
|
|
6671
7396
|
bridgeSecret,
|
|
6672
7397
|
ownerSeedSecret,
|
|
6673
7398
|
...credentialEnvs.map((credential) => credential.value),
|
|
7399
|
+
...grants.secrets,
|
|
6674
7400
|
].filter((v) => v !== null);
|
|
6675
7401
|
if (minted.length === 0)
|
|
6676
7402
|
throw error;
|
|
@@ -6709,6 +7435,7 @@ export class FoundryStepExecutor {
|
|
|
6709
7435
|
bridgeSecret !== null ||
|
|
6710
7436
|
ownerSeedSecret !== null ||
|
|
6711
7437
|
secretsNotRestarted ||
|
|
7438
|
+
registrarUnstarted ||
|
|
6712
7439
|
dbPush.changed ||
|
|
6713
7440
|
mail.changed)) {
|
|
6714
7441
|
// A restart inside the mail rule already came after every env write above.
|
|
@@ -6718,6 +7445,9 @@ export class FoundryStepExecutor {
|
|
|
6718
7445
|
if (action.desired.platformOwner === true) {
|
|
6719
7446
|
context.recordScopeId(BOULE_SECRETS_RESTARTED_SCOPE_KEY, '1');
|
|
6720
7447
|
}
|
|
7448
|
+
// After the restart or deploy, so a key Boule never ran with is planned (and restarted) again.
|
|
7449
|
+
if (registrarMint !== null)
|
|
7450
|
+
context.recordScopeId(REGISTRAR_DELIVERED_SCOPE_KEY, registrarMint);
|
|
6721
7451
|
// Last: the login-host step points the sign-in at this app's own address only once the env is running.
|
|
6722
7452
|
this.recordSignInEnvLive(context, 'boule', bouleHostnameFor(org), env);
|
|
6723
7453
|
return { resourceId: app.appId };
|
|
@@ -6786,23 +7516,278 @@ export class FoundryStepExecutor {
|
|
|
6786
7516
|
}
|
|
6787
7517
|
return minted.token;
|
|
6788
7518
|
}
|
|
6789
|
-
/**
|
|
6790
|
-
|
|
6791
|
-
|
|
6792
|
-
|
|
6793
|
-
|
|
6794
|
-
|
|
6795
|
-
|
|
6796
|
-
|
|
6797
|
-
|
|
6798
|
-
|
|
6799
|
-
|
|
6800
|
-
|
|
7519
|
+
/** The held keys with a timeout on every call: a hung store stops the step with a plain line. */
|
|
7520
|
+
timedHeldKeys() {
|
|
7521
|
+
const held = this.ports.heldKeys;
|
|
7522
|
+
if (!held)
|
|
7523
|
+
return undefined;
|
|
7524
|
+
return {
|
|
7525
|
+
resolve: (ref) => withLabelledTimeout('held key read', HELD_KEY_READ_TIMEOUT_MS, () => held.resolve(ref)),
|
|
7526
|
+
...(typeof held.put === 'function'
|
|
7527
|
+
? {
|
|
7528
|
+
put: (ref, value) => withLabelledTimeout('held key write', HELD_KEY_READ_TIMEOUT_MS, () => held.put(ref, value)),
|
|
7529
|
+
}
|
|
7530
|
+
: {}),
|
|
7531
|
+
};
|
|
6801
7532
|
}
|
|
6802
|
-
/**
|
|
6803
|
-
|
|
6804
|
-
|
|
6805
|
-
|
|
7533
|
+
/** T3: the company's registrar key, held at `foundry:registrar:<org id>`; either step may make it. */
|
|
7534
|
+
registrarKey(org, context) {
|
|
7535
|
+
return ensureRegistrarKey({
|
|
7536
|
+
orgId: org.id,
|
|
7537
|
+
slug: org.slug,
|
|
7538
|
+
heldKeys: this.timedHeldKeys(),
|
|
7539
|
+
recorded: () => context.recordedIds(),
|
|
7540
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
7541
|
+
persist: () => context.persistScopeIds(),
|
|
7542
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
7543
|
+
log: (line) => console.info(`coolify: ${line}`),
|
|
7544
|
+
});
|
|
7545
|
+
}
|
|
7546
|
+
/**
|
|
7547
|
+
* T3, customers off: takes away the registrar env foundry gave Boule. The registrar step removes the record
|
|
7548
|
+
* at the broker and forgets the key. Returns true when Boule's env changed.
|
|
7549
|
+
*/
|
|
7550
|
+
async dropRegistrarEnv(appId, org, context, liveRows) {
|
|
7551
|
+
if (registrarWanted(org) || !context.recordedIds()[REGISTRAR_DELIVERED_SCOPE_KEY])
|
|
7552
|
+
return false;
|
|
7553
|
+
// In step with the record: while the broker may still take the key, Boule keeps it.
|
|
7554
|
+
const kept = !this.ports.broker
|
|
7555
|
+
? 'the host has not wired the broker port, so the record cannot be taken away'
|
|
7556
|
+
: context.recordedIds()[REGISTRAR_REMOVAL_HELD_SCOPE_KEY]
|
|
7557
|
+
? 'the last removal at the broker wrote nothing'
|
|
7558
|
+
: null;
|
|
7559
|
+
if (kept) {
|
|
7560
|
+
console.info(`coolify:boule for ${org.slug}: Boule keeps its registrar env: ${kept}`);
|
|
7561
|
+
return false;
|
|
7562
|
+
}
|
|
7563
|
+
let changed = false;
|
|
7564
|
+
for (const row of liveRows) {
|
|
7565
|
+
if (row.key !== AUTH_REGISTRAR_KEY_ENV && row.key !== AUTH_REGISTRAR_URL_ENV)
|
|
7566
|
+
continue;
|
|
7567
|
+
if (!row.uuid)
|
|
7568
|
+
continue;
|
|
7569
|
+
try {
|
|
7570
|
+
await this.ports.coolify.deleteEnv(appId, row.uuid);
|
|
7571
|
+
changed = true;
|
|
7572
|
+
}
|
|
7573
|
+
catch (error) {
|
|
7574
|
+
if (!isNotFoundError(error))
|
|
7575
|
+
throw error;
|
|
7576
|
+
}
|
|
7577
|
+
}
|
|
7578
|
+
context.recordScopeId(REGISTRAR_DELIVERED_SCOPE_KEY, '');
|
|
7579
|
+
return changed;
|
|
7580
|
+
}
|
|
7581
|
+
/**
|
|
7582
|
+
* T3: the company's record in the broker's `AUTH_REGISTRARS` (`plan/broker-registrar.ts`): its key (made
|
|
7583
|
+
* and held first if the Boule step has not), its sign-in organisation, customers organisations and login
|
|
7584
|
+
* host. With customers off it takes the record away, then forgets the key. The applied marker is recorded
|
|
7585
|
+
* last and never holds the key; every error is redacted of it.
|
|
7586
|
+
*/
|
|
7587
|
+
async brokerRegistrar(org, context) {
|
|
7588
|
+
const at = `coolify:broker-registrar for ${org.slug}`;
|
|
7589
|
+
const broker = this.ports.broker;
|
|
7590
|
+
if (!broker)
|
|
7591
|
+
throw new Error(`${at}: the broker port is not wired`);
|
|
7592
|
+
const recorded = context.recordedIds();
|
|
7593
|
+
const previousOrganizationId = appliedRegistrarOrganization(recorded);
|
|
7594
|
+
if (!registrarWanted(org)) {
|
|
7595
|
+
const keys = [];
|
|
7596
|
+
const heldKeys = this.timedHeldKeys();
|
|
7597
|
+
let removal;
|
|
7598
|
+
try {
|
|
7599
|
+
// The held key finds the record whatever organisation it names, also after a crash before the
|
|
7600
|
+
// applied marker; the recorded sign-in organisation does too.
|
|
7601
|
+
const ownKey = recorded[REGISTRAR_MINTED_SCOPE_KEY]
|
|
7602
|
+
? ((await heldKeys?.resolve(registrarRef(org.id))) ?? null)
|
|
7603
|
+
: null;
|
|
7604
|
+
if (ownKey) {
|
|
7605
|
+
keys.push(ownKey);
|
|
7606
|
+
this.handledSecrets.add(ownKey);
|
|
7607
|
+
}
|
|
7608
|
+
removal = await writeRegistrar(broker, {
|
|
7609
|
+
slug: org.slug,
|
|
7610
|
+
record: null,
|
|
7611
|
+
previousOrganizationId,
|
|
7612
|
+
ownOrganizationIds: [recorded[ZITADEL_ORG_SCOPE_KEY] ?? ''],
|
|
7613
|
+
ownKey,
|
|
7614
|
+
secret: (value) => {
|
|
7615
|
+
keys.push(value);
|
|
7616
|
+
this.handledSecrets.add(value);
|
|
7617
|
+
},
|
|
7618
|
+
log: (line) => console.info(redactSecrets(line, keys)),
|
|
7619
|
+
});
|
|
7620
|
+
}
|
|
7621
|
+
catch (error) {
|
|
7622
|
+
throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), keys));
|
|
7623
|
+
}
|
|
7624
|
+
if (removal.removalHeld) {
|
|
7625
|
+
// The record stays at the broker, so the key stays held; the plan says why.
|
|
7626
|
+
console.info(`${at}: the record is not taken away and the key is kept: ${removal.removalHeld}`);
|
|
7627
|
+
context.recordScopeId(REGISTRAR_REMOVAL_HELD_SCOPE_KEY, removal.removalHeld);
|
|
7628
|
+
return {};
|
|
7629
|
+
}
|
|
7630
|
+
context.recordScopeId(REGISTRAR_REMOVAL_HELD_SCOPE_KEY, '');
|
|
7631
|
+
context.recordScopeId(REGISTRAR_APPLIED_SCOPE_KEY, '');
|
|
7632
|
+
context.recordScopeId(REGISTRAR_DEPLOY_WAITS_SCOPE_KEY, '');
|
|
7633
|
+
await context.persistScopeIds();
|
|
7634
|
+
// Last: the broker no longer takes the key, so forgetting it leaves nothing that needs it.
|
|
7635
|
+
if (recorded[REGISTRAR_MINTED_SCOPE_KEY]) {
|
|
7636
|
+
if (!heldKeys?.put) {
|
|
7637
|
+
throw new Error(`${at}: the held keys cannot forget the registrar key (ref ${registrarRef(org.id)})`);
|
|
7638
|
+
}
|
|
7639
|
+
await heldKeys.put(registrarRef(org.id), '');
|
|
7640
|
+
context.recordScopeId(REGISTRAR_MINTED_SCOPE_KEY, '');
|
|
7641
|
+
}
|
|
7642
|
+
return {};
|
|
7643
|
+
}
|
|
7644
|
+
const wanted = wantedRegistrar(org, recorded);
|
|
7645
|
+
if ('missing' in wanted) {
|
|
7646
|
+
throw new Error(`${at}: ${wanted.missing.map((m) => m.what).join(', ')} not recorded yet`);
|
|
7647
|
+
}
|
|
7648
|
+
const { key, mint } = await this.registrarKey(org, context);
|
|
7649
|
+
// Every company's key the broker's env holds, so none can leave in an error.
|
|
7650
|
+
const keys = [key];
|
|
7651
|
+
let result;
|
|
7652
|
+
try {
|
|
7653
|
+
result = await writeRegistrar(broker, {
|
|
7654
|
+
slug: org.slug,
|
|
7655
|
+
record: { ...wanted.want, key },
|
|
7656
|
+
previousOrganizationId,
|
|
7657
|
+
secret: (value) => {
|
|
7658
|
+
keys.push(value);
|
|
7659
|
+
this.handledSecrets.add(value);
|
|
7660
|
+
},
|
|
7661
|
+
log: (line) => console.info(redactSecrets(line, keys)),
|
|
7662
|
+
});
|
|
7663
|
+
}
|
|
7664
|
+
catch (error) {
|
|
7665
|
+
throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), keys));
|
|
7666
|
+
}
|
|
7667
|
+
context.recordScopeId(REGISTRAR_DEPLOY_WAITS_SCOPE_KEY, result.waits.join(', '));
|
|
7668
|
+
// Last: a run that failed above plans the step again.
|
|
7669
|
+
context.recordScopeId(REGISTRAR_APPLIED_SCOPE_KEY, registrarAppliedValue(wanted.want, mint));
|
|
7670
|
+
return {};
|
|
7671
|
+
}
|
|
7672
|
+
customerGrantsCredential(org, entryKey, context) {
|
|
7673
|
+
return ensureCustomerGrantsCredential({
|
|
7674
|
+
orgId: org.id,
|
|
7675
|
+
slug: org.slug,
|
|
7676
|
+
entryKey,
|
|
7677
|
+
heldKeys: this.timedHeldKeys(),
|
|
7678
|
+
recorded: () => context.recordedIds(),
|
|
7679
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
7680
|
+
persist: () => context.persistScopeIds(),
|
|
7681
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
7682
|
+
});
|
|
7683
|
+
}
|
|
7684
|
+
/**
|
|
7685
|
+
* F13, app side: an entry with `uses.customerGrants` gets `CUSTOMER_GRANTS_CREDENTIAL` (made and held first,
|
|
7686
|
+
* once). An entry that had one and no longer declares the flag loses the env and the held value; each part is
|
|
7687
|
+
* repeated safely, so a run that died halfway is finished by the next.
|
|
7688
|
+
*/
|
|
7689
|
+
async customerGrantsAppEnv(appId, org, entry, context, envs) {
|
|
7690
|
+
if (entry.uses?.customerGrants) {
|
|
7691
|
+
envs.push({
|
|
7692
|
+
key: CUSTOMER_GRANTS_CREDENTIAL_ENV,
|
|
7693
|
+
value: await this.customerGrantsCredential(org, entry.key, context),
|
|
7694
|
+
});
|
|
7695
|
+
return;
|
|
7696
|
+
}
|
|
7697
|
+
// Either marker says the app may still hold the env: the held value may already be forgotten by Boule's step.
|
|
7698
|
+
const recorded = context.recordedIds();
|
|
7699
|
+
const minted = Boolean(recorded[customerGrantsMintedScopeKey(entry.key)]);
|
|
7700
|
+
const applied = (recorded[appEnvAppliedScopeKey(entry.key)] ?? '').includes(`"${CUSTOMER_GRANTS_CREDENTIAL_ENV}"`);
|
|
7701
|
+
if (!minted && !applied)
|
|
7702
|
+
return;
|
|
7703
|
+
const at = `coolify:app for ${org.slug}`;
|
|
7704
|
+
for (const row of await this.ports.coolify.listEnvs(appId)) {
|
|
7705
|
+
if (row.key !== CUSTOMER_GRANTS_CREDENTIAL_ENV || row.is_preview || !row.uuid)
|
|
7706
|
+
continue;
|
|
7707
|
+
try {
|
|
7708
|
+
await this.ports.coolify.deleteEnv(appId, row.uuid);
|
|
7709
|
+
}
|
|
7710
|
+
catch (error) {
|
|
7711
|
+
if (!isNotFoundError(error))
|
|
7712
|
+
throw error;
|
|
7713
|
+
}
|
|
7714
|
+
}
|
|
7715
|
+
if (minted)
|
|
7716
|
+
await this.forgetCustomerGrantsCredential(org, entry.key, context, at);
|
|
7717
|
+
}
|
|
7718
|
+
/** Forgets one entry's held credential and clears its marker. Held first, marker last, so a failed forget is repeated. */
|
|
7719
|
+
async forgetCustomerGrantsCredential(org, entryKey, context, at) {
|
|
7720
|
+
const heldKeys = this.timedHeldKeys();
|
|
7721
|
+
if (typeof heldKeys?.put !== 'function') {
|
|
7722
|
+
throw new Error(`${at}: the held keys cannot forget the customer grants credential of entry '${entryKey}' (ref ${customerGrantsRef(org.id, entryKey)})`);
|
|
7723
|
+
}
|
|
7724
|
+
await heldKeys.put(customerGrantsRef(org.id, entryKey), '');
|
|
7725
|
+
context.recordScopeId(customerGrantsMintedScopeKey(entryKey), '');
|
|
7726
|
+
await context.persistScopeIds();
|
|
7727
|
+
}
|
|
7728
|
+
/** F13: the held credentials of entries that no longer declare the flag, forgotten after Boule stopped listing them. */
|
|
7729
|
+
async forgetDroppedCustomerGrants(org, context) {
|
|
7730
|
+
for (const key of customerGrantsToDrop(org, context.recordedIds())) {
|
|
7731
|
+
await this.forgetCustomerGrantsCredential(org, key, context, `coolify:boule for ${org.slug}`);
|
|
7732
|
+
}
|
|
7733
|
+
}
|
|
7734
|
+
/**
|
|
7735
|
+
* F13, Boule side: the `CUSTOMER_GRANTS` value for the flagged entries (each credential made and held if it
|
|
7736
|
+
* is not yet), or `value: null`. With no flagged entry left and a value foundry wrote, the env row is
|
|
7737
|
+
* removed (`removed`). A flagged entry that cannot be listed yet changes nothing. Never a hand-set value
|
|
7738
|
+
* it did not write.
|
|
7739
|
+
*/
|
|
7740
|
+
async customerGrantsForBoule(appId, org, context, planned, directoryCredential, liveRows) {
|
|
7741
|
+
const none = { value: null, removed: false, secrets: [] };
|
|
7742
|
+
const at = `coolify:boule for ${org.slug}`;
|
|
7743
|
+
if (!planned) {
|
|
7744
|
+
if (customerGrantsEntries(org).length > 0)
|
|
7745
|
+
return none;
|
|
7746
|
+
if (!context.recordedIds()[CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY])
|
|
7747
|
+
return none;
|
|
7748
|
+
for (const row of liveRows) {
|
|
7749
|
+
if (row.key !== CUSTOMER_GRANTS_ENV || !row.uuid)
|
|
7750
|
+
continue;
|
|
7751
|
+
try {
|
|
7752
|
+
await this.ports.coolify.deleteEnv(appId, row.uuid);
|
|
7753
|
+
}
|
|
7754
|
+
catch (error) {
|
|
7755
|
+
if (!isNotFoundError(error))
|
|
7756
|
+
throw error;
|
|
7757
|
+
}
|
|
7758
|
+
}
|
|
7759
|
+
context.recordScopeId(CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY, '');
|
|
7760
|
+
await context.persistScopeIds();
|
|
7761
|
+
return { ...none, removed: liveRows.some((row) => row.key === CUSTOMER_GRANTS_ENV) };
|
|
7762
|
+
}
|
|
7763
|
+
const wanted = wantedCustomerGrants(org, context.recordedIds(), { live: true });
|
|
7764
|
+
if (!wanted.shape || !directoryCredential) {
|
|
7765
|
+
throw new Error(`${at}: the plan carries customer grants this run cannot build (${wanted.notes.join('; ') || 'no directory credential'}); plan this org's Boule again`);
|
|
7766
|
+
}
|
|
7767
|
+
const credentials = {};
|
|
7768
|
+
for (const key of Object.keys(wanted.shape.apps)) {
|
|
7769
|
+
credentials[key] = await this.customerGrantsCredential(org, key, context);
|
|
7770
|
+
}
|
|
7771
|
+
const value = customerGrantsJson(wanted.shape, directoryCredential, credentials);
|
|
7772
|
+
return { value, removed: false, secrets: [...Object.values(credentials), value] };
|
|
7773
|
+
}
|
|
7774
|
+
/** `put`, tried up to {@link BOULE_CREDENTIAL_ATTEMPTS} times; true once one attempt succeeds. The cause is dropped. */
|
|
7775
|
+
async holdWithRetry(ref, value, put, label) {
|
|
7776
|
+
for (let attempt = 1; attempt <= BOULE_CREDENTIAL_ATTEMPTS; attempt++) {
|
|
7777
|
+
try {
|
|
7778
|
+
await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, label, () => put(ref, value));
|
|
7779
|
+
return true;
|
|
7780
|
+
}
|
|
7781
|
+
catch {
|
|
7782
|
+
// Retried; the cause may echo the value.
|
|
7783
|
+
}
|
|
7784
|
+
}
|
|
7785
|
+
return false;
|
|
7786
|
+
}
|
|
7787
|
+
/**
|
|
7788
|
+
* C3: customers switched off. Revokes the directory credential's tokens first, then removes the customers
|
|
7789
|
+
* env from Boule, then forgets the held value and the user id. Each step is repeated safely, so a run that
|
|
7790
|
+
* died halfway is finished by the next. Returns true when Boule's env changed.
|
|
6806
7791
|
*/
|
|
6807
7792
|
async dropCustomersCredential(appId, org, context, liveRows) {
|
|
6808
7793
|
if (org.customers?.enabled)
|
|
@@ -6817,7 +7802,9 @@ export class FoundryStepExecutor {
|
|
|
6817
7802
|
}
|
|
6818
7803
|
let changed = false;
|
|
6819
7804
|
for (const row of liveRows) {
|
|
6820
|
-
if (row.key !== BOULE_CUSTOMERS_ORG_ENV &&
|
|
7805
|
+
if (row.key !== BOULE_CUSTOMERS_ORG_ENV &&
|
|
7806
|
+
row.key !== BOULE_TEST_CUSTOMERS_ORG_ENV &&
|
|
7807
|
+
row.key !== BOULE_CUSTOMERS_CREDENTIAL_ENV)
|
|
6821
7808
|
continue;
|
|
6822
7809
|
if (!row.uuid)
|
|
6823
7810
|
continue;
|
|
@@ -7020,6 +8007,30 @@ export class FoundryStepExecutor {
|
|
|
7020
8007
|
project: { ensure: () => this.ensureCoolifyProject(org, context) },
|
|
7021
8008
|
});
|
|
7022
8009
|
}
|
|
8010
|
+
/**
|
|
8011
|
+
* `plan/own-diff.ts`'s `planOwnJobsRoute`: the org's scheduler on `jobs.<domain>`, through the company's own
|
|
8012
|
+
* edge-routed DNS and tunnel ports, dialling the address `coolify:own-jobs` recorded.
|
|
8013
|
+
*/
|
|
8014
|
+
async ownJobsRoute(action, org, context) {
|
|
8015
|
+
const own = this.ports.ownServices;
|
|
8016
|
+
if (!own)
|
|
8017
|
+
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
8018
|
+
const internalUrl = context.recordedIds()[JOBS_INTERNAL_URL_SCOPE_KEY];
|
|
8019
|
+
if (!internalUrl) {
|
|
8020
|
+
throw new Error(`own step coolify:own-jobs-route for ${org.slug}: the jobs scheduler has not recorded its address yet; apply its step first`);
|
|
8021
|
+
}
|
|
8022
|
+
const scope = scopeFromOrg(context, {});
|
|
8023
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
8024
|
+
return provisionJobsRoute(scope, {
|
|
8025
|
+
coolify: limited(this.placedForCompany(own.provision.sfu.coolify, org, context), LIMITS.connector),
|
|
8026
|
+
network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:jobs-route`),
|
|
8027
|
+
}, orgTarget(org), {
|
|
8028
|
+
hostname: action.desired.hostname,
|
|
8029
|
+
apex: ownHostnames(org).zone,
|
|
8030
|
+
internalUrl,
|
|
8031
|
+
projectUuid,
|
|
8032
|
+
});
|
|
8033
|
+
}
|
|
7023
8034
|
/**
|
|
7024
8035
|
* `plan/own-diff.ts`'s `planOwnFiles`: the org's own files host in the
|
|
7025
8036
|
* org's own project, trusting the company's issuer `https://auth.<domain>`.
|
|
@@ -7426,6 +8437,168 @@ export class FoundryStepExecutor {
|
|
|
7426
8437
|
...(context.serverIpv4 ? { serverIpv4: context.serverIpv4 } : {}),
|
|
7427
8438
|
});
|
|
7428
8439
|
}
|
|
8440
|
+
/**
|
|
8441
|
+
* `plan/entry-jobs.ts`'s `planEntryJobs`: registers one entry as its own project in the platform's jobs
|
|
8442
|
+
* scheduler, holds the credentials the scheduler shows once, then writes `JOBS_SCHEDULER_URL`,
|
|
8443
|
+
* `JOBS_SIGNING_SECRET` and `JOBS_SYNC_KEY` to the entry's env and restarts the app.
|
|
8444
|
+
*
|
|
8445
|
+
* The project's id is recorded as undelivered and persisted before the credentials are held, and cleared
|
|
8446
|
+
* only once both are, so a failure in between never registers a second project: `planEntryJobs` blocks
|
|
8447
|
+
* on it. Credentials already held are never registered again, which is also how a failed env write is
|
|
8448
|
+
* retried. Errors name the project and the refs, never a credential.
|
|
8449
|
+
*/
|
|
8450
|
+
async jobsProject(action, org, context) {
|
|
8451
|
+
const entryKey = action.key.slice(org.id.length + 1);
|
|
8452
|
+
const at = `jobs:project for ${org.slug} entry '${entryKey}'`;
|
|
8453
|
+
const want = action.desired;
|
|
8454
|
+
if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.jobs)) {
|
|
8455
|
+
throw new Error(`${at}: the entry no longer declares uses.jobs`);
|
|
8456
|
+
}
|
|
8457
|
+
const appId = context.recordedIds()[`coolify:app:${appStepKey(org, entryKey)}`];
|
|
8458
|
+
if (!appId)
|
|
8459
|
+
throw new Error(`${at}: the entry's app is not recorded yet`);
|
|
8460
|
+
const held = this.ports.heldKeys;
|
|
8461
|
+
const put = held?.put?.bind(held);
|
|
8462
|
+
if (!held || !put)
|
|
8463
|
+
throw new Error(`${at}: the held keys cannot hold the project's credentials`);
|
|
8464
|
+
const refs = entryJobsRefs(org.id, entryKey);
|
|
8465
|
+
const read = async (ref) => {
|
|
8466
|
+
try {
|
|
8467
|
+
const value = await withTimeout(held.resolve(ref), JOBS_PROJECT_CALL_TIMEOUT_MS);
|
|
8468
|
+
return value || null;
|
|
8469
|
+
}
|
|
8470
|
+
catch {
|
|
8471
|
+
throw new Error(`${at}: the held key ${ref} could not be read`);
|
|
8472
|
+
}
|
|
8473
|
+
};
|
|
8474
|
+
let signingSecret = await read(refs.signingSecret);
|
|
8475
|
+
let syncKey = await read(refs.syncKey);
|
|
8476
|
+
if (Boolean(signingSecret) !== Boolean(syncKey)) {
|
|
8477
|
+
throw new Error(`${at}: only one of the project's credentials is held (${refs.signingSecret}, ${refs.syncKey}); the scheduler shows them once, so deregister project ${want.name} and hold neither`);
|
|
8478
|
+
}
|
|
8479
|
+
if (signingSecret)
|
|
8480
|
+
this.handledSecrets.add(signingSecret);
|
|
8481
|
+
if (syncKey)
|
|
8482
|
+
this.handledSecrets.add(syncKey);
|
|
8483
|
+
if (!signingSecret || !syncKey) {
|
|
8484
|
+
const scheduler = await this.jobsPort(org);
|
|
8485
|
+
let registered;
|
|
8486
|
+
try {
|
|
8487
|
+
registered = await withTimeout(scheduler.registerProject({ name: want.name, baseUrl: want.baseUrl }), JOBS_PROJECT_CALL_TIMEOUT_MS);
|
|
8488
|
+
}
|
|
8489
|
+
catch (error) {
|
|
8490
|
+
const detail = error instanceof Error ? error.message : 'no detail';
|
|
8491
|
+
throw new Error(`${at}: the scheduler refused or did not answer the registration (${detail})`);
|
|
8492
|
+
}
|
|
8493
|
+
if (registered.secret)
|
|
8494
|
+
this.handledSecrets.add(registered.secret);
|
|
8495
|
+
if (registered.syncKey)
|
|
8496
|
+
this.handledSecrets.add(registered.syncKey);
|
|
8497
|
+
context.recordScopeId(entryJobsUndeliveredScopeKey(entryKey), registered.id);
|
|
8498
|
+
await context.persistScopeIds();
|
|
8499
|
+
if (!registered.secret || !registered.syncKey) {
|
|
8500
|
+
throw new Error(`${at}: project ${registered.id} is registered but the scheduler returned ${registered.secret || registered.syncKey ? 'only one of' : 'neither of'} its credentials, which it shows once; deregister it (DELETE /admin/projects/${want.name}) and remove the recorded key ${entryJobsUndeliveredScopeKey(entryKey)}`);
|
|
8501
|
+
}
|
|
8502
|
+
for (const [ref, value] of [
|
|
8503
|
+
[refs.signingSecret, registered.secret],
|
|
8504
|
+
[refs.syncKey, registered.syncKey],
|
|
8505
|
+
]) {
|
|
8506
|
+
let stored = false;
|
|
8507
|
+
for (let attempt = 1; attempt <= JOBS_PROJECT_HOLD_ATTEMPTS && !stored; attempt++) {
|
|
8508
|
+
try {
|
|
8509
|
+
await withTimeout(put(ref, value), JOBS_PROJECT_CALL_TIMEOUT_MS);
|
|
8510
|
+
stored = true;
|
|
8511
|
+
}
|
|
8512
|
+
catch {
|
|
8513
|
+
// Retried; the error is dropped in case it echoes the credential.
|
|
8514
|
+
}
|
|
8515
|
+
}
|
|
8516
|
+
if (!stored) {
|
|
8517
|
+
throw new Error(`${at}: project ${registered.id} is registered but the credential for ${ref} could not be held after ${JOBS_PROJECT_HOLD_ATTEMPTS} attempts; deregister it (DELETE /admin/projects/${want.name}) and remove the recorded key ${entryJobsUndeliveredScopeKey(entryKey)}`);
|
|
8518
|
+
}
|
|
8519
|
+
}
|
|
8520
|
+
signingSecret = registered.secret;
|
|
8521
|
+
syncKey = registered.syncKey;
|
|
8522
|
+
forgetScopeId(context, entryJobsUndeliveredScopeKey(entryKey));
|
|
8523
|
+
await context.persistScopeIds();
|
|
8524
|
+
}
|
|
8525
|
+
try {
|
|
8526
|
+
await withTimeout(this.ports.coolify.bulkUpsertEnvs(appId, [
|
|
8527
|
+
{ key: 'JOBS_SCHEDULER_URL', value: want.schedulerUrl, literal: true },
|
|
8528
|
+
{ key: 'JOBS_SIGNING_SECRET', value: signingSecret, literal: true },
|
|
8529
|
+
{ key: 'JOBS_SYNC_KEY', value: syncKey, literal: true },
|
|
8530
|
+
]), JOBS_PROJECT_CALL_TIMEOUT_MS);
|
|
8531
|
+
await withTimeout(this.ports.coolify.restart(appId), JOBS_PROJECT_CALL_TIMEOUT_MS);
|
|
8532
|
+
}
|
|
8533
|
+
catch {
|
|
8534
|
+
// The error is dropped in case Coolify echoes the request body.
|
|
8535
|
+
throw new Error(`${at}: the project's credentials are held but could not be written to the app's env; the next apply writes them again`);
|
|
8536
|
+
}
|
|
8537
|
+
context.recordScopeId(entryJobsAppliedScopeKey(entryKey), entryJobsWantMarker(want));
|
|
8538
|
+
return { resourceId: want.name };
|
|
8539
|
+
}
|
|
8540
|
+
/** `plan/entry-audit.ts`: makes the platform signer if none exists and registers one entry's ledger for sealing. */
|
|
8541
|
+
async auditLedger(action, org, context) {
|
|
8542
|
+
const entryKey = action.key.slice(org.id.length + 1);
|
|
8543
|
+
const at = `audit:ledger for ${org.slug} entry '${entryKey}'`;
|
|
8544
|
+
if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.audit === true)) {
|
|
8545
|
+
throw new Error(`${at}: the entry no longer declares uses.audit`);
|
|
8546
|
+
}
|
|
8547
|
+
const held = this.timedHeldKeys();
|
|
8548
|
+
if (typeof held?.put !== 'function') {
|
|
8549
|
+
throw new Error(`${at}: the held keys cannot hold the platform signer's key`);
|
|
8550
|
+
}
|
|
8551
|
+
return applyEntryAudit({
|
|
8552
|
+
heldKeys: { resolve: held.resolve, put: held.put },
|
|
8553
|
+
org: { id: org.id, slug: org.slug },
|
|
8554
|
+
entryKey,
|
|
8555
|
+
recorded: () => context.recordedIds(),
|
|
8556
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
8557
|
+
persist: () => context.persistScopeIds(),
|
|
8558
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
8559
|
+
});
|
|
8560
|
+
}
|
|
8561
|
+
/** F12, `plan/entry-repo-key.ts`: makes or removes one entry's deploy key. See `entry-repo-key-apply.ts`. */
|
|
8562
|
+
async githubDeployKey(action, org, context) {
|
|
8563
|
+
const entryKey = action.key.slice(org.id.length + 1);
|
|
8564
|
+
const at = `github:deploy-key for ${org.slug} entry '${entryKey}'`;
|
|
8565
|
+
const desired = action.desired;
|
|
8566
|
+
const github = this.ports.githubDeployKeys;
|
|
8567
|
+
if (!github)
|
|
8568
|
+
throw new Error(`${at}: the host gave no GitHub deploy-key client`);
|
|
8569
|
+
const held = this.timedHeldKeys();
|
|
8570
|
+
if (typeof held?.put !== 'function') {
|
|
8571
|
+
throw new Error(`${at}: the held keys cannot hold the key's private half`);
|
|
8572
|
+
}
|
|
8573
|
+
const deps = {
|
|
8574
|
+
github,
|
|
8575
|
+
heldKeys: { resolve: held.resolve, put: held.put },
|
|
8576
|
+
coolify: this.ports.coolify,
|
|
8577
|
+
org: { id: org.id, slug: org.slug },
|
|
8578
|
+
entryKey,
|
|
8579
|
+
appId: context.recordedIds()[`coolify:app:${appStepKey(org, entryKey)}`],
|
|
8580
|
+
recorded: () => context.recordedIds(),
|
|
8581
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
8582
|
+
persist: () => context.persistScopeIds(),
|
|
8583
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
8584
|
+
installationFor: (repo) => companyGithubFor(org.hosting, repo)?.installationId,
|
|
8585
|
+
};
|
|
8586
|
+
if (desired.remove) {
|
|
8587
|
+
await removeRepoKey(deps, desired.repo);
|
|
8588
|
+
return {};
|
|
8589
|
+
}
|
|
8590
|
+
if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.repo)) {
|
|
8591
|
+
throw new Error(`${at}: the entry no longer declares uses.repo`);
|
|
8592
|
+
}
|
|
8593
|
+
await ensureRepoKey(deps, {
|
|
8594
|
+
repo: desired.repo,
|
|
8595
|
+
access: desired.access === 'write' ? 'write' : 'read',
|
|
8596
|
+
...(companyGithubFor(org.hosting, desired.repo)
|
|
8597
|
+
? { installationId: companyGithubFor(org.hosting, desired.repo)?.installationId }
|
|
8598
|
+
: {}),
|
|
8599
|
+
});
|
|
8600
|
+
return {};
|
|
8601
|
+
}
|
|
7429
8602
|
async jobsRegistration(action, org, context) {
|
|
7430
8603
|
const scheduler = await this.jobsPort(org);
|
|
7431
8604
|
const name = action.desired.name;
|
|
@@ -7695,6 +8868,7 @@ export class FoundryStepExecutor {
|
|
|
7695
8868
|
}
|
|
7696
8869
|
}
|
|
7697
8870
|
await this.publishOikosEntryRecords(org, tunnel.id, previousIds, context);
|
|
8871
|
+
await this.publishAppsEntryRecords(org, tunnel.id, previousIds, context);
|
|
7698
8872
|
return { resourceId: tunnel.id };
|
|
7699
8873
|
}
|
|
7700
8874
|
/**
|
|
@@ -7708,19 +8882,41 @@ export class FoundryStepExecutor {
|
|
|
7708
8882
|
* halfway is planned again.
|
|
7709
8883
|
*/
|
|
7710
8884
|
async publishOikosEntryRecords(org, tunnelId, previousIds, context) {
|
|
7711
|
-
|
|
7712
|
-
|
|
8885
|
+
await this.publishPlatformZoneEntryRecords(org, tunnelId, previousIds, context, {
|
|
8886
|
+
zoneName: NO_DOMAIN_ZONE,
|
|
8887
|
+
wanted: entryOikosHostnames(org, context.recordedIds()),
|
|
8888
|
+
scopeKey: CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY,
|
|
8889
|
+
owns: (name) => ownsOikosName(org, name),
|
|
8890
|
+
});
|
|
8891
|
+
}
|
|
8892
|
+
/**
|
|
8893
|
+
* The entry names a company has in the platform's `companyincluded.app` zone: its apps' `hostedName`
|
|
8894
|
+
* addresses. Written the same way as {@link publishOikosEntryRecords}. Which company may hold a name is
|
|
8895
|
+
* the host's check (Archon), so the step owns any single-label name under the zone.
|
|
8896
|
+
*/
|
|
8897
|
+
async publishAppsEntryRecords(org, tunnelId, previousIds, context) {
|
|
8898
|
+
await this.publishPlatformZoneEntryRecords(org, tunnelId, previousIds, context, {
|
|
8899
|
+
zoneName: APPS_DOMAIN,
|
|
8900
|
+
wanted: entryAppsHostnames(org, context.recordedIds()),
|
|
8901
|
+
scopeKey: CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY,
|
|
8902
|
+
owns: (name) => ownsAppsZoneName(org, name),
|
|
8903
|
+
organisationId: org.id,
|
|
8904
|
+
});
|
|
8905
|
+
}
|
|
8906
|
+
async publishPlatformZoneEntryRecords(org, tunnelId, previousIds, context, zoneSpec) {
|
|
8907
|
+
const { zoneName, wanted, scopeKey, owns, organisationId } = zoneSpec;
|
|
8908
|
+
const recorded = context.recordedIds()[scopeKey];
|
|
7713
8909
|
const previous = recorded ? recorded.split(',') : [];
|
|
7714
8910
|
if (wanted.length === 0 && previous.length === 0)
|
|
7715
8911
|
return;
|
|
7716
8912
|
for (const name of wanted) {
|
|
7717
|
-
if (!
|
|
7718
|
-
throw new Error(`cloudflare:tunnel for ${org.slug}: ${name} is not a name this company may write in the ${
|
|
8913
|
+
if (!owns(name)) {
|
|
8914
|
+
throw new Error(`cloudflare:tunnel for ${org.slug}: ${name} is not a name this company may write in the ${zoneName} zone`);
|
|
7719
8915
|
}
|
|
7720
8916
|
}
|
|
7721
|
-
const zone = await this.zonePort().findZoneByApex(
|
|
8917
|
+
const zone = await this.zonePort().findZoneByApex(zoneName);
|
|
7722
8918
|
if (!zone) {
|
|
7723
|
-
throw new Error(`cloudflare:tunnel for ${org.slug}: no Cloudflare zone found for ${
|
|
8919
|
+
throw new Error(`cloudflare:tunnel for ${org.slug}: no Cloudflare zone found for ${zoneName} to point its entry names at`);
|
|
7724
8920
|
}
|
|
7725
8921
|
const dns = this.dnsPort();
|
|
7726
8922
|
const dnsTarget = `${tunnelId}.cfargotunnel.com`;
|
|
@@ -7728,7 +8924,7 @@ export class FoundryStepExecutor {
|
|
|
7728
8924
|
dnsTarget,
|
|
7729
8925
|
...[...previousIds].map((id) => `${id}.cfargotunnel.com`),
|
|
7730
8926
|
]);
|
|
7731
|
-
const [shared] = await dns.findDnsRecord(zone.name, `*.${
|
|
8927
|
+
const [shared] = await dns.findDnsRecord(zone.name, `*.${zoneName}`, 'CNAME');
|
|
7732
8928
|
const writes = [];
|
|
7733
8929
|
const conflicts = [];
|
|
7734
8930
|
for (const name of wanted) {
|
|
@@ -7739,7 +8935,13 @@ export class FoundryStepExecutor {
|
|
|
7739
8935
|
continue;
|
|
7740
8936
|
}
|
|
7741
8937
|
const [cname] = await dns.findDnsRecord(zone.name, name, 'CNAME');
|
|
7742
|
-
const record = {
|
|
8938
|
+
const record = {
|
|
8939
|
+
type: 'CNAME',
|
|
8940
|
+
name,
|
|
8941
|
+
content: dnsTarget,
|
|
8942
|
+
proxied: true,
|
|
8943
|
+
...(organisationId === undefined ? {} : { organisationId }),
|
|
8944
|
+
};
|
|
7743
8945
|
if (!cname)
|
|
7744
8946
|
writes.push(() => dns.createDnsRecord(zone.name, record));
|
|
7745
8947
|
else if (cname.content === dnsTarget)
|
|
@@ -7756,14 +8958,14 @@ export class FoundryStepExecutor {
|
|
|
7756
8958
|
for (const write of writes)
|
|
7757
8959
|
await write();
|
|
7758
8960
|
for (const name of previous.filter((n) => !wanted.includes(n))) {
|
|
7759
|
-
if (!
|
|
8961
|
+
if (!owns(name))
|
|
7760
8962
|
continue;
|
|
7761
8963
|
for (const record of await dns.findDnsRecord(zone.name, name, 'CNAME')) {
|
|
7762
8964
|
if (ownTargets.has(record.content))
|
|
7763
8965
|
await dns.deleteDnsRecord(zone.name, record.id);
|
|
7764
8966
|
}
|
|
7765
8967
|
}
|
|
7766
|
-
context.recordScopeId(
|
|
8968
|
+
context.recordScopeId(scopeKey, wanted.join(','));
|
|
7767
8969
|
}
|
|
7768
8970
|
/**
|
|
7769
8971
|
* #236: when this tunnel's DNS was last published under a domain that
|
|
@@ -8814,8 +10016,9 @@ export class FoundryStepExecutor {
|
|
|
8814
10016
|
return {};
|
|
8815
10017
|
}
|
|
8816
10018
|
if (entryKey === BOULE_EDGE_ENTRY_KEY) {
|
|
8817
|
-
if (!org
|
|
8818
|
-
throw new Error(`${at}: Boule's edge key needs org.domain`);
|
|
10019
|
+
if (!bouleEdgeKeyWanted(org)) {
|
|
10020
|
+
throw new Error(`${at}: Boule's edge key needs org.domain or org.boule.apps`);
|
|
10021
|
+
}
|
|
8819
10022
|
}
|
|
8820
10023
|
else if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.edge)) {
|
|
8821
10024
|
throw new Error(`${at}: the entry no longer declares uses.edge`);
|
|
@@ -8823,7 +10026,7 @@ export class FoundryStepExecutor {
|
|
|
8823
10026
|
const appId = context.recordedIds()[edgeKeyAppRecordKey(org, entryKey)];
|
|
8824
10027
|
if (!appId)
|
|
8825
10028
|
throw new Error(`${at}: the entry's app is not recorded yet`);
|
|
8826
|
-
if (!org.domain) {
|
|
10029
|
+
if (entryKey !== BOULE_EDGE_ENTRY_KEY && !org.domain) {
|
|
8827
10030
|
throw new Error(`${at}: edge needs org.domain, and validateEntryEdge already refuses it without one`);
|
|
8828
10031
|
}
|
|
8829
10032
|
// A key left over from a previous mint whose delivery never succeeded
|
|
@@ -8846,7 +10049,7 @@ export class FoundryStepExecutor {
|
|
|
8846
10049
|
}
|
|
8847
10050
|
const minted = await edge.mintKey({
|
|
8848
10051
|
organisationId: org.id,
|
|
8849
|
-
domain: org
|
|
10052
|
+
domain: edgeKeyDomain(org),
|
|
8850
10053
|
name: desired.name,
|
|
8851
10054
|
expiresDays: EDGE_ENTRY_KEY_DAYS,
|
|
8852
10055
|
});
|
|
@@ -8874,6 +10077,150 @@ export class FoundryStepExecutor {
|
|
|
8874
10077
|
// Names the key id only; the underlying error is dropped in case it echoes the secret.
|
|
8875
10078
|
throw new Error(`${at}: key ${minted.id} was minted but could not be written to the app's env after ${ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS} attempts (${lastError instanceof Error ? lastError.name : 'error'}); the next apply revokes it and mints a fresh one automatically`);
|
|
8876
10079
|
}
|
|
10080
|
+
/**
|
|
10081
|
+
* F7 (#928): the company's signed-records registrar key (`plan/keys-registrar.ts`). Mint: a leftover
|
|
10082
|
+
* undelivered key is revoked first; the new key's id is recorded and persisted before it is written to
|
|
10083
|
+
* Boule's env (3 tries, then the error names the id only and the next apply revokes and mints again).
|
|
10084
|
+
* `url` rewrites the address only. Retire revokes and takes both env rows away. Never holds the key.
|
|
10085
|
+
*/
|
|
10086
|
+
async keysRegistrar(action, org, context) {
|
|
10087
|
+
const at = `coolify:keys-registrar for ${org.slug}`;
|
|
10088
|
+
const keys = this.ports.keysRegistrar;
|
|
10089
|
+
if (!keys)
|
|
10090
|
+
throw new Error(`${at}: this executor was built without a KeysRegistrarPort`);
|
|
10091
|
+
const desired = action.desired;
|
|
10092
|
+
const recorded = context.recordedIds();
|
|
10093
|
+
const appId = recorded[`coolify:boule:${org.id}`];
|
|
10094
|
+
const revoke = async (id) => {
|
|
10095
|
+
try {
|
|
10096
|
+
await keys.revokeCompanyKey(org.id, id);
|
|
10097
|
+
}
|
|
10098
|
+
catch (error) {
|
|
10099
|
+
if (!isNotFoundError(error))
|
|
10100
|
+
throw error;
|
|
10101
|
+
}
|
|
10102
|
+
};
|
|
10103
|
+
const tolerate = async (call, gone) => {
|
|
10104
|
+
try {
|
|
10105
|
+
return await call();
|
|
10106
|
+
}
|
|
10107
|
+
catch (error) {
|
|
10108
|
+
if (!isNotFoundError(error))
|
|
10109
|
+
throw error;
|
|
10110
|
+
return gone;
|
|
10111
|
+
}
|
|
10112
|
+
};
|
|
10113
|
+
if (desired.mode === 'retire') {
|
|
10114
|
+
for (const id of new Set([recorded[KEYS_REGISTRAR_SCOPE_KEY], recorded[KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY]].filter((id) => Boolean(id)))) {
|
|
10115
|
+
await revoke(id);
|
|
10116
|
+
}
|
|
10117
|
+
if (recorded[KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY]) {
|
|
10118
|
+
// A mint whose answer was lost may have left a key no id points at.
|
|
10119
|
+
await keys.revokeAllCompanyKeys(org.id);
|
|
10120
|
+
}
|
|
10121
|
+
if (appId) {
|
|
10122
|
+
const rows = await tolerate(() => this.ports.coolify.listEnvs(appId), []);
|
|
10123
|
+
for (const name of [KEYS_REGISTRAR_URL_ENV, KEYS_REGISTRAR_KEY_ENV]) {
|
|
10124
|
+
const row = rows.find((e) => e.key === name && !e.is_preview);
|
|
10125
|
+
if (row?.uuid) {
|
|
10126
|
+
const uuid = row.uuid;
|
|
10127
|
+
await tolerate(() => this.ports.coolify.deleteEnv(appId, uuid), undefined);
|
|
10128
|
+
}
|
|
10129
|
+
}
|
|
10130
|
+
}
|
|
10131
|
+
for (const scope of [
|
|
10132
|
+
KEYS_REGISTRAR_SCOPE_KEY,
|
|
10133
|
+
KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY,
|
|
10134
|
+
KEYS_REGISTRAR_MINTED_AT_SCOPE_KEY,
|
|
10135
|
+
KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY,
|
|
10136
|
+
KEYS_REGISTRAR_URL_APPLIED_SCOPE_KEY,
|
|
10137
|
+
]) {
|
|
10138
|
+
context.recordScopeId(scope, '');
|
|
10139
|
+
}
|
|
10140
|
+
return {};
|
|
10141
|
+
}
|
|
10142
|
+
if (!appsOn(org))
|
|
10143
|
+
throw new Error(`${at}: the company's Boule runs no apps (org.boule.apps)`);
|
|
10144
|
+
if (!appId)
|
|
10145
|
+
throw new Error(`${at}: Boule is not recorded yet`);
|
|
10146
|
+
if (!desired.keysUrl)
|
|
10147
|
+
throw new Error(`${at}: the plan gave no keys service address`);
|
|
10148
|
+
if (desired.mode === 'url') {
|
|
10149
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, [
|
|
10150
|
+
{ key: KEYS_REGISTRAR_URL_ENV, value: desired.keysUrl, literal: true },
|
|
10151
|
+
]);
|
|
10152
|
+
await this.ports.coolify.restart(appId);
|
|
10153
|
+
context.recordScopeId(KEYS_REGISTRAR_URL_APPLIED_SCOPE_KEY, desired.keysUrl);
|
|
10154
|
+
return {};
|
|
10155
|
+
}
|
|
10156
|
+
if (recorded[KEYS_REGISTRAR_SCOPE_KEY])
|
|
10157
|
+
return {};
|
|
10158
|
+
const stale = recorded[KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY];
|
|
10159
|
+
if (stale) {
|
|
10160
|
+
await revoke(stale);
|
|
10161
|
+
context.recordScopeId(KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY, '');
|
|
10162
|
+
await context.persistScopeIds();
|
|
10163
|
+
}
|
|
10164
|
+
if (recorded[KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY]) {
|
|
10165
|
+
// The last mint's answer never reached us, so a key may exist whose id was never recorded. Revoke all
|
|
10166
|
+
// of the company's registrar keys first (none is delivered: the delivered id is not recorded here).
|
|
10167
|
+
const revoked = await keys.revokeAllCompanyKeys(org.id);
|
|
10168
|
+
console.info(`${at}: revoked ${revoked} registrar key(s) left by a mint whose answer was lost, before minting again`);
|
|
10169
|
+
}
|
|
10170
|
+
context.recordScopeId(KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY, new Date(context.now?.() ?? Date.now()).toISOString());
|
|
10171
|
+
await context.persistScopeIds();
|
|
10172
|
+
const minted = await keys.mintCompanyKey({
|
|
10173
|
+
organisationId: org.id,
|
|
10174
|
+
name: desired.name ?? `${org.slug}-registrar`,
|
|
10175
|
+
expiresDays: KEYS_REGISTRAR_KEY_DAYS,
|
|
10176
|
+
});
|
|
10177
|
+
this.handledSecrets.add(minted.secret);
|
|
10178
|
+
context.recordScopeId(KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY, minted.id);
|
|
10179
|
+
context.recordScopeId(KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY, '');
|
|
10180
|
+
await context.persistScopeIds();
|
|
10181
|
+
const rows = [
|
|
10182
|
+
{ key: KEYS_REGISTRAR_URL_ENV, value: desired.keysUrl, literal: true },
|
|
10183
|
+
{ key: KEYS_REGISTRAR_KEY_ENV, value: minted.secret, literal: true },
|
|
10184
|
+
];
|
|
10185
|
+
let lastError;
|
|
10186
|
+
for (let attempt = 1; attempt <= KEYS_REGISTRAR_DELIVERY_ATTEMPTS; attempt++) {
|
|
10187
|
+
try {
|
|
10188
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, rows);
|
|
10189
|
+
await this.ports.coolify.restart(appId);
|
|
10190
|
+
context.recordScopeId(KEYS_REGISTRAR_SCOPE_KEY, minted.id);
|
|
10191
|
+
context.recordScopeId(KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY, '');
|
|
10192
|
+
context.recordScopeId(KEYS_REGISTRAR_MINTED_AT_SCOPE_KEY, new Date(context.now?.() ?? Date.now()).toISOString());
|
|
10193
|
+
context.recordScopeId(KEYS_REGISTRAR_URL_APPLIED_SCOPE_KEY, desired.keysUrl);
|
|
10194
|
+
return {};
|
|
10195
|
+
}
|
|
10196
|
+
catch (e) {
|
|
10197
|
+
lastError = e;
|
|
10198
|
+
}
|
|
10199
|
+
}
|
|
10200
|
+
throw new Error(`${at}: key ${minted.id} was minted but could not be written to Boule's env after ${KEYS_REGISTRAR_DELIVERY_ATTEMPTS} attempts (${lastError instanceof Error ? lastError.name : 'error'}); the next apply revokes it and mints a new one`);
|
|
10201
|
+
}
|
|
10202
|
+
/**
|
|
10203
|
+
* F3: `plan/entry-edge-key.ts`'s `planEdgeSlug` -- records the org's slug in edge. A slug another company
|
|
10204
|
+
* holds (edge's `409`) is a plain line naming the clash, not the raw provider error.
|
|
10205
|
+
*/
|
|
10206
|
+
async edgeSlug(action, org, context) {
|
|
10207
|
+
const edge = this.ports.edge;
|
|
10208
|
+
if (!edge) {
|
|
10209
|
+
throw new Error(`edge:slug for ${org.slug}: this executor was built without an EdgePort`);
|
|
10210
|
+
}
|
|
10211
|
+
const slug = action.desired.slug;
|
|
10212
|
+
try {
|
|
10213
|
+
await edge.setOrganisationSlug(org.id, slug);
|
|
10214
|
+
}
|
|
10215
|
+
catch (e) {
|
|
10216
|
+
if (e instanceof EdgeApiError && e.code === 'conflict') {
|
|
10217
|
+
throw new Error(`edge:slug for ${org.slug} is blocked: edge says another company already holds the slug '${slug}', so its platform hostnames cannot be accepted; change the slug`);
|
|
10218
|
+
}
|
|
10219
|
+
throw e;
|
|
10220
|
+
}
|
|
10221
|
+
context.recordScopeId(EDGE_SLUG_APPLIED_SCOPE_KEY, slug);
|
|
10222
|
+
return {};
|
|
10223
|
+
}
|
|
8877
10224
|
/**
|
|
8878
10225
|
* F7 (#317): `plan/entry-edge-key.ts`'s `planEdgeReservedLabels` --
|
|
8879
10226
|
* tells the edge service every label this org's own entries already
|
|
@@ -9299,6 +10646,28 @@ export class FoundryStepExecutor {
|
|
|
9299
10646
|
throw error;
|
|
9300
10647
|
}
|
|
9301
10648
|
}
|
|
10649
|
+
const appsNames = (recordedIds[CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY] ?? '')
|
|
10650
|
+
.split(',')
|
|
10651
|
+
.filter((name) => name !== '' && name.endsWith(`.${APPS_DOMAIN}`));
|
|
10652
|
+
if (appsNames.length > 0) {
|
|
10653
|
+
try {
|
|
10654
|
+
const zone = await this.zonePort().findZoneByApex(APPS_DOMAIN);
|
|
10655
|
+
if (zone) {
|
|
10656
|
+
const dns = this.dnsPort();
|
|
10657
|
+
for (const name of appsNames) {
|
|
10658
|
+
for (const record of await dns.findDnsRecord(zone.name, name, 'CNAME')) {
|
|
10659
|
+
if (dnsTargets.has(record.content)) {
|
|
10660
|
+
await dns.deleteDnsRecord(zone.name, record.id);
|
|
10661
|
+
}
|
|
10662
|
+
}
|
|
10663
|
+
}
|
|
10664
|
+
}
|
|
10665
|
+
}
|
|
10666
|
+
catch (error) {
|
|
10667
|
+
if (!isNotFoundError(error))
|
|
10668
|
+
throw error;
|
|
10669
|
+
}
|
|
10670
|
+
}
|
|
9302
10671
|
await this.tunnelPort().deleteTunnel(target.id);
|
|
9303
10672
|
return;
|
|
9304
10673
|
}
|
|
@@ -9377,6 +10746,16 @@ export class FoundryStepExecutor {
|
|
|
9377
10746
|
case 'coolify:bg-app':
|
|
9378
10747
|
await this.ports.coolify.deleteApplication(target.id);
|
|
9379
10748
|
return;
|
|
10749
|
+
case 'coolify:github-source':
|
|
10750
|
+
try {
|
|
10751
|
+
await withLabelledTimeout(`coolify:github-source for ${org.slug}: deleting the source`, GITHUB_SOURCE_TIMEOUT_MS, () => this.ports.coolify.raw('DELETE', `/github-apps/${target.id}`));
|
|
10752
|
+
}
|
|
10753
|
+
catch (error) {
|
|
10754
|
+
// withTimeout hides the cause; a source already gone counts as deleted.
|
|
10755
|
+
if (!(await this.githubSourceGone(target.id)))
|
|
10756
|
+
throw error;
|
|
10757
|
+
}
|
|
10758
|
+
return;
|
|
9380
10759
|
case 'cloudflare:bg-access':
|
|
9381
10760
|
await this.accessPort().deleteAccessApp(target.id);
|
|
9382
10761
|
return;
|
|
@@ -9384,16 +10763,31 @@ export class FoundryStepExecutor {
|
|
|
9384
10763
|
await this.accessPort().removeServiceToken(target.id);
|
|
9385
10764
|
return;
|
|
9386
10765
|
case 'cloudflare:bg-dns': {
|
|
9387
|
-
// `target.id` is the recorded route ({ hostname, service })
|
|
9388
|
-
|
|
9389
|
-
|
|
9390
|
-
|
|
10766
|
+
// `target.id` is the recorded route ({ hostname, service }). The zone comes from the hostname
|
|
10767
|
+
// (`<label>-hd.<zone apex>`, one label deep), as the apps-off retire finds it: a company on
|
|
10768
|
+
// oikos.host has no `org.domain`, and the apps database's record lives in the platform zone.
|
|
10769
|
+
let hostname;
|
|
10770
|
+
try {
|
|
10771
|
+
hostname = JSON.parse(target.id).hostname;
|
|
10772
|
+
}
|
|
10773
|
+
catch {
|
|
10774
|
+
hostname = undefined;
|
|
10775
|
+
}
|
|
10776
|
+
if (typeof hostname !== 'string' || !hostname.includes('.'))
|
|
10777
|
+
return;
|
|
10778
|
+
const zone = await this.zonePort().findZoneByApex(hostname.slice(hostname.indexOf('.') + 1));
|
|
10779
|
+
if (!zone)
|
|
9391
10780
|
return;
|
|
9392
10781
|
const dns = this.dnsPort();
|
|
10782
|
+
// Only this company's own tunnel's record is deleted; a record that points anywhere else is not ours.
|
|
10783
|
+
const ownTunnel = recordedIds[`cloudflare:tunnel:${org.id}`];
|
|
9393
10784
|
for (const record of await dns.findDnsRecord(zone.name, hostname, 'CNAME')) {
|
|
9394
|
-
if (record.content.
|
|
10785
|
+
if (ownTunnel && record.content === `${ownTunnel}.cfargotunnel.com`) {
|
|
9395
10786
|
await dns.deleteDnsRecord(zone.name, record.id);
|
|
9396
10787
|
}
|
|
10788
|
+
else {
|
|
10789
|
+
console.info(`cloudflare:bg-dns for ${org.slug}: left the DNS record ${hostname} alone, it does not point at this company's tunnel`);
|
|
10790
|
+
}
|
|
9397
10791
|
}
|
|
9398
10792
|
return;
|
|
9399
10793
|
}
|
|
@@ -9415,6 +10809,26 @@ export class FoundryStepExecutor {
|
|
|
9415
10809
|
case 'coolify:database':
|
|
9416
10810
|
await this.ports.coolify.deleteDatabase(target.id);
|
|
9417
10811
|
return;
|
|
10812
|
+
case 'coolify:keys-registrar': {
|
|
10813
|
+
// F7: the company's registrar key at the keys service (live or never delivered).
|
|
10814
|
+
const keys = this.ports.keysRegistrar;
|
|
10815
|
+
if (!keys) {
|
|
10816
|
+
throw new Error(`coolify:keys-registrar for ${org.slug}: this executor was built without a KeysRegistrarPort; cannot revoke key ${target.id}`);
|
|
10817
|
+
}
|
|
10818
|
+
if (target.key === 'all') {
|
|
10819
|
+
// Every registrar key of the company, including one no recorded id points at.
|
|
10820
|
+
await keys.revokeAllCompanyKeys(org.id);
|
|
10821
|
+
return;
|
|
10822
|
+
}
|
|
10823
|
+
try {
|
|
10824
|
+
await keys.revokeCompanyKey(org.id, target.id);
|
|
10825
|
+
}
|
|
10826
|
+
catch (error) {
|
|
10827
|
+
if (!isNotFoundError(error))
|
|
10828
|
+
throw error;
|
|
10829
|
+
}
|
|
10830
|
+
return;
|
|
10831
|
+
}
|
|
9418
10832
|
case 'coolify:own-files-r2': {
|
|
9419
10833
|
// An own files bucket's storage key; a 404 is caught below as already gone.
|
|
9420
10834
|
const r2Keys = this.ports.ownServices?.provision.files.r2Keys;
|
|
@@ -9450,6 +10864,7 @@ export class FoundryStepExecutor {
|
|
|
9450
10864
|
return;
|
|
9451
10865
|
}
|
|
9452
10866
|
case 'zitadel:customers':
|
|
10867
|
+
case 'zitadel:customers-test':
|
|
9453
10868
|
case 'zitadel:org':
|
|
9454
10869
|
await this.ports.zitadel.deleteOrg(target.id);
|
|
9455
10870
|
return;
|