@wtfalch/foundry 0.60.0 → 0.62.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 +14 -5
- package/dist/index.js +7 -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/migrations/0065_native_build_runs.sql +32 -0
- package/dist/migrations/0066_org_kosmos.sql +10 -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/native-build.d.ts +70 -0
- package/dist/native-build.js +92 -0
- package/dist/org.d.ts +184 -24
- package/dist/org.js +444 -49
- package/dist/plan/agora.d.ts +1 -1
- package/dist/plan/agora.js +4 -0
- 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 +60 -8
- package/dist/plan/blue-green.js +115 -10
- 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/deploy-health.js +9 -2
- package/dist/plan/desktop-callback.js +15 -7
- package/dist/plan/diff.d.ts +8 -2
- package/dist/plan/diff.js +552 -104
- package/dist/plan/edge-access.d.ts +6 -0
- package/dist/plan/edge-access.js +1 -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 +231 -19
- package/dist/plan/executor.js +1648 -129
- 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/kosmos.d.ts +56 -0
- package/dist/plan/kosmos.js +84 -0
- package/dist/plan/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.d.ts +2 -2
- package/dist/plan/locked-hostnames.js +7 -4
- 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.d.ts +3 -3
- package/dist/plan/org-names.js +15 -2
- package/dist/plan/org-postgres-migrate.js +1 -0
- 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-limits.d.ts +6 -0
- package/dist/plan/own-limits.js +1 -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 +13 -2
- package/dist/plan/post-logout-uris.d.ts +1 -1
- package/dist/plan/post-logout-uris.js +12 -3
- package/dist/plan/private-pages.d.ts +26 -0
- package/dist/plan/private-pages.js +39 -0
- package/dist/plan/providers-readers.js +20 -1
- 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/migration-compatibility.d.ts +66 -0
- package/dist/release/migration-compatibility.js +340 -0
- 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 +13 -1
- 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 +20 -7
- package/dist/store/postgres.js +25 -2
- package/dist/store/schema.d.ts +73 -1
- package/dist/store/schema.js +10 -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 +19 -2
package/dist/plan/executor.js
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
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, kosmosOn, 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';
|
|
16
|
-
import {
|
|
21
|
+
import { CallError, withTimeout as withLabelledTimeout } from './call-timeout.js';
|
|
22
|
+
import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, 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';
|
|
19
25
|
import { BACKUP_POSTGRES_DATABASES_SCOPE_KEY, BACKUP_SCOPE_PREFIX, COMPANY_BACKUP_DATABASE_NAMES, backupScopeKey, } from './company-backups.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,40 @@ 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';
|
|
53
|
+
import { KOSMOS_CLIENT_ID_SCOPE_KEY, KOSMOS_DATABASE_SCOPE_KEY, KOSMOS_DEPLOYED_SCOPE_KEY, desiredKosmosEnv, kosmosEnvDrift, kosmosHostnameFor, } from './kosmos.js';
|
|
40
54
|
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';
|
|
55
|
+
import { machineBypassScopeKey } from './machine-paths.js';
|
|
41
56
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
42
57
|
import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_SCOPE_KEY, isOwnMailSmtpPort, normalizeMailTarget, } from './mail-relay.js';
|
|
43
58
|
import { applyAccessTokenLifetime, applyMcpAccess, applyMcpProject } from './mcp.js';
|
|
44
59
|
import { NETWORK_APPLIED_SCOPE_KEY } from './network-move.js';
|
|
60
|
+
import { provisionOrgAppsAdmin, retireOrgAppsAdmin, } from './org-apps-admin.js';
|
|
45
61
|
import { ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationNotConfirmedError, migrateHomeOf, migrateOrgPostgresHome, parseMigrationConfirmation, } from './org-postgres-migrate.js';
|
|
46
62
|
import { orgPostgresSecretRef, provisionOrgPostgresDatabases, provisionOrgPostgresServer, serviceEndpoint, serviceOnServer, serviceUrls, } from './org-postgres.js';
|
|
47
63
|
import { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, } from './own-diff.js';
|
|
48
64
|
import { NO_DOMAIN_ZONE, ownHostnames } from './own-hostnames.js';
|
|
65
|
+
import { provisionJobsRoute } from './own-jobs-route.js';
|
|
49
66
|
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';
|
|
67
|
+
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
68
|
import { ownSfuUrls, provisionSfuApp } from './own-sfu.js';
|
|
52
|
-
import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, } from './platform-diff.js';
|
|
69
|
+
import { JOBS_INTERNAL_URL_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, } from './platform-diff.js';
|
|
53
70
|
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';
|
|
71
|
+
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
72
|
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';
|
|
73
|
+
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
74
|
import { createReleaseHost } from './release-host.js';
|
|
58
75
|
import { TUNNEL_HTTP_INGRESS_SCOPE_KEY, applyRemoteDatabase, mergeTcpIngress, recordedTcpRoutes, } from './remote-database.js';
|
|
59
76
|
import { editSignInBindings } from './sign-in-bindings.js';
|
|
@@ -64,6 +81,9 @@ import { stageGateKey, stageHostname, } from './stage-guards.js';
|
|
|
64
81
|
import { WORKER_APP_DB_SCOPE_KEYS, provisionWorkerAppDb, workerAppDbCaPem, workerAppDbOwnerUrl, } from './worker-app-db.js';
|
|
65
82
|
import { WORKER_APP_CALL_TIMEOUT_MS, applyWorkerApp, heldBrokerKey, teardownWorkerApp, withCallTimeout, } from './worker-app.js';
|
|
66
83
|
import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
|
|
84
|
+
import { TEST_CUSTOMERS_SIGN_IN_SCOPE_KEY, TEST_CUSTOMERS_SIGN_UP_SCOPE_KEY, testCustomersOrgName, workspaceAuthEntryOf, workspaceClientHosts, workspaceClientKey, } from './workspace-client.js';
|
|
85
|
+
import { liveClientIds, wantedWorkspaceBrokerEntry, workspaceAuthEnv, workspaceSignInMarker, } from './workspace-sign-in.js';
|
|
86
|
+
import { gatePeopleScopeKey, isServerWorkspace, peopleHash, readWorkspaceCommit, withWorkspaceWorkers, workspaceGateKey, workspaceStructureHash, workspaceSubject, wsKey, } from './workspace.js';
|
|
67
87
|
/**
|
|
68
88
|
* The real {@link StepExecutor} (#20 "provision Company Factory end to
|
|
69
89
|
* end"): makes each {@link StepAction} `diff.ts` plans true against a real
|
|
@@ -116,9 +136,24 @@ export function adaptZitadelListApps(client) {
|
|
|
116
136
|
}
|
|
117
137
|
/** An outside call for Boule's credentials that has not answered in this long fails the step; the next apply repairs it. */
|
|
118
138
|
const BOULE_CREDENTIAL_TIMEOUT_MS = 60_000;
|
|
139
|
+
/** One read of the broker's env. */
|
|
140
|
+
const BROKER_ENV_READ_TIMEOUT_MS = 15_000;
|
|
141
|
+
const HELD_KEY_READ_TIMEOUT_MS = 15_000;
|
|
119
142
|
const BOULE_CREDENTIAL_ATTEMPTS = 3;
|
|
120
143
|
/** The ZITADEL machine user that reads one company's customers organisation. */
|
|
121
144
|
const CUSTOMERS_DIRECTORY_USER_NAME = 'foundry-customers-directory';
|
|
145
|
+
const LIVE_CUSTOMERS = {
|
|
146
|
+
step: 'zitadel:customers',
|
|
147
|
+
name: (slug) => `${slug} customers`,
|
|
148
|
+
signUpKey: CUSTOMERS_SIGN_UP_SCOPE_KEY,
|
|
149
|
+
signInKey: CUSTOMERS_SIGN_IN_SCOPE_KEY,
|
|
150
|
+
};
|
|
151
|
+
const TEST_CUSTOMERS = {
|
|
152
|
+
step: 'zitadel:customers-test',
|
|
153
|
+
name: testCustomersOrgName,
|
|
154
|
+
signUpKey: TEST_CUSTOMERS_SIGN_UP_SCOPE_KEY,
|
|
155
|
+
signInKey: TEST_CUSTOMERS_SIGN_IN_SCOPE_KEY,
|
|
156
|
+
};
|
|
122
157
|
// ---------------------------------------------------------------------------
|
|
123
158
|
// Errors
|
|
124
159
|
// ---------------------------------------------------------------------------
|
|
@@ -358,6 +393,15 @@ export const BOULE_REPO = 'wtfalch/boule';
|
|
|
358
393
|
export const BOULE_APP_DOCKERFILE = '/Dockerfile';
|
|
359
394
|
/** Boule's own `EXPOSE 3000` (its doc comment just above) -- the port its health check (#543) probes. */
|
|
360
395
|
export const BOULE_PORT = 3000;
|
|
396
|
+
/**
|
|
397
|
+
* foundry#949: kosmos (https://github.com/wtfalch/kosmos), bootstrapped from the same `wtfalch/package-template`
|
|
398
|
+
* service shape Boule is, with its own Dockerfile at the repository root, serving on 3000 (`EXPOSE 3000`) and
|
|
399
|
+
* running its migrations through `apps/host/scripts/migrate.mjs` before it serves, the same shape Boule's
|
|
400
|
+
* `migrate-and-start.sh` follows.
|
|
401
|
+
*/
|
|
402
|
+
export const KOSMOS_REPO = 'wtfalch/kosmos';
|
|
403
|
+
export const KOSMOS_APP_DOCKERFILE = '/Dockerfile';
|
|
404
|
+
export const KOSMOS_PORT = 3000;
|
|
361
405
|
/**
|
|
362
406
|
* Every app foundry deploys keeps its own logs across a redeploy, so a
|
|
363
407
|
* database outage does not also erase the only other copy of the evidence
|
|
@@ -623,6 +667,14 @@ const CALLS_SYNAPSE_SCOPE_KEY = 'coolify_calls_synapse';
|
|
|
623
667
|
const CALLS_MAS_SCOPE_KEY = 'coolify_calls_mas';
|
|
624
668
|
const CALLS_SYNAPSE_DB_SCOPE_KEY = 'coolify_calls_synapse_database';
|
|
625
669
|
const CALLS_MAS_DB_SCOPE_KEY = 'coolify_calls_mas_database';
|
|
670
|
+
/** #430: the calls service's own Matrix/MAS env vars, from before calls#93 (transit-only). Removed from every org's calls service, never written again. */
|
|
671
|
+
const CALLS_MATRIX_ENV_KEYS = [
|
|
672
|
+
'MAS_URL',
|
|
673
|
+
'MAS_HOMESERVER',
|
|
674
|
+
'MAS_CLIENT_ID',
|
|
675
|
+
'MAS_CLIENT_SECRET',
|
|
676
|
+
'SYNAPSE_URL',
|
|
677
|
+
];
|
|
626
678
|
/**
|
|
627
679
|
* Synapse refuses to start against a database whose `LC_COLLATE`/`LC_CTYPE`
|
|
628
680
|
* is not `C` (https://element-hq.github.io/synapse/latest/postgres.html);
|
|
@@ -859,6 +911,10 @@ async function withTimeout(work, ms) {
|
|
|
859
911
|
clearTimeout(timer);
|
|
860
912
|
}
|
|
861
913
|
}
|
|
914
|
+
/** How long one call to the jobs scheduler or the held keys, made for an entry's project, may take. */
|
|
915
|
+
const JOBS_PROJECT_CALL_TIMEOUT_MS = 30_000;
|
|
916
|
+
/** How many times an entry's project credentials are tried against the held keys. */
|
|
917
|
+
const JOBS_PROJECT_HOLD_ATTEMPTS = 3;
|
|
862
918
|
/** How long one Lettermint call of an own company's sign-in mint may take. */
|
|
863
919
|
const LETTERMINT_TIMEOUT_MS = 60_000;
|
|
864
920
|
const OWN_SERVICES_PORT_ABSENT = 'this org runs its own services and the host has no ownServices port for it';
|
|
@@ -889,7 +945,11 @@ function limitProfileOf(action) {
|
|
|
889
945
|
case 'coolify:app':
|
|
890
946
|
case 'coolify:database':
|
|
891
947
|
return LIMITS.companyApp;
|
|
948
|
+
case 'coolify:github-source':
|
|
949
|
+
return undefined;
|
|
892
950
|
case 'coolify:broker-entry':
|
|
951
|
+
case 'coolify:workspace-broker-entry':
|
|
952
|
+
case 'coolify:workspaces-database':
|
|
893
953
|
return LIMITS.brokerEntry;
|
|
894
954
|
case 'cms:site':
|
|
895
955
|
return LIMITS.cms;
|
|
@@ -905,8 +965,16 @@ function limitProfileOf(action) {
|
|
|
905
965
|
case 'coolify:boule':
|
|
906
966
|
case 'coolify:boule-broker-entry':
|
|
907
967
|
return LIMITS.boule;
|
|
968
|
+
case 'coolify:kosmos':
|
|
969
|
+
return LIMITS.kosmos;
|
|
970
|
+
case 'coolify:broker-registrar':
|
|
971
|
+
return LIMITS.brokerEntry;
|
|
972
|
+
case 'coolify:keys-registrar':
|
|
973
|
+
return LIMITS.boule;
|
|
908
974
|
case 'coolify:bluegreen':
|
|
909
975
|
case 'coolify:bluegreen-lb':
|
|
976
|
+
case 'coolify:workspace':
|
|
977
|
+
case 'coolify:promote':
|
|
910
978
|
return LIMITS.companyApp;
|
|
911
979
|
case 'cloudflare:tunnel':
|
|
912
980
|
case 'coolify:bluegreen-connector':
|
|
@@ -1087,6 +1155,58 @@ export class FoundryStepExecutor {
|
|
|
1087
1155
|
throw e;
|
|
1088
1156
|
}
|
|
1089
1157
|
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Where the workspaces database of the broker this org's entries are written to already is: in the broker's
|
|
1160
|
+
* `WORKSPACES_DATABASE_URL` (`inEnv`) or as a URL held at the org's ref (`held`). The company's own broker for an
|
|
1161
|
+
* org that owns its services, the shared one otherwise. Throws when the broker is not wired or a read fails,
|
|
1162
|
+
* times out, or finds a held value that is no postgres URL, so a caller never takes "unknown" for "absent".
|
|
1163
|
+
*/
|
|
1164
|
+
async workspacesDatabaseSources(org, recordedIds) {
|
|
1165
|
+
const broker = ownsServices(org)
|
|
1166
|
+
? (await this.ports.ownServices?.portsFor(org, recordedIds))?.broker
|
|
1167
|
+
: this.ports.broker;
|
|
1168
|
+
if (!broker)
|
|
1169
|
+
throw new Error('the broker port is not wired');
|
|
1170
|
+
const envs = await withLabelledTimeout('the broker env read', BROKER_ENV_READ_TIMEOUT_MS, () => broker.listEnvs());
|
|
1171
|
+
if (envs.some((e) => e.key === BROKER_WORKSPACES_DATABASE_ENV && !e.is_preview && e.value)) {
|
|
1172
|
+
return { inEnv: true, held: false };
|
|
1173
|
+
}
|
|
1174
|
+
const held = await withLabelledTimeout('the held key read', HELD_KEY_READ_TIMEOUT_MS, () => heldWorkspacesDatabase(this.ports.heldKeys, workspacesDatabaseRef(org)));
|
|
1175
|
+
return { inEnv: false, held: Boolean(held) };
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Whether the broker this org's entries are written to has `WORKSPACES_DATABASE_URL`, or a URL is held to give it.
|
|
1179
|
+
* A read that fails or times out answers `false`, so the plan does not write an entry the broker cannot start
|
|
1180
|
+
* with.
|
|
1181
|
+
*/
|
|
1182
|
+
async brokerHasWorkspacesDatabase(org, recordedIds) {
|
|
1183
|
+
try {
|
|
1184
|
+
const { inEnv, held } = await this.workspacesDatabaseSources(org, recordedIds);
|
|
1185
|
+
return inEnv || held;
|
|
1186
|
+
}
|
|
1187
|
+
catch {
|
|
1188
|
+
// Not readable (blocked own services, a timeout, a failed call, a held value that is no URL): the plan reports the entry blocked.
|
|
1189
|
+
return false;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Whether foundry may make the broker's workspaces database: it can hold the URL, and a read just showed that
|
|
1194
|
+
* neither the broker nor the held keys have one. A read that fails answers `false`, never "make one": a database
|
|
1195
|
+
* made over a broker whose own could not be read would replace it.
|
|
1196
|
+
*/
|
|
1197
|
+
async canProvisionWorkspacesDatabase(org, recordedIds) {
|
|
1198
|
+
if (!canHoldWorkspacesDatabase(this.ports.heldKeys))
|
|
1199
|
+
return false;
|
|
1200
|
+
if (ownsServices(org) && !this.ports.ownServices)
|
|
1201
|
+
return false;
|
|
1202
|
+
try {
|
|
1203
|
+
const { inEnv, held } = await this.workspacesDatabaseSources(org, recordedIds);
|
|
1204
|
+
return !inEnv && !held;
|
|
1205
|
+
}
|
|
1206
|
+
catch {
|
|
1207
|
+
return false;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1090
1210
|
/** #555: the edge-routed ports this executor was built with itself (they win over edge). */
|
|
1091
1211
|
ownEdgePorts() {
|
|
1092
1212
|
return {
|
|
@@ -1165,6 +1285,8 @@ export class FoundryStepExecutor {
|
|
|
1165
1285
|
return this.zitadelDesktopCallback(action, org, context);
|
|
1166
1286
|
case 'zitadel:customers':
|
|
1167
1287
|
return this.zitadelCustomers(action, org, context);
|
|
1288
|
+
case 'zitadel:customers-test':
|
|
1289
|
+
return this.zitadelCustomers(action, org, context, TEST_CUSTOMERS);
|
|
1168
1290
|
case 'zitadel:mcp-project':
|
|
1169
1291
|
return this.zitadelMcpProject(action, org, context);
|
|
1170
1292
|
case 'zitadel:mcp-access':
|
|
@@ -1173,14 +1295,24 @@ export class FoundryStepExecutor {
|
|
|
1173
1295
|
return this.zitadelAccessTokenLifetime(context);
|
|
1174
1296
|
case 'coolify:app':
|
|
1175
1297
|
return this.coolifyApp(action, org, context);
|
|
1298
|
+
case 'coolify:github-source':
|
|
1299
|
+
return this.coolifyGithubSource(org, context);
|
|
1176
1300
|
case 'coolify:network':
|
|
1177
1301
|
return this.coolifyNetwork(action, org, context);
|
|
1178
1302
|
case 'coolify:bluegreen':
|
|
1179
1303
|
case 'coolify:bluegreen-lb':
|
|
1180
1304
|
case 'coolify:bluegreen-connector':
|
|
1181
1305
|
return this.coolifyBlueGreen(action, org, context);
|
|
1306
|
+
case 'coolify:workspace':
|
|
1307
|
+
return this.coolifyWorkspace(action, org, context);
|
|
1308
|
+
case 'coolify:promote':
|
|
1309
|
+
return this.coolifyPromote(action, org, context);
|
|
1182
1310
|
case 'coolify:broker-entry':
|
|
1183
1311
|
return this.coolifyBrokerEntry(action, org, context);
|
|
1312
|
+
case 'coolify:workspace-broker-entry':
|
|
1313
|
+
return this.coolifyWorkspaceBrokerEntry(action, org, context);
|
|
1314
|
+
case 'coolify:workspaces-database':
|
|
1315
|
+
return this.coolifyWorkspacesDatabase(org, context);
|
|
1184
1316
|
case 'coolify:database':
|
|
1185
1317
|
return this.coolifyDatabase(action, org, context);
|
|
1186
1318
|
case 'coolify:org-postgres':
|
|
@@ -1189,6 +1321,12 @@ export class FoundryStepExecutor {
|
|
|
1189
1321
|
return this.orgPostgresDatabases(action, org, context);
|
|
1190
1322
|
case 'coolify:org-postgres-migrate':
|
|
1191
1323
|
return this.orgPostgresMigrate(action, org, context);
|
|
1324
|
+
case 'coolify:org-apps-admin':
|
|
1325
|
+
return this.orgAppsAdmin(action, org, context);
|
|
1326
|
+
case 'cloudflare:apps-db-route':
|
|
1327
|
+
return this.appsDbRoute(action, org, context);
|
|
1328
|
+
case 'cloudflare:apps-zones':
|
|
1329
|
+
return this.appsZones(action, org, context);
|
|
1192
1330
|
case 'coolify:backup':
|
|
1193
1331
|
return this.coolifyBackup(action, org, context);
|
|
1194
1332
|
case 'coolify:calls':
|
|
@@ -1202,7 +1340,7 @@ export class FoundryStepExecutor {
|
|
|
1202
1340
|
case 'cloudflare:zone':
|
|
1203
1341
|
return this.cloudflareZone(action, org, context);
|
|
1204
1342
|
case 'cloudflare:access':
|
|
1205
|
-
return this.cloudflareAccess(action, org);
|
|
1343
|
+
return this.cloudflareAccess(action, org, context);
|
|
1206
1344
|
case 'cloudflare:gate':
|
|
1207
1345
|
return this.cloudflareGate(action, org, context);
|
|
1208
1346
|
case 'cloudflare:ratelimit':
|
|
@@ -1214,7 +1352,7 @@ export class FoundryStepExecutor {
|
|
|
1214
1352
|
if (!workers) {
|
|
1215
1353
|
throw new Error(`cloudflare:worker-app for ${org.slug}: this executor was built without a WorkerAppPort`);
|
|
1216
1354
|
}
|
|
1217
|
-
return applyWorkerApp(this.workerAppDeps(workers, org, () => context.recordedIds()), org, action, context);
|
|
1355
|
+
return applyWorkerApp(this.workerAppDeps(workers, org, () => context.recordedIds()), withWorkspaceWorkers(org), action, context);
|
|
1218
1356
|
}
|
|
1219
1357
|
case 'files:organisation':
|
|
1220
1358
|
return this.filesOrganisation(action, org, context);
|
|
@@ -1236,6 +1374,14 @@ export class FoundryStepExecutor {
|
|
|
1236
1374
|
return this.edgeEntryKey(action, org, context);
|
|
1237
1375
|
case 'edge:reserved-labels':
|
|
1238
1376
|
return this.edgeReservedLabels(action, org, context);
|
|
1377
|
+
case 'edge:slug':
|
|
1378
|
+
return this.edgeSlug(action, org, context);
|
|
1379
|
+
case 'jobs:project':
|
|
1380
|
+
return this.jobsProject(action, org, context);
|
|
1381
|
+
case 'github:deploy-key':
|
|
1382
|
+
return this.githubDeployKey(action, org, context);
|
|
1383
|
+
case 'audit:ledger':
|
|
1384
|
+
return this.auditLedger(action, org, context);
|
|
1239
1385
|
case 'coolify:mail':
|
|
1240
1386
|
return this.coolifyMail(action, org, context);
|
|
1241
1387
|
case 'coolify:mail-copy':
|
|
@@ -1254,6 +1400,12 @@ export class FoundryStepExecutor {
|
|
|
1254
1400
|
return this.bouleOwners(org, context);
|
|
1255
1401
|
case 'coolify:boule-broker-entry':
|
|
1256
1402
|
return this.bouleBrokerEntry(org, context);
|
|
1403
|
+
case 'coolify:kosmos':
|
|
1404
|
+
return this.coolifyKosmos(action, org, context);
|
|
1405
|
+
case 'coolify:broker-registrar':
|
|
1406
|
+
return this.brokerRegistrar(org, context);
|
|
1407
|
+
case 'coolify:keys-registrar':
|
|
1408
|
+
return this.keysRegistrar(action, org, context);
|
|
1257
1409
|
case 'jobs:registration':
|
|
1258
1410
|
return this.jobsRegistration(action, org, context);
|
|
1259
1411
|
case 'cloudflare:db-route':
|
|
@@ -1266,6 +1418,8 @@ export class FoundryStepExecutor {
|
|
|
1266
1418
|
return this.workerAppDb(org, context);
|
|
1267
1419
|
case 'coolify:own-jobs':
|
|
1268
1420
|
return this.ownJobs(action, org, context);
|
|
1421
|
+
case 'coolify:own-jobs-route':
|
|
1422
|
+
return this.ownJobsRoute(action, org, context);
|
|
1269
1423
|
case 'coolify:own-mail':
|
|
1270
1424
|
return this.ownMail(action, org, context);
|
|
1271
1425
|
case 'coolify:own-sfu':
|
|
@@ -1288,7 +1442,10 @@ export class FoundryStepExecutor {
|
|
|
1288
1442
|
* app carries any policy but foundry's, since that policy could let others
|
|
1289
1443
|
* in.
|
|
1290
1444
|
*/
|
|
1291
|
-
async cloudflareAccess(action, org) {
|
|
1445
|
+
async cloudflareAccess(action, org, context) {
|
|
1446
|
+
const machine = action.desired.machineBypass;
|
|
1447
|
+
if (machine)
|
|
1448
|
+
return this.cloudflareMachineBypass(action, org, context);
|
|
1292
1449
|
const rule = (org.hosting.access ?? []).find((r) => appStepKey(org, r.hostname) === action.key);
|
|
1293
1450
|
const owned = action.desired.ownedBypass;
|
|
1294
1451
|
if (!rule && owned && appStepKey(org, owned) === action.key) {
|
|
@@ -1310,6 +1467,41 @@ export class FoundryStepExecutor {
|
|
|
1310
1467
|
policy: await this.accessPolicyFor(org, rule),
|
|
1311
1468
|
});
|
|
1312
1469
|
}
|
|
1470
|
+
/**
|
|
1471
|
+
* F11 (`plan/machine-paths.ts`): one path-scoped Access application (`<host><path>`) with foundry's bypass
|
|
1472
|
+
* policy, so a machine caller is not stopped by the host's gate; or its removal. The stem of the step key
|
|
1473
|
+
* (`mp-<hash>`) names it; the recorded `host/path` is kept only to word the removal.
|
|
1474
|
+
*/
|
|
1475
|
+
async cloudflareMachineBypass(action, org, context) {
|
|
1476
|
+
const stem = action.key.slice(org.id.length + 1);
|
|
1477
|
+
const desired = action.desired;
|
|
1478
|
+
if (desired.machineBypass === 'delete') {
|
|
1479
|
+
const id = context.recordedIds()[`cloudflare:access:${action.key}`];
|
|
1480
|
+
if (id) {
|
|
1481
|
+
try {
|
|
1482
|
+
await this.accessPort().deleteAccessApp(id);
|
|
1483
|
+
}
|
|
1484
|
+
catch (error) {
|
|
1485
|
+
if (!isEdgeNotFound(error))
|
|
1486
|
+
throw error;
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
forgetScopeId(context, `cloudflare:access:${action.key}`);
|
|
1490
|
+
forgetScopeId(context, machineBypassScopeKey(stem));
|
|
1491
|
+
return {};
|
|
1492
|
+
}
|
|
1493
|
+
if (!desired.domain) {
|
|
1494
|
+
throw new Error(`cloudflare:access for ${org.slug}: machine bypass '${stem}' names no host and path`);
|
|
1495
|
+
}
|
|
1496
|
+
const result = await this.cloudflareAccessApp(org, {
|
|
1497
|
+
appName: `foundry:${org.id}:${desired.domain}`,
|
|
1498
|
+
domain: desired.domain,
|
|
1499
|
+
policyName: ACCESS_RULE_POLICY_NAMES.bypass,
|
|
1500
|
+
policy: await this.accessPolicyFor(org, { kind: 'bypass', hostname: desired.domain }),
|
|
1501
|
+
});
|
|
1502
|
+
context.recordScopeId(machineBypassScopeKey(stem), desired.domain);
|
|
1503
|
+
return result;
|
|
1504
|
+
}
|
|
1313
1505
|
/**
|
|
1314
1506
|
* `plan/diff.ts`'s `planGates`: makes, changes or deletes one declared
|
|
1315
1507
|
* gate through edge. A create needs the platform's recorded Access provider,
|
|
@@ -1326,13 +1518,30 @@ export class FoundryStepExecutor {
|
|
|
1326
1518
|
const recorded = context.recordedIds();
|
|
1327
1519
|
const gateId = recorded[gateIdRecordKey(org.id, gateKey)];
|
|
1328
1520
|
const hostnamesKey = gateHostnamesScopeKey(gateKey);
|
|
1521
|
+
const peopleKey = gatePeopleScopeKey(gateKey);
|
|
1329
1522
|
if (desired.mode === 'delete') {
|
|
1330
1523
|
if (gateId)
|
|
1331
1524
|
await this.accessPort().deleteGate(gateId);
|
|
1332
1525
|
if (context.forgetId) {
|
|
1333
1526
|
context.forgetId(gateIdRecordKey(org.id, gateKey));
|
|
1334
1527
|
context.forgetId(hostnamesKey);
|
|
1528
|
+
context.forgetId(peopleKey);
|
|
1529
|
+
}
|
|
1530
|
+
return {};
|
|
1531
|
+
}
|
|
1532
|
+
if (desired.mode === 'people') {
|
|
1533
|
+
const emails = desired.emails ?? [];
|
|
1534
|
+
if (!gateId)
|
|
1535
|
+
throw new Error(`${at}: no gate is recorded, so there is no one to change`);
|
|
1536
|
+
if (emails.length === 0) {
|
|
1537
|
+
throw new Error(`${at}: a gate nobody can pass is a mistake, and the list is empty`);
|
|
1335
1538
|
}
|
|
1539
|
+
const access = this.accessPort();
|
|
1540
|
+
if (!access.setGatePeople) {
|
|
1541
|
+
throw new Error(`${at}: this host's Access port cannot change who passes a gate`);
|
|
1542
|
+
}
|
|
1543
|
+
await access.setGatePeople(gateId, emails);
|
|
1544
|
+
context.recordScopeId(peopleKey, peopleHash(emails));
|
|
1336
1545
|
return {};
|
|
1337
1546
|
}
|
|
1338
1547
|
const hostnames = desired.hostnames ?? [];
|
|
@@ -1346,6 +1555,7 @@ export class FoundryStepExecutor {
|
|
|
1346
1555
|
// The gate is gone at edge: forget it, so the next plan creates it again.
|
|
1347
1556
|
context.forgetId?.(gateIdRecordKey(org.id, gateKey));
|
|
1348
1557
|
context.forgetId?.(hostnamesKey);
|
|
1558
|
+
context.forgetId?.(peopleKey);
|
|
1349
1559
|
return {};
|
|
1350
1560
|
}
|
|
1351
1561
|
context.recordScopeId(hostnamesKey, JSON.stringify(hostnames));
|
|
@@ -1358,7 +1568,7 @@ export class FoundryStepExecutor {
|
|
|
1358
1568
|
if (!claimValue) {
|
|
1359
1569
|
throw new Error(`${at}: ${org.slug} has no sign-in organisation recorded yet`);
|
|
1360
1570
|
}
|
|
1361
|
-
const emails = org.owners.map((owner) => owner.email);
|
|
1571
|
+
const emails = desired.emails ?? org.owners.map((owner) => owner.email);
|
|
1362
1572
|
if (emails.length === 0) {
|
|
1363
1573
|
throw new Error(`${at}: ${org.slug} declares no owner with an email, and a gate nobody can pass is a mistake`);
|
|
1364
1574
|
}
|
|
@@ -1373,6 +1583,8 @@ export class FoundryStepExecutor {
|
|
|
1373
1583
|
emails,
|
|
1374
1584
|
});
|
|
1375
1585
|
context.recordScopeId(hostnamesKey, JSON.stringify(hostnames));
|
|
1586
|
+
if (desired.emails)
|
|
1587
|
+
context.recordScopeId(peopleKey, peopleHash(desired.emails));
|
|
1376
1588
|
return { resourceId: created.id };
|
|
1377
1589
|
}
|
|
1378
1590
|
/** The Cloudflare Access policy body for one `hosting.access` rule, by kind (#246). */
|
|
@@ -1535,6 +1747,7 @@ export class FoundryStepExecutor {
|
|
|
1535
1747
|
return db.internal_db_url;
|
|
1536
1748
|
},
|
|
1537
1749
|
secret: (value) => void this.handledSecrets.add(value),
|
|
1750
|
+
log: (line) => console.info(redactSecrets(line, [...this.handledSecrets])),
|
|
1538
1751
|
},
|
|
1539
1752
|
}
|
|
1540
1753
|
: {}),
|
|
@@ -2488,13 +2701,13 @@ export class FoundryStepExecutor {
|
|
|
2488
2701
|
* every issuer org, which is named by a slug and a slug has no spaces. Every call here is idempotent, so
|
|
2489
2702
|
* a re-run after a crash finishes the job.
|
|
2490
2703
|
*/
|
|
2491
|
-
async zitadelCustomers(action, org, context) {
|
|
2704
|
+
async zitadelCustomers(action, org, context, variant = LIVE_CUSTOMERS) {
|
|
2492
2705
|
const zitadelOrgId = context.recordedIds().zitadel_org;
|
|
2493
2706
|
if (!zitadelOrgId) {
|
|
2494
|
-
throw new Error(
|
|
2707
|
+
throw new Error(`${variant.step} for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
|
|
2495
2708
|
}
|
|
2496
2709
|
const projectId = await this.ensureZitadelProject(org, zitadelOrgId, context);
|
|
2497
|
-
const { org: customers } = await this.ports.zitadel.findOrCreateOrg(
|
|
2710
|
+
const { org: customers } = await this.ports.zitadel.findOrCreateOrg(variant.name(org.slug));
|
|
2498
2711
|
// Self-registration follows `customers.signUp` (off by default). The marker is written last, so a
|
|
2499
2712
|
// run that failed before here is planned again.
|
|
2500
2713
|
const signUp = action.desired.signUp === true;
|
|
@@ -2504,11 +2717,14 @@ export class FoundryStepExecutor {
|
|
|
2504
2717
|
await this.ports.zitadel.closeSelfRegistration(customers.id);
|
|
2505
2718
|
await this.ports.zitadel.findOrCreateProjectGrant(zitadelOrgId, projectId, customers.id);
|
|
2506
2719
|
// 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
|
-
|
|
2720
|
+
const signIn = await this.writeCustomersSignInMetadata(org, customers.id, action, context, variant);
|
|
2721
|
+
// The sign-in window text is the live customers organisation's; the test customers planner plans none.
|
|
2722
|
+
const room = variant === LIVE_CUSTOMERS
|
|
2723
|
+
? await this.writeCustomersRoomMetadata(org, customers.id, action, context)
|
|
2724
|
+
: null;
|
|
2725
|
+
context.recordScopeId(variant.signUpKey, signUp ? '1' : '0');
|
|
2510
2726
|
if (signIn)
|
|
2511
|
-
context.recordScopeId(
|
|
2727
|
+
context.recordScopeId(variant.signInKey, signIn);
|
|
2512
2728
|
if (room !== null)
|
|
2513
2729
|
context.recordScopeId(CUSTOMERS_ROOM_SCOPE_KEY, room);
|
|
2514
2730
|
return { resourceId: customers.id };
|
|
@@ -2550,13 +2766,13 @@ export class FoundryStepExecutor {
|
|
|
2550
2766
|
* only when the recorded marker differs, so a step for another change touches
|
|
2551
2767
|
* nothing here. Returns the marker value to record, or null for no change.
|
|
2552
2768
|
*/
|
|
2553
|
-
async writeCustomersSignInMetadata(org, customersOrgId, action, context) {
|
|
2769
|
+
async writeCustomersSignInMetadata(org, customersOrgId, action, context, variant = LIVE_CUSTOMERS) {
|
|
2554
2770
|
const want = action.desired.signIn;
|
|
2555
2771
|
if (want === undefined)
|
|
2556
2772
|
return null;
|
|
2557
2773
|
const { setOrgMetadata, removeOrgMetadata } = this.ports.zitadel;
|
|
2558
2774
|
if (!setOrgMetadata || !removeOrgMetadata) {
|
|
2559
|
-
throw new Error(
|
|
2775
|
+
throw new Error(`${variant.step} for ${org.slug}: the ZITADEL client cannot set organisation metadata`);
|
|
2560
2776
|
}
|
|
2561
2777
|
try {
|
|
2562
2778
|
if (want === 'email-code') {
|
|
@@ -2564,13 +2780,13 @@ export class FoundryStepExecutor {
|
|
|
2564
2780
|
return 'email-code';
|
|
2565
2781
|
}
|
|
2566
2782
|
// Nothing to remove when it was never written.
|
|
2567
|
-
if (context.recordedIds()[
|
|
2783
|
+
if (context.recordedIds()[variant.signInKey] !== 'email-code')
|
|
2568
2784
|
return null;
|
|
2569
2785
|
await removeOrgMetadata.call(this.ports.zitadel, customersOrgId, CUSTOMERS_SIGN_IN_METADATA_KEY);
|
|
2570
2786
|
return 'password';
|
|
2571
2787
|
}
|
|
2572
2788
|
catch {
|
|
2573
|
-
throw new Error(
|
|
2789
|
+
throw new Error(`${variant.step} for ${org.slug}: writing the sign-in metadata of organisation ${customersOrgId} failed`);
|
|
2574
2790
|
}
|
|
2575
2791
|
}
|
|
2576
2792
|
async zitadelApp(action, org, context) {
|
|
@@ -2584,16 +2800,21 @@ export class FoundryStepExecutor {
|
|
|
2584
2800
|
const isCmsClient = action.key === 'cms' && org.site.enabled;
|
|
2585
2801
|
const isAgoraClient = action.key === 'agora';
|
|
2586
2802
|
const isBouleClient = action.key === 'boule';
|
|
2803
|
+
const isKosmosClient = action.key === 'kosmos' && kosmosOn(org);
|
|
2587
2804
|
const authEntry = org.hosting.entries.find((e) => e.key === action.key && e.uses?.auth);
|
|
2805
|
+
// A workspace's own client `<key>-work`, signing in at the workspace host.
|
|
2806
|
+
const workspaceEntry = authEntry ? undefined : workspaceAuthEntryOf(org, action.key);
|
|
2588
2807
|
const clientIdScopeKey = isCmsClient
|
|
2589
2808
|
? CMS_CLIENT_ID_SCOPE_KEY
|
|
2590
2809
|
: isAgoraClient
|
|
2591
2810
|
? AGORA_CLIENT_ID_SCOPE_KEY
|
|
2592
2811
|
: isBouleClient
|
|
2593
2812
|
? BOULE_CLIENT_ID_SCOPE_KEY
|
|
2594
|
-
:
|
|
2595
|
-
?
|
|
2596
|
-
:
|
|
2813
|
+
: isKosmosClient
|
|
2814
|
+
? KOSMOS_CLIENT_ID_SCOPE_KEY
|
|
2815
|
+
: authEntry || workspaceEntry
|
|
2816
|
+
? entryClientIdScopeKey(action.key)
|
|
2817
|
+
: APP_CLIENT_ID_SCOPE_KEY;
|
|
2597
2818
|
const priorAppId = recorded[`zitadel:app:${action.key}`];
|
|
2598
2819
|
// Only a step the planner flagged as destroying (`recreate`, set with
|
|
2599
2820
|
// `destroys: true` so plan-apply asks for the typed confirmation) may
|
|
@@ -2936,6 +3157,8 @@ export class FoundryStepExecutor {
|
|
|
2936
3157
|
coolify: this.ports.coolify,
|
|
2937
3158
|
healthToken: async () => (await this.ports.heldKeys?.resolve(sharedSecretRef(org.id, healthTokenName(subject.id)))) ?? null,
|
|
2938
3159
|
secret: (value) => void this.handledSecrets.add(value),
|
|
3160
|
+
pinRef: sharedSecretRef(org.id, pinKeyName(subject.id)),
|
|
3161
|
+
...(this.ports.heldKeys ? { heldKeys: this.ports.heldKeys } : {}),
|
|
2939
3162
|
primaryUuid,
|
|
2940
3163
|
primaryBlueAppId,
|
|
2941
3164
|
runtime: this.ports.runtime ?? {},
|
|
@@ -3035,6 +3258,16 @@ export class FoundryStepExecutor {
|
|
|
3035
3258
|
this.handledSecrets.add(token);
|
|
3036
3259
|
return token;
|
|
3037
3260
|
},
|
|
3261
|
+
pinKey: () => mintPinKey({
|
|
3262
|
+
id: subject.id,
|
|
3263
|
+
ref: sharedSecretRef(org.id, pinKeyName(subject.id)),
|
|
3264
|
+
recorded,
|
|
3265
|
+
record: (k, v) => context.recordScopeId(k, v),
|
|
3266
|
+
persist: () => context.persistScopeIds(),
|
|
3267
|
+
heldKeys: this.ports.heldKeys ?? { resolve: async () => null },
|
|
3268
|
+
generate: () => randomBytes(32).toString('hex'),
|
|
3269
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
3270
|
+
}),
|
|
3038
3271
|
actionsKey: async () => {
|
|
3039
3272
|
const heldKeys = this.ports.heldKeys;
|
|
3040
3273
|
if (typeof heldKeys?.put !== 'function') {
|
|
@@ -3057,6 +3290,7 @@ export class FoundryStepExecutor {
|
|
|
3057
3290
|
return key;
|
|
3058
3291
|
},
|
|
3059
3292
|
secret: (value) => void this.handledSecrets.add(value),
|
|
3293
|
+
githubAppUuid: (repo) => this.githubAppUuidFor(org, repo, recorded()),
|
|
3060
3294
|
runtime,
|
|
3061
3295
|
sleep: runtime.sleep ?? defaultSleep,
|
|
3062
3296
|
label: `${action.system}:${action.kind}:${subject.id}`,
|
|
@@ -3074,6 +3308,296 @@ export class FoundryStepExecutor {
|
|
|
3074
3308
|
}
|
|
3075
3309
|
return undefined;
|
|
3076
3310
|
}
|
|
3311
|
+
/**
|
|
3312
|
+
* `coolify:workspace` (`plan/workspace.ts`): a server entry's private workspace on the primary server. It is
|
|
3313
|
+
* the blue/green stage (the stage app, its own empty database, the stage mail sink and the stage guards) at
|
|
3314
|
+
* the workspace host, with the Access gate `planGates` made first. Nothing here makes a color, a balancer or
|
|
3315
|
+
* a public name. The live app, when the entry is live, is only read for the names stage must not share.
|
|
3316
|
+
*/
|
|
3317
|
+
async coolifyWorkspace(action, org, context) {
|
|
3318
|
+
const recorded = () => context.recordedIds();
|
|
3319
|
+
const key = String(action.desired.entry ?? '');
|
|
3320
|
+
const entry = org.hosting.entries.find((e) => e.key === key && isServerWorkspace(e) && e.repo !== null);
|
|
3321
|
+
if (!entry) {
|
|
3322
|
+
throw new Error(`coolify:workspace for ${org.slug}: no server workspace '${key}'`);
|
|
3323
|
+
}
|
|
3324
|
+
const serverUuid = String(action.desired.server ?? '');
|
|
3325
|
+
const primaryUuid = String(action.desired.primary ?? '');
|
|
3326
|
+
const servers = await this.resolveServers(org, context);
|
|
3327
|
+
if (!servers.servers.some((s) => s.uuid === serverUuid) ||
|
|
3328
|
+
servers.primary.uuid !== primaryUuid) {
|
|
3329
|
+
throw new Error(`coolify:workspace for ${org.slug}: Coolify server ${serverUuid} is not the usable, recorded primary`);
|
|
3330
|
+
}
|
|
3331
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
3332
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
3333
|
+
const subject = workspaceSubject(org, entry);
|
|
3334
|
+
const host = workspaceHost(org.slug, entry.key);
|
|
3335
|
+
const gateId = recorded()[gateIdRecordKey(org.id, workspaceGateKey(entry.key))];
|
|
3336
|
+
const guards = this.ports.stageProbes ? { probes: this.ports.stageProbes } : {};
|
|
3337
|
+
if (gateId)
|
|
3338
|
+
guards.gate = { hostname: host, create: async () => ({ id: gateId }) };
|
|
3339
|
+
const runtime = this.ports.runtime ?? {};
|
|
3340
|
+
const authIssuer = action.desired.authIssuer ?? null;
|
|
3341
|
+
const signIn = await this.workspaceStageSignIn(org, entry, authIssuer, context);
|
|
3342
|
+
await applyWorkspaceServer({
|
|
3343
|
+
coolify: this.ports.coolify,
|
|
3344
|
+
recorded,
|
|
3345
|
+
record: (k, v) => context.recordScopeId(k, v),
|
|
3346
|
+
persist: () => context.persistScopeIds(),
|
|
3347
|
+
projectUuid,
|
|
3348
|
+
environment,
|
|
3349
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
3350
|
+
githubAppUuid: (repo) => this.githubAppUuidFor(org, repo, recorded()),
|
|
3351
|
+
runtime,
|
|
3352
|
+
sleep: runtime.sleep ?? defaultSleep,
|
|
3353
|
+
label: `${action.system}:${action.kind}:${entry.key}`,
|
|
3354
|
+
// The live app, when there is one: stage's names are checked against its env and nothing is copied from it.
|
|
3355
|
+
primaryBlueAppId: isLiveEntry(entry) ? (recorded()[subject.blueAppKey] ?? null) : null,
|
|
3356
|
+
logDir: LOG_DIR,
|
|
3357
|
+
isRunning: isRunningOrStarting,
|
|
3358
|
+
stageGuards: guards,
|
|
3359
|
+
stageSink: { slug: org.slug },
|
|
3360
|
+
stageAppUrl: `https://${host}`,
|
|
3361
|
+
...(signIn ? { signIn } : {}),
|
|
3362
|
+
}, subject, serverUuid);
|
|
3363
|
+
// Only a stage that took the values has applied them; otherwise the next plan tries again.
|
|
3364
|
+
if (signIn && recorded()[bgKey.stageState(entry.key)] === 'ok') {
|
|
3365
|
+
const marker = workspaceSignInMarker(org, entry, recorded(), authIssuer);
|
|
3366
|
+
if (marker)
|
|
3367
|
+
context.recordScopeId(wsKey.signIn(entry.key), marker);
|
|
3368
|
+
}
|
|
3369
|
+
context.recordScopeId(wsKey.applied(entry.key), workspaceStructureHash(org, entry));
|
|
3370
|
+
await context.persistScopeIds();
|
|
3371
|
+
return undefined;
|
|
3372
|
+
}
|
|
3373
|
+
/**
|
|
3374
|
+
* The stage's own sign-in, for a workspace of an entry with `uses.auth`: the recorded organisation, client and
|
|
3375
|
+
* issuer, its own workspaces key (made and held first, once) and what it must never hold, which is what the
|
|
3376
|
+
* live app holds. A key is registered for redaction before it goes anywhere. Errors name the entry and the ref,
|
|
3377
|
+
* never a value; every held-key read has a timeout.
|
|
3378
|
+
*/
|
|
3379
|
+
async workspaceStageSignIn(org, entry, authIssuer, context) {
|
|
3380
|
+
const auth = entry.uses?.auth;
|
|
3381
|
+
if (!auth)
|
|
3382
|
+
return undefined;
|
|
3383
|
+
const at = `coolify:workspace for ${org.slug}`;
|
|
3384
|
+
const recorded = context.recordedIds();
|
|
3385
|
+
const authEnv = workspaceAuthEnv(org, entry, recorded, authIssuer);
|
|
3386
|
+
if (!authEnv) {
|
|
3387
|
+
throw new Error(`${at}: the sign-in organisation, client or issuer of entry '${entry.key}' is not recorded yet`);
|
|
3388
|
+
}
|
|
3389
|
+
const env = Object.entries(authEnv).map(([key, value]) => ({ key, value }));
|
|
3390
|
+
const held = this.ports.heldKeys;
|
|
3391
|
+
const heldKeys = held && {
|
|
3392
|
+
resolve: (ref) => withLabelledTimeout('held key read', HELD_KEY_READ_TIMEOUT_MS, () => held.resolve(ref)),
|
|
3393
|
+
...(typeof held.put === 'function'
|
|
3394
|
+
? {
|
|
3395
|
+
put: (ref, value) => withLabelledTimeout('held key write', HELD_KEY_READ_TIMEOUT_MS, () => held.put(ref, value)),
|
|
3396
|
+
}
|
|
3397
|
+
: {}),
|
|
3398
|
+
};
|
|
3399
|
+
if (auth.workspaces) {
|
|
3400
|
+
const workspacesKey = await ensureWorkspacesKey({
|
|
3401
|
+
orgId: org.id,
|
|
3402
|
+
slug: org.slug,
|
|
3403
|
+
entryKey: workspaceClientKey(entry.key),
|
|
3404
|
+
heldKeys,
|
|
3405
|
+
recorded: () => context.recordedIds(),
|
|
3406
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
3407
|
+
persist: () => context.persistScopeIds(),
|
|
3408
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
3409
|
+
});
|
|
3410
|
+
env.push({ key: 'WORKSPACES_API_KEY', value: workspacesKey });
|
|
3411
|
+
}
|
|
3412
|
+
const workspacesKeys = [];
|
|
3413
|
+
for (const live of org.hosting.entries.filter((e) => e.uses?.auth?.workspaces)) {
|
|
3414
|
+
const value = await heldKeys?.resolve(workspacesKeyRef(org.id, live.key));
|
|
3415
|
+
if (value) {
|
|
3416
|
+
this.handledSecrets.add(value);
|
|
3417
|
+
workspacesKeys.push(value);
|
|
3418
|
+
}
|
|
3419
|
+
}
|
|
3420
|
+
const liveMcp = org.hosting.entries.filter((live) => live.uses?.auth?.mcp);
|
|
3421
|
+
return {
|
|
3422
|
+
env,
|
|
3423
|
+
customers: auth.users === 'customers',
|
|
3424
|
+
redirectUri: `https://${workspaceClientHosts(org, entry)[0]}/auth/callback`,
|
|
3425
|
+
live: {
|
|
3426
|
+
...(recorded[`zitadel:customers:${org.id}`]
|
|
3427
|
+
? { customersOrgId: recorded[`zitadel:customers:${org.id}`] }
|
|
3428
|
+
: {}),
|
|
3429
|
+
clientIds: liveClientIds(org, recorded),
|
|
3430
|
+
workspacesKeys,
|
|
3431
|
+
mcpAudiences: liveMcp.flatMap((m) => recorded[entryMcpProjectScopeKey(m.key)] ?? []),
|
|
3432
|
+
mcpResources: liveMcp.flatMap((m) => entryMcp(org, m, recorded)?.resource ?? []),
|
|
3433
|
+
},
|
|
3434
|
+
};
|
|
3435
|
+
}
|
|
3436
|
+
/**
|
|
3437
|
+
* `coolify:workspace-broker-entry` (`plan/workspace-sign-in.ts`): the broker entry `<slug>-<key>-work` of a
|
|
3438
|
+
* workspace with sign-in, written with the stage app's own `AUTH_APP_KEY` and the hash of its own workspaces
|
|
3439
|
+
* key. It never reads or writes the live entry. Records what it wrote, never a key.
|
|
3440
|
+
*/
|
|
3441
|
+
async coolifyWorkspaceBrokerEntry(action, org, context) {
|
|
3442
|
+
const at = `coolify:workspace-broker-entry for ${org.slug}`;
|
|
3443
|
+
const broker = this.ports.broker;
|
|
3444
|
+
if (!broker)
|
|
3445
|
+
throw new Error(`${at}: the broker port is not wired`);
|
|
3446
|
+
const recorded = context.recordedIds();
|
|
3447
|
+
const entry = org.hosting.entries.find((e) => appStepKey(org, e.key) === action.key && isServerWorkspace(e) && e.uses?.auth);
|
|
3448
|
+
if (!entry?.uses?.auth)
|
|
3449
|
+
throw new Error(`${at}: no workspace with sign-in for ${action.key}`);
|
|
3450
|
+
const wanted = wantedWorkspaceBrokerEntry(org, entry, recorded);
|
|
3451
|
+
if ('missing' in wanted) {
|
|
3452
|
+
throw new Error(`${at}: ${wanted.missing.map((m) => m.what).join(', ')} not recorded yet`);
|
|
3453
|
+
}
|
|
3454
|
+
const stagePrefix = `bg.${entry.key}.app.`;
|
|
3455
|
+
const stageId = Object.entries(recorded).find(([key, id]) => id && key.startsWith(stagePrefix) && key.endsWith('.stage'))?.[1];
|
|
3456
|
+
if (!stageId)
|
|
3457
|
+
throw new Error(`${at}: the workspace's app is not recorded yet`);
|
|
3458
|
+
const appKey = (await this.ports.coolify.listEnvs(stageId)).find((e) => e.key === 'AUTH_APP_KEY' && !e.is_preview)?.value;
|
|
3459
|
+
if (!appKey || appKey.length < 32) {
|
|
3460
|
+
throw new Error(`${at}: the workspace's app ${stageId} has no AUTH_APP_KEY yet`);
|
|
3461
|
+
}
|
|
3462
|
+
this.handledSecrets.add(appKey);
|
|
3463
|
+
const clientKey = workspaceClientKey(entry.key);
|
|
3464
|
+
await this.writeAppBrokerEntry(org, broker, clientKey, wanted.want, appKey, context, wanted.want.workspaces ? await this.workspacesInputs(org, clientKey) : {});
|
|
3465
|
+
return {};
|
|
3466
|
+
}
|
|
3467
|
+
/**
|
|
3468
|
+
* `coolify:workspaces-database` (`plan/broker-workspaces-database.ts`): the broker's workspaces database, made
|
|
3469
|
+
* beside the broker (the platform's project and network, or the company's own for an org that owns its services)
|
|
3470
|
+
* and held at `workspacesDatabaseRef(org)`. Reads the broker and the held keys again first and makes nothing when
|
|
3471
|
+
* either has a URL, so a database is never made over one. Records nothing: the held URL is the record, and a
|
|
3472
|
+
* database made but not held is found by name on the next apply. The database stays when the org is removed, as
|
|
3473
|
+
* the held keys do.
|
|
3474
|
+
*/
|
|
3475
|
+
async coolifyWorkspacesDatabase(org, context) {
|
|
3476
|
+
const at = `coolify:workspaces-database for ${org.slug}`;
|
|
3477
|
+
const sources = await this.workspacesDatabaseSources(org, context.recordedIds()).catch(() => {
|
|
3478
|
+
throw new Error(`${at}: the broker's env or the held URL could not be read; nothing was made`);
|
|
3479
|
+
});
|
|
3480
|
+
if (sources.inEnv || sources.held)
|
|
3481
|
+
return undefined;
|
|
3482
|
+
const own = ownsServices(org);
|
|
3483
|
+
const target = own ? orgTarget(org) : platformTarget();
|
|
3484
|
+
let coolify;
|
|
3485
|
+
let projectUuid;
|
|
3486
|
+
if (own) {
|
|
3487
|
+
const ownPorts = this.ports.ownServices;
|
|
3488
|
+
if (!ownPorts)
|
|
3489
|
+
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
3490
|
+
coolify = limited(this.placedForCompany(ownPorts.provision.auth.coolify, org, context), LIMITS.ownAuth);
|
|
3491
|
+
projectUuid = () => this.ensureCoolifyProject(org, context);
|
|
3492
|
+
}
|
|
3493
|
+
else {
|
|
3494
|
+
coolify = placedOnPlatform(this.ports.coolify);
|
|
3495
|
+
projectUuid = async () => (await this.ports.coolify.findProjectByName(PLATFORM_AUTH_PROJECT))?.uuid ?? null;
|
|
3496
|
+
}
|
|
3497
|
+
const known = context.recordedIds()[BROKER_WORKSPACES_DATABASE_SCOPE_KEY];
|
|
3498
|
+
await ensureBrokerWorkspacesDatabase({
|
|
3499
|
+
target,
|
|
3500
|
+
coolify,
|
|
3501
|
+
projectUuid,
|
|
3502
|
+
heldKeys: this.ports.heldKeys,
|
|
3503
|
+
...(known ? { knownUuid: known } : {}),
|
|
3504
|
+
recordUuid: async (uuid) => {
|
|
3505
|
+
context.recordScopeId(BROKER_WORKSPACES_DATABASE_SCOPE_KEY, uuid);
|
|
3506
|
+
await context.persistScopeIds();
|
|
3507
|
+
},
|
|
3508
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
3509
|
+
});
|
|
3510
|
+
return undefined;
|
|
3511
|
+
}
|
|
3512
|
+
/**
|
|
3513
|
+
* `coolify:promote`: moves a live workspace entry's app to `HostingEntry.liveCommit`. Patches `git_commit_sha`,
|
|
3514
|
+
* deploys and waits for the deployment, then records `ws.<key>.livecommit`. A failure after the patch leaves the
|
|
3515
|
+
* app configured for the new commit with the old container still running and no marker, so the next apply
|
|
3516
|
+
* patches and deploys again.
|
|
3517
|
+
*/
|
|
3518
|
+
async coolifyPromote(action, org, context) {
|
|
3519
|
+
const key = String(action.desired.entry ?? '');
|
|
3520
|
+
const entry = org.hosting.entries.find((e) => e.key === key && isServerWorkspace(e));
|
|
3521
|
+
const commit = entry?.liveCommit;
|
|
3522
|
+
if (!entry || commit === undefined || !FULL_COMMIT_SHA.test(commit)) {
|
|
3523
|
+
throw new Error(`coolify:promote for ${org.slug}: entry '${key}' has no valid liveCommit`);
|
|
3524
|
+
}
|
|
3525
|
+
const appId = context.recordedIds()[`coolify:app:${appStepKey(org, key)}`];
|
|
3526
|
+
if (!appId) {
|
|
3527
|
+
throw new Error(`coolify:promote for ${org.slug}: the live app for '${key}' is not recorded`);
|
|
3528
|
+
}
|
|
3529
|
+
await this.ports.coolify.updateApplication(appId, { git_commit_sha: commit });
|
|
3530
|
+
const runtime = this.ports.runtime ?? {};
|
|
3531
|
+
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`);
|
|
3532
|
+
await recordBuiltCommit(this.ports.coolify, { appId, deploymentUuid: built.deploymentUuid, name: key }, (k, v) => context.recordScopeId(k, v));
|
|
3533
|
+
context.recordScopeId(wsKey.liveCommit(key), commit);
|
|
3534
|
+
await context.persistScopeIds();
|
|
3535
|
+
return undefined;
|
|
3536
|
+
}
|
|
3537
|
+
/**
|
|
3538
|
+
* The commit a workspace entry's live app is made from: the newest finished deployment of its workspace's stage
|
|
3539
|
+
* app, else the commit recorded when the workspace was last applied. Recorded as `ws.<key>.commit`. Throws,
|
|
3540
|
+
* before any app is made, when there is none.
|
|
3541
|
+
*/
|
|
3542
|
+
async workspaceCommit(org, entry, serverUuid, context) {
|
|
3543
|
+
const stageId = context.recordedIds()[bgKey.app(entry.key, serverUuid, 'stage')];
|
|
3544
|
+
const commit = (stageId ? await readWorkspaceCommit(this.ports.coolify, stageId) : null) ??
|
|
3545
|
+
context.recordedIds()[wsKey.commit(entry.key)] ??
|
|
3546
|
+
null;
|
|
3547
|
+
if (!commit) {
|
|
3548
|
+
throw new Error(`coolify:app for ${org.slug}: workspace '${entry.key}' has not deployed a commit, so there is nothing to put live`);
|
|
3549
|
+
}
|
|
3550
|
+
context.recordScopeId(wsKey.commit(entry.key), commit);
|
|
3551
|
+
await context.persistScopeIds();
|
|
3552
|
+
return commit;
|
|
3553
|
+
}
|
|
3554
|
+
/**
|
|
3555
|
+
* The Coolify GitHub source an app of `repo` is created through: the company's own (`coolify:github-source`,
|
|
3556
|
+
* recorded by that step) when `repo` is under the company's GitHub account, else the platform's.
|
|
3557
|
+
*/
|
|
3558
|
+
async githubAppUuidFor(org, repo, recorded) {
|
|
3559
|
+
if (!companyGithubFor(org.hosting, repo)) {
|
|
3560
|
+
return (await this.ports.coolify.requireGithubApp()).uuid;
|
|
3561
|
+
}
|
|
3562
|
+
const uuid = recorded[githubSourceResourceKey(org.id)];
|
|
3563
|
+
if (!uuid) {
|
|
3564
|
+
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`);
|
|
3565
|
+
}
|
|
3566
|
+
return uuid;
|
|
3567
|
+
}
|
|
3568
|
+
/** 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. */
|
|
3569
|
+
async coolifyGithubSource(org, context) {
|
|
3570
|
+
const github = org.hosting.github;
|
|
3571
|
+
if (!github)
|
|
3572
|
+
throw new Error(`coolify:github-source for ${org.slug}: no hosting.github declared`);
|
|
3573
|
+
const heldKeys = this.ports.heldKeys;
|
|
3574
|
+
if (!heldKeys) {
|
|
3575
|
+
throw new Error(`coolify:github-source for ${org.slug}: the executor was built without heldKeys`);
|
|
3576
|
+
}
|
|
3577
|
+
const { uuid } = await ensureGithubSource({
|
|
3578
|
+
coolify: this.ports.coolify,
|
|
3579
|
+
heldKeys,
|
|
3580
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
3581
|
+
label: `coolify:github-source for ${org.slug}`,
|
|
3582
|
+
slug: org.slug,
|
|
3583
|
+
github,
|
|
3584
|
+
});
|
|
3585
|
+
context.recordScopeId(GITHUB_SOURCE_INSTALLATION_SCOPE_KEY, String(github.installationId));
|
|
3586
|
+
await context.persistScopeIds();
|
|
3587
|
+
return { resourceId: uuid };
|
|
3588
|
+
}
|
|
3589
|
+
/** True when no Coolify GitHub source has this uuid. A failed read counts as not gone. */
|
|
3590
|
+
async githubSourceGone(uuid) {
|
|
3591
|
+
try {
|
|
3592
|
+
const sources = await withLabelledTimeout('coolify:github-source: listing sources', GITHUB_SOURCE_TIMEOUT_MS, () => this.ports.coolify.raw('GET', '/github-apps'));
|
|
3593
|
+
return (Array.isArray(sources) && !sources.some((s) => s?.uuid === uuid));
|
|
3594
|
+
}
|
|
3595
|
+
catch (error) {
|
|
3596
|
+
if (error instanceof CallError)
|
|
3597
|
+
return false;
|
|
3598
|
+
throw error;
|
|
3599
|
+
}
|
|
3600
|
+
}
|
|
3077
3601
|
/**
|
|
3078
3602
|
* Stage's guards (`plan/stage-guards.ts`): the probe port the host supplied, and the Access gate over the stage
|
|
3079
3603
|
* host. The gate is offered only when everything it needs is in hand (a domain, the platform's Access provider,
|
|
@@ -3201,6 +3725,8 @@ export class FoundryStepExecutor {
|
|
|
3201
3725
|
// (the marker is recorded only after the redeploy), e.g. a first deploy
|
|
3202
3726
|
// that failed: starting it builds nothing. A parked app has its marker.
|
|
3203
3727
|
let neverDeployed = false;
|
|
3728
|
+
// A workspace entry's live app is made from the commit its workspace runs.
|
|
3729
|
+
let pinnedCommit = null;
|
|
3204
3730
|
if (existing) {
|
|
3205
3731
|
appId = existing.uuid;
|
|
3206
3732
|
// Found by name (rather than just created) is started back up if not
|
|
@@ -3219,14 +3745,18 @@ export class FoundryStepExecutor {
|
|
|
3219
3745
|
}
|
|
3220
3746
|
else {
|
|
3221
3747
|
const server = await this.resolveServer(org, context);
|
|
3222
|
-
const
|
|
3748
|
+
const githubAppUuid = await this.githubAppUuidFor(org, appRepo, context.recordedIds());
|
|
3749
|
+
if (isServerWorkspace(entry)) {
|
|
3750
|
+
pinnedCommit =
|
|
3751
|
+
entry.liveCommit ?? (await this.workspaceCommit(org, entry, server.uuid, context));
|
|
3752
|
+
}
|
|
3223
3753
|
const created = await this.ports.coolify.createGithubAppApplication({
|
|
3224
3754
|
is_preview_deployments_enabled: false,
|
|
3225
3755
|
project_uuid: projectUuid,
|
|
3226
3756
|
server_uuid: server.uuid,
|
|
3227
3757
|
environment_name: environment.name,
|
|
3228
3758
|
environment_uuid: environment.uuid,
|
|
3229
|
-
github_app_uuid:
|
|
3759
|
+
github_app_uuid: githubAppUuid,
|
|
3230
3760
|
git_repository: appRepo,
|
|
3231
3761
|
git_branch: entry.branch,
|
|
3232
3762
|
build_pack: entry.buildPack,
|
|
@@ -3261,6 +3791,8 @@ export class FoundryStepExecutor {
|
|
|
3261
3791
|
// step for it). A created app has them off from the create.
|
|
3262
3792
|
if (previewsOn(live))
|
|
3263
3793
|
patch.is_preview_deployments_enabled = false;
|
|
3794
|
+
if (pinnedCommit)
|
|
3795
|
+
patch.git_commit_sha = pinnedCommit;
|
|
3264
3796
|
// #543: the health-check quartet too, every run -- the same reason
|
|
3265
3797
|
// Archon's own doc comment gives (without one, Coolify's rolling update
|
|
3266
3798
|
// has nothing to wait on and stops the old container immediately). The
|
|
@@ -3391,6 +3923,9 @@ export class FoundryStepExecutor {
|
|
|
3391
3923
|
context.recordScopeId(settingsKey, JSON.stringify({ ...want, port: want.port ?? have.port }));
|
|
3392
3924
|
context.recordScopeId(domainsKey, JSON.stringify(wantDomains));
|
|
3393
3925
|
context.recordScopeId(labelsKey, wantLabelsHash);
|
|
3926
|
+
if (pinnedCommit && pinnedCommit === entry.liveCommit) {
|
|
3927
|
+
context.recordScopeId(wsKey.liveCommit(entry.key), pinnedCommit);
|
|
3928
|
+
}
|
|
3394
3929
|
context.recordScopeId(appEnvAppliedScopeKey(entry.key), appEnv.applied);
|
|
3395
3930
|
if (entry.uses?.auth) {
|
|
3396
3931
|
context.recordScopeId(entryAuthEnvAppliedScopeKey(entry.key), appEnv.authApplied);
|
|
@@ -3404,7 +3939,8 @@ export class FoundryStepExecutor {
|
|
|
3404
3939
|
// actually resolved a mail domain for this org.
|
|
3405
3940
|
// One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
|
|
3406
3941
|
// second entry sharing the name would delete the first's live token.
|
|
3407
|
-
|
|
3942
|
+
// An entry that declares `uses.mail` shares that project's token and sender address.
|
|
3943
|
+
if (isMainEntry(entry) || entry.uses?.mail) {
|
|
3408
3944
|
await this.ensureAppLettermintMail(appId, org, context,
|
|
3409
3945
|
// Deployed or started above on every path, a created app included
|
|
3410
3946
|
// (#566), so it has read its env before the mail env is written.
|
|
@@ -3416,7 +3952,7 @@ export class FoundryStepExecutor {
|
|
|
3416
3952
|
// step planned before this field existed; `ensureAppLettermintMail`
|
|
3417
3953
|
// refuses rather than silently falling back once it knows there is
|
|
3418
3954
|
// a recorded mail domain to reason about.
|
|
3419
|
-
'platformApex' in action.desired, action.desired.platformApex ?? null, action.desired.platformLettermintProjectId ?? null);
|
|
3955
|
+
'platformApex' in action.desired, action.desired.platformApex ?? null, action.desired.platformLettermintProjectId ?? null, isMainEntry(entry) ? 'app' : 'entry');
|
|
3420
3956
|
}
|
|
3421
3957
|
return { resourceId: appId };
|
|
3422
3958
|
}
|
|
@@ -3537,6 +4073,150 @@ export class FoundryStepExecutor {
|
|
|
3537
4073
|
await provisionOrgPostgresDatabases(scopeFromOrg(context, {}), this.orgPostgresDeps(org, context), org, homes);
|
|
3538
4074
|
return {};
|
|
3539
4075
|
}
|
|
4076
|
+
/** F4 (#925): Boule's app and the calls on its env that the apps database steps make. */
|
|
4077
|
+
appsBoule(org, context) {
|
|
4078
|
+
const appId = context.recordedIds()[`coolify:boule:${org.id}`];
|
|
4079
|
+
if (!appId)
|
|
4080
|
+
return null;
|
|
4081
|
+
const coolify = this.ports.coolify;
|
|
4082
|
+
return {
|
|
4083
|
+
upsertEnvs: async (rows) => void (await coolify.bulkUpsertEnvs(appId, rows)),
|
|
4084
|
+
deleteEnvs: async (names) => {
|
|
4085
|
+
let rows;
|
|
4086
|
+
try {
|
|
4087
|
+
rows = await coolify.listEnvs(appId);
|
|
4088
|
+
}
|
|
4089
|
+
catch (error) {
|
|
4090
|
+
if (isNotFoundError(error))
|
|
4091
|
+
return;
|
|
4092
|
+
throw error;
|
|
4093
|
+
}
|
|
4094
|
+
for (const name of names) {
|
|
4095
|
+
const row = rows.find((e) => e.key === name && !e.is_preview);
|
|
4096
|
+
if (!row?.uuid)
|
|
4097
|
+
continue;
|
|
4098
|
+
try {
|
|
4099
|
+
await coolify.deleteEnv(appId, row.uuid);
|
|
4100
|
+
}
|
|
4101
|
+
catch (error) {
|
|
4102
|
+
if (!isNotFoundError(error))
|
|
4103
|
+
throw error;
|
|
4104
|
+
}
|
|
4105
|
+
}
|
|
4106
|
+
},
|
|
4107
|
+
restart: async () => void (await coolify.restart(appId)),
|
|
4108
|
+
};
|
|
4109
|
+
}
|
|
4110
|
+
/**
|
|
4111
|
+
* F4 (#925) `coolify:org-apps-admin`: with `org.boule.apps` on, the `<slug>_apps_admin` role on the
|
|
4112
|
+
* company's Postgres server (`plan/org-apps-admin.ts`); its URL is held and written to Boule's env as
|
|
4113
|
+
* `APPS_DATABASE_ADMIN_URL`, then Boule restarts. Apps off: the retire form (no login, env row gone,
|
|
4114
|
+
* nothing dropped).
|
|
4115
|
+
*/
|
|
4116
|
+
async orgAppsAdmin(action, org, context) {
|
|
4117
|
+
const at = `coolify:org-apps-admin for ${org.slug}`;
|
|
4118
|
+
const base = this.orgPostgresDeps(org, context);
|
|
4119
|
+
const boule = this.appsBoule(org, context);
|
|
4120
|
+
const { deliverSecret: _held, ...shared } = base;
|
|
4121
|
+
if (action.desired.mode === 'retire') {
|
|
4122
|
+
await retireOrgAppsAdmin(scopeFromOrg(context, {}), {
|
|
4123
|
+
...shared,
|
|
4124
|
+
clearHeldUrl: async (ref) => {
|
|
4125
|
+
// The deliver sink has no delete; the held-key store blanks a value (an empty one reads as not held).
|
|
4126
|
+
if (typeof this.ports.heldKeys?.put !== 'function') {
|
|
4127
|
+
console.info(`${at}: the held URL ${ref} was not blanked, the executor's held keys cannot put; blank it by hand`);
|
|
4128
|
+
return;
|
|
4129
|
+
}
|
|
4130
|
+
await this.ports.heldKeys.put(ref, '');
|
|
4131
|
+
},
|
|
4132
|
+
removeBouleEnv: async () => {
|
|
4133
|
+
await boule?.deleteEnvs([APPS_DATABASE_ADMIN_URL_ENV]);
|
|
4134
|
+
if (boule)
|
|
4135
|
+
await boule.restart();
|
|
4136
|
+
},
|
|
4137
|
+
}, org);
|
|
4138
|
+
return {};
|
|
4139
|
+
}
|
|
4140
|
+
if (!appsOn(org))
|
|
4141
|
+
throw new Error(`${at}: the company's Boule runs no apps (org.boule.apps)`);
|
|
4142
|
+
if (!boule)
|
|
4143
|
+
throw new Error(`${at}: Boule is not recorded yet`);
|
|
4144
|
+
const deps = {
|
|
4145
|
+
...shared,
|
|
4146
|
+
deliverSecret: async (ref, url) => {
|
|
4147
|
+
await this.ports.deliverSecret(ref, url);
|
|
4148
|
+
await boule.upsertEnvs([{ key: APPS_DATABASE_ADMIN_URL_ENV, value: url, literal: true }]);
|
|
4149
|
+
await boule.restart();
|
|
4150
|
+
},
|
|
4151
|
+
};
|
|
4152
|
+
await provisionOrgAppsAdmin(scopeFromOrg(context, {}), deps, org);
|
|
4153
|
+
return {};
|
|
4154
|
+
}
|
|
4155
|
+
/**
|
|
4156
|
+
* F4 (#925) `cloudflare:apps-db-route`: the tunnel route Hyperdrive reaches the company's Postgres server
|
|
4157
|
+
* by, and the `APPS_DATABASE_*` env on Boule (`plan/apps-db-route.ts`). Retire takes both away; the
|
|
4158
|
+
* databases stay.
|
|
4159
|
+
*/
|
|
4160
|
+
async appsDbRoute(action, org, context) {
|
|
4161
|
+
const at = `cloudflare:apps-db-route for ${org.slug}`;
|
|
4162
|
+
const heldKeys = this.ports.heldKeys;
|
|
4163
|
+
if (!heldKeys)
|
|
4164
|
+
throw new Error(`${at}: this executor was built without held keys`);
|
|
4165
|
+
const retire = action.desired.mode === 'retire';
|
|
4166
|
+
const boule = this.appsBoule(org, context);
|
|
4167
|
+
if (!boule && !retire)
|
|
4168
|
+
throw new Error(`${at}: Boule is not recorded yet`);
|
|
4169
|
+
const deps = {
|
|
4170
|
+
database: {
|
|
4171
|
+
tunnels: this.tunnelPort(),
|
|
4172
|
+
access: this.accessPort(),
|
|
4173
|
+
dns: this.dnsPort(),
|
|
4174
|
+
zones: this.zonePort(),
|
|
4175
|
+
},
|
|
4176
|
+
heldKeys,
|
|
4177
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
4178
|
+
upstreamHost: async (serverId) => {
|
|
4179
|
+
const server = await getDatabaseStarted(this.ports.coolify, serverId);
|
|
4180
|
+
if (!server.internal_db_url || !URL.canParse(server.internal_db_url))
|
|
4181
|
+
return '';
|
|
4182
|
+
this.handledSecrets.add(server.internal_db_url);
|
|
4183
|
+
return new URL(server.internal_db_url).hostname;
|
|
4184
|
+
},
|
|
4185
|
+
boule: boule ?? {
|
|
4186
|
+
upsertEnvs: async () => undefined,
|
|
4187
|
+
deleteEnvs: async () => undefined,
|
|
4188
|
+
restart: async () => undefined,
|
|
4189
|
+
},
|
|
4190
|
+
};
|
|
4191
|
+
if (retire)
|
|
4192
|
+
await retireAppsDbRoute(deps, org, context);
|
|
4193
|
+
else
|
|
4194
|
+
await applyAppsDbRoute(deps, org, context);
|
|
4195
|
+
return {};
|
|
4196
|
+
}
|
|
4197
|
+
/**
|
|
4198
|
+
* FZ `cloudflare:apps-zones`: the company slug and the zone ids Boule's managed backend names edge routes
|
|
4199
|
+
* by (`plan/apps-zones.ts`). Retire takes the rows away again.
|
|
4200
|
+
*/
|
|
4201
|
+
async appsZones(action, org, context) {
|
|
4202
|
+
const at = `cloudflare:apps-zones for ${org.slug}`;
|
|
4203
|
+
const retire = action.desired.mode === 'retire';
|
|
4204
|
+
const boule = this.appsBoule(org, context);
|
|
4205
|
+
if (retire) {
|
|
4206
|
+
if (boule)
|
|
4207
|
+
await retireAppsZones({ boule }, org, context);
|
|
4208
|
+
else {
|
|
4209
|
+
console.info(`${at}: Boule is not recorded, so no env rows were removed; marker cleared`);
|
|
4210
|
+
context.recordScopeId(APPS_ZONES_APPLIED_SCOPE_KEY, '');
|
|
4211
|
+
await context.persistScopeIds();
|
|
4212
|
+
}
|
|
4213
|
+
return {};
|
|
4214
|
+
}
|
|
4215
|
+
if (!boule)
|
|
4216
|
+
throw new Error(`${at}: Boule is not recorded yet`);
|
|
4217
|
+
await applyAppsZones({ zones: this.zonePort(), boule }, org, context);
|
|
4218
|
+
return {};
|
|
4219
|
+
}
|
|
3540
4220
|
/**
|
|
3541
4221
|
* `coolify:org-postgres-migrate` (#444): one home's data from its old Coolify database to the
|
|
3542
4222
|
* company's server. Refuses before any port call unless the staging confirmation is recorded.
|
|
@@ -3592,6 +4272,8 @@ export class FoundryStepExecutor {
|
|
|
3592
4272
|
}
|
|
3593
4273
|
case 'boule':
|
|
3594
4274
|
return { appIds: await byName(`${org.slug}-boule`), template: true };
|
|
4275
|
+
case 'kosmos':
|
|
4276
|
+
return { appIds: await byName(`${org.slug}-kosmos`), template: true };
|
|
3595
4277
|
case 'chat_service':
|
|
3596
4278
|
return { appIds: await byName(chat.service), template: true };
|
|
3597
4279
|
case 'calls_service':
|
|
@@ -3845,6 +4527,19 @@ export class FoundryStepExecutor {
|
|
|
3845
4527
|
const appKey = await this.ensureAuthAppKey(appId);
|
|
3846
4528
|
if (appKey)
|
|
3847
4529
|
envs.push({ key: 'AUTH_APP_KEY', value: appKey });
|
|
4530
|
+
if (entry.uses.auth.workspaces) {
|
|
4531
|
+
const workspacesKey = await ensureWorkspacesKey({
|
|
4532
|
+
orgId: org.id,
|
|
4533
|
+
slug: org.slug,
|
|
4534
|
+
entryKey: entry.key,
|
|
4535
|
+
heldKeys: this.ports.heldKeys,
|
|
4536
|
+
recorded: () => context.recordedIds(),
|
|
4537
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
4538
|
+
persist: () => context.persistScopeIds(),
|
|
4539
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
4540
|
+
});
|
|
4541
|
+
envs.push({ key: 'WORKSPACES_API_KEY', value: workspacesKey });
|
|
4542
|
+
}
|
|
3848
4543
|
}
|
|
3849
4544
|
else {
|
|
3850
4545
|
const zitadelOrgId = recorded.zitadel_org;
|
|
@@ -3901,6 +4596,8 @@ export class FoundryStepExecutor {
|
|
|
3901
4596
|
heldEnvs.push({ key: name, value: await this.readHeldEnv(org, entry.key, name) });
|
|
3902
4597
|
}
|
|
3903
4598
|
envs.push(...heldEnvs);
|
|
4599
|
+
// F13: the entry's customer-grants credential, or its removal when the flag went.
|
|
4600
|
+
await this.customerGrantsAppEnv(appId, org, entry, context, envs);
|
|
3904
4601
|
// A held value has no version the plan can read, so the live env decides whether it changed.
|
|
3905
4602
|
let heldChanged = false;
|
|
3906
4603
|
if (heldEnvs.length > 0) {
|
|
@@ -4039,9 +4736,24 @@ export class FoundryStepExecutor {
|
|
|
4039
4736
|
if (!appKey || appKey.length < 32) {
|
|
4040
4737
|
throw new Error(`coolify:broker-entry for ${org.slug}: app ${appId} has no AUTH_APP_KEY yet`);
|
|
4041
4738
|
}
|
|
4042
|
-
await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, appKey, context);
|
|
4739
|
+
await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, appKey, context, wanted.want.workspaces ? await this.workspacesInputs(org, entry.key) : {});
|
|
4043
4740
|
return {};
|
|
4044
4741
|
}
|
|
4742
|
+
/**
|
|
4743
|
+
* What the broker entry needs beyond its want when it declares workspaces: the hash of the held key (the key
|
|
4744
|
+
* itself is registered for redaction and goes no further) and the operator's held database connection string.
|
|
4745
|
+
*/
|
|
4746
|
+
async workspacesInputs(org, entryKey) {
|
|
4747
|
+
const key = await heldWorkspacesKey(this.ports.heldKeys, org, entryKey);
|
|
4748
|
+
this.handledSecrets.add(key);
|
|
4749
|
+
const database = await heldWorkspacesDatabase(this.ports.heldKeys, workspacesDatabaseRef(org));
|
|
4750
|
+
if (database)
|
|
4751
|
+
this.handledSecrets.add(database);
|
|
4752
|
+
return {
|
|
4753
|
+
workspacesKeyHash: workspacesKeyHash(key),
|
|
4754
|
+
...(database ? { workspacesDatabaseUrl: database } : {}),
|
|
4755
|
+
};
|
|
4756
|
+
}
|
|
4045
4757
|
/**
|
|
4046
4758
|
* #161: the org's Boule broker entry, `<slug>-boule`, written the way a
|
|
4047
4759
|
* hosting entry's is: with the `AUTH_APP_KEY` `coolifyBoule` put in the
|
|
@@ -4114,12 +4826,13 @@ export class FoundryStepExecutor {
|
|
|
4114
4826
|
return {};
|
|
4115
4827
|
}
|
|
4116
4828
|
/** 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) {
|
|
4829
|
+
async writeAppBrokerEntry(_org, broker, entryKey, want, appKey, context, workspaces = {}) {
|
|
4118
4830
|
const previousRaw = context.recordedIds()[brokerEntryAppliedScopeKey(entryKey)];
|
|
4119
4831
|
const result = await writeBrokerEntry(broker, {
|
|
4120
4832
|
want,
|
|
4121
4833
|
appKey,
|
|
4122
4834
|
previous: previousRaw ? JSON.parse(previousRaw) : null,
|
|
4835
|
+
...workspaces,
|
|
4123
4836
|
});
|
|
4124
4837
|
context.recordScopeId(brokerEntryWarningsScopeKey(entryKey), JSON.stringify(result.invalidForeign));
|
|
4125
4838
|
// Last: a run that failed above plans the step again.
|
|
@@ -4220,7 +4933,7 @@ export class FoundryStepExecutor {
|
|
|
4220
4933
|
* mail domain was never even recorded for this org, the same as before
|
|
4221
4934
|
* this field existed (review round 2, finding 3).
|
|
4222
4935
|
*/
|
|
4223
|
-
async ensureAppLettermintMail(appId, org, context, running, hasPlatformApexField, platformApex, platformLettermintProjectId) {
|
|
4936
|
+
async ensureAppLettermintMail(appId, org, context, running, hasPlatformApexField, platformApex, platformLettermintProjectId, role = 'app') {
|
|
4224
4937
|
const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
|
|
4225
4938
|
if (!mailDomain)
|
|
4226
4939
|
return;
|
|
@@ -4273,7 +4986,7 @@ export class FoundryStepExecutor {
|
|
|
4273
4986
|
org,
|
|
4274
4987
|
context,
|
|
4275
4988
|
mailDomain,
|
|
4276
|
-
self: { role
|
|
4989
|
+
self: { role, id: appId, restart: running },
|
|
4277
4990
|
mailFrom: `${org.mail.noReplyLocalPart ?? 'no-reply'}@${mailDomain}`,
|
|
4278
4991
|
platformApex,
|
|
4279
4992
|
platformLettermintProjectId,
|
|
@@ -4307,12 +5020,26 @@ export class FoundryStepExecutor {
|
|
|
4307
5020
|
const { org, context, mailDomain, self, mailFrom } = args;
|
|
4308
5021
|
const appName = `${org.slug}-app`;
|
|
4309
5022
|
const recorded = context.recordedIds();
|
|
4310
|
-
const
|
|
5023
|
+
const mainId = self.role === 'app'
|
|
4311
5024
|
? self.id
|
|
4312
5025
|
: recorded[`coolify:app:${appStepKey(org, mainEntry(org.hosting)?.key)}`];
|
|
4313
5026
|
const bouleId = self.role === 'boule' ? self.id : recorded[`coolify:boule:${org.id}`];
|
|
4314
|
-
|
|
4315
|
-
|
|
5027
|
+
// The hosted app first, Boule second, then the other entries that send mail (`uses.mail`): an earlier token wins a disagreement.
|
|
5028
|
+
const ids = [];
|
|
5029
|
+
const add = (id) => {
|
|
5030
|
+
if (id && !ids.includes(id))
|
|
5031
|
+
ids.push(id);
|
|
5032
|
+
};
|
|
5033
|
+
add(mainId);
|
|
5034
|
+
add(bouleId);
|
|
5035
|
+
for (const entry of org.hosting.entries) {
|
|
5036
|
+
if (entry.uses?.mail && !isMainEntry(entry)) {
|
|
5037
|
+
add(recorded[`coolify:app:${appStepKey(org, entry.key)}`]);
|
|
5038
|
+
}
|
|
5039
|
+
}
|
|
5040
|
+
add(self.id);
|
|
5041
|
+
const envsOf = (id) => id === self.id
|
|
5042
|
+
? this.ports.coolify.listEnvs(id)
|
|
4316
5043
|
: // A recorded peer that Coolify no longer has is not a consumer: not
|
|
4317
5044
|
// read, written or restarted (same 404 rule as the retire step).
|
|
4318
5045
|
this.ports.coolify
|
|
@@ -4322,21 +5049,17 @@ export class FoundryStepExecutor {
|
|
|
4322
5049
|
throw error;
|
|
4323
5050
|
return null;
|
|
4324
5051
|
});
|
|
4325
|
-
const [projects,
|
|
5052
|
+
const [projects, envLists] = await Promise.all([
|
|
4326
5053
|
this.ports.lettermint.listProjects(),
|
|
4327
|
-
|
|
4328
|
-
self.role === 'app' ? Promise.resolve(null) : peerListEnvs(appId),
|
|
4329
|
-
self.role === 'boule' ? Promise.resolve(null) : peerListEnvs(bouleId),
|
|
5054
|
+
Promise.all(ids.map(envsOf)),
|
|
4330
5055
|
]);
|
|
4331
5056
|
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
5057
|
const consumers = [];
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
consumers.push({ id: bouleId, token: tokenOf(bouleSlot) });
|
|
5058
|
+
ids.forEach((id, at) => {
|
|
5059
|
+
const envs = envLists[at];
|
|
5060
|
+
if (envs)
|
|
5061
|
+
consumers.push({ id, token: tokenOf(envs) });
|
|
5062
|
+
});
|
|
4340
5063
|
const existingToken = consumers.find((c) => c.token)?.token;
|
|
4341
5064
|
const ours = projects.filter((p) => p.name === appName);
|
|
4342
5065
|
const held = ours.length === 1 ? ours[0] : undefined;
|
|
@@ -5834,16 +6557,18 @@ export class FoundryStepExecutor {
|
|
|
5834
6557
|
// ---- calls service (#145, part 2 of #128) -------------------------------
|
|
5835
6558
|
/**
|
|
5836
6559
|
* `plan/diff.ts`'s `planCallsService`: the org's own calls service --
|
|
5837
|
-
* `wtfalch/calls`'s `packages/service`, wired to the
|
|
5838
|
-
*
|
|
5839
|
-
*
|
|
5840
|
-
*
|
|
5841
|
-
*
|
|
5842
|
-
*
|
|
5843
|
-
*
|
|
5844
|
-
*
|
|
5845
|
-
*
|
|
5846
|
-
*
|
|
6560
|
+
* `wtfalch/calls`'s `packages/service`, wired to the shared LiveKit SFU
|
|
6561
|
+
* (#122/#127). #430: calls run transit-only (calls#93's
|
|
6562
|
+
* `CALLS_ENCRYPTION=transit`), so this no longer waits on or wires a
|
|
6563
|
+
* per-org Matrix stack at all -- an org whose stack is already live from
|
|
6564
|
+
* before this keeps running it, unused, until a human removes it
|
|
6565
|
+
* separately. Static config checks run first, before any Coolify call
|
|
6566
|
+
* (#128's own carried-over PR 344 review fix, restated in #145's own
|
|
6567
|
+
* issue body): the shared SFU's LiveKit credentials must already be
|
|
6568
|
+
* resolvable -- not minted or guessed here. Park stops the app only, the
|
|
6569
|
+
* same park-not-delete rule {@link coolifyCalls}/{@link coolifyApp}
|
|
6570
|
+
* already follow (#7 corrections 1); its own database is never touched
|
|
6571
|
+
* from that branch.
|
|
5847
6572
|
*/
|
|
5848
6573
|
async coolifyCallsService(action, org, context) {
|
|
5849
6574
|
if (action.desired.enabled === false) {
|
|
@@ -5854,18 +6579,8 @@ export class FoundryStepExecutor {
|
|
|
5854
6579
|
await this.ports.coolify.raw('POST', `/applications/${appId}/stop`);
|
|
5855
6580
|
return { resourceId: appId };
|
|
5856
6581
|
}
|
|
5857
|
-
// Refused before anything is created, like the missing-
|
|
6582
|
+
// Refused before anything is created, like the missing-SFU refusal below.
|
|
5858
6583
|
const sfuUrls = resolveCallsSfuUrls(this.ports.callsSfuUrls);
|
|
5859
|
-
const recorded = context.recordedIds();
|
|
5860
|
-
const synapseId = recorded[CALLS_SYNAPSE_SCOPE_KEY];
|
|
5861
|
-
const masId = recorded[CALLS_MAS_SCOPE_KEY];
|
|
5862
|
-
if (!synapseId || !masId) {
|
|
5863
|
-
throw new Error(`coolify:calls-service for ${org.slug}: the calls Matrix stack has not been provisioned yet -- coolify:calls must run first`);
|
|
5864
|
-
}
|
|
5865
|
-
const masCredentials = await this.readCallsMasClientCredentials(masId);
|
|
5866
|
-
if (!masCredentials) {
|
|
5867
|
-
throw new Error(`coolify:calls-service for ${org.slug}: the calls Matrix stack's MAS client credentials are not on the MAS app yet -- retry this apply once coolify:calls has minted them`);
|
|
5868
|
-
}
|
|
5869
6584
|
const sfu = await this.ports.callsSfu.resolveCredentials(org.slug);
|
|
5870
6585
|
if (!sfu) {
|
|
5871
6586
|
throw new Error(`coolify:calls-service for ${org.slug}: the shared calls SFU has no LiveKit credentials for this org yet -- see https://github.com/wtfalch/foundry/issues/152`);
|
|
@@ -5873,8 +6588,6 @@ export class FoundryStepExecutor {
|
|
|
5873
6588
|
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
5874
6589
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
5875
6590
|
const name = callsServiceInternalName(org);
|
|
5876
|
-
const synapseHost = `${org.slug}-calls-synapse`;
|
|
5877
|
-
const masHost = `${org.slug}-calls-mas`;
|
|
5878
6591
|
const dbName = `${name}-db`;
|
|
5879
6592
|
const serviceHome = await this.ensureHomeDatabase(org, context, 'calls_service', CALLS_SERVICE_DB_SCOPE_KEY, () => this.ensureCallsDatabase(org, context, projectUuid, environment, dbName, dbName.replace(/-/g, '_'), CALLS_SERVICE_DB_SCOPE_KEY));
|
|
5880
6593
|
if (!(await this.homeOwnerUrl(serviceHome))) {
|
|
@@ -5916,17 +6629,25 @@ export class FoundryStepExecutor {
|
|
|
5916
6629
|
});
|
|
5917
6630
|
// Every app foundry deploys keeps its own logs (#75), reused as-is here.
|
|
5918
6631
|
await this.ensureLogVolume(app.appId, org, app.existed);
|
|
6632
|
+
// #430: an org whose Matrix stack is still live from before this keeps
|
|
6633
|
+
// the Synapse/MAS apps running, unused, until a human removes them
|
|
6634
|
+
// separately -- but its calls service's own env drops their vars on its
|
|
6635
|
+
// next apply, the same as a brand new org that never got them.
|
|
6636
|
+
for (const key of CALLS_MATRIX_ENV_KEYS) {
|
|
6637
|
+
const env = (await this.ports.coolify.listEnvs(app.appId)).find((e) => e.key === key && !e.is_preview);
|
|
6638
|
+
if (env?.uuid)
|
|
6639
|
+
await this.ports.coolify.deleteEnv(app.appId, env.uuid);
|
|
6640
|
+
}
|
|
5919
6641
|
const bindingSecret = await this.ensureCallsBindingSecret(app.appId);
|
|
5920
6642
|
const envs = [
|
|
5921
6643
|
{ key: 'LIVEKIT_URL', value: sfuUrls.privateUrl },
|
|
5922
6644
|
{ key: 'LIVEKIT_PUBLIC_URL', value: sfuUrls.publicUrl },
|
|
5923
6645
|
{ key: 'LIVEKIT_API_KEY', value: sfu.apiKey },
|
|
5924
6646
|
{ key: 'LIVEKIT_API_SECRET', value: sfu.apiSecret },
|
|
5925
|
-
|
|
5926
|
-
|
|
5927
|
-
|
|
5928
|
-
{ key: '
|
|
5929
|
-
{ key: 'SYNAPSE_URL', value: `http://${synapseHost}:8008` },
|
|
6647
|
+
// #430 (calls#93): calls are transit-only, so the service never needs
|
|
6648
|
+
// a Matrix/MAS provider -- MAS_URL, MAS_HOMESERVER, MAS_CLIENT_ID,
|
|
6649
|
+
// MAS_CLIENT_SECRET and SYNAPSE_URL are never written.
|
|
6650
|
+
{ key: 'CALLS_ENCRYPTION', value: 'transit' },
|
|
5930
6651
|
{ key: 'CALLS_ORIGIN', value: `https://${agoraHostnameFor(org)}` },
|
|
5931
6652
|
{ key: 'CALLS_HOST_ADAPTER', value: 'production' },
|
|
5932
6653
|
// Deliberately kept as it was, not widened: the calls service refuses to
|
|
@@ -6052,6 +6773,9 @@ export class FoundryStepExecutor {
|
|
|
6052
6773
|
throw new Error(`${at}: token ${tokenId} is recorded but chat-service no longer holds a usable one; remove the recorded keys ${CHAT_CALLS_TOKEN_SCOPE_KEY} and ${CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
|
|
6053
6774
|
}
|
|
6054
6775
|
const token = usable ?? randomBytes(32).toString('hex');
|
|
6776
|
+
// Registered before either write, so a Coolify error body echoing the upsert is scrubbed
|
|
6777
|
+
// from the run record the same way the write loop's own dropped-error fallback intends.
|
|
6778
|
+
this.handledSecrets.add(token);
|
|
6055
6779
|
if (!tokenId) {
|
|
6056
6780
|
tokenId = randomBytes(8).toString('hex');
|
|
6057
6781
|
context.recordScopeId(CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, tokenId);
|
|
@@ -6156,22 +6880,6 @@ export class FoundryStepExecutor {
|
|
|
6156
6880
|
appId: client.appId,
|
|
6157
6881
|
};
|
|
6158
6882
|
}
|
|
6159
|
-
/**
|
|
6160
|
-
* Reads back the calls Matrix stack's MAS client id/secret from the MAS
|
|
6161
|
-
* app's own envs ({@link CALLS_SECRET_ENV_KEYS}) -- never mints: minting
|
|
6162
|
-
* either is {@link ensureCallsMatrixSecrets}'s own job, and a missing value
|
|
6163
|
-
* here means that stack has not finished provisioning yet, not something
|
|
6164
|
-
* this step can fix on its own.
|
|
6165
|
-
*/
|
|
6166
|
-
async readCallsMasClientCredentials(masAppId) {
|
|
6167
|
-
const envs = await this.ports.coolify.listEnvs(masAppId);
|
|
6168
|
-
const read = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
|
|
6169
|
-
const clientId = read(CALLS_SECRET_ENV_KEYS.masClientId);
|
|
6170
|
-
const clientSecret = read(CALLS_SECRET_ENV_KEYS.masClientSecret);
|
|
6171
|
-
if (!clientId || !clientSecret)
|
|
6172
|
-
return null;
|
|
6173
|
-
return { clientId, clientSecret };
|
|
6174
|
-
}
|
|
6175
6883
|
/**
|
|
6176
6884
|
* `null` once the calls service app already has a `CALLS_BINDING_SECRET` --
|
|
6177
6885
|
* never rotated, the same rule {@link ensureAuthCookieSecret} follows for
|
|
@@ -6558,6 +7266,7 @@ export class FoundryStepExecutor {
|
|
|
6558
7266
|
const credentialWants = {
|
|
6559
7267
|
archonCredential: action.desired.archonCredential === true,
|
|
6560
7268
|
customersDirectory: action.desired.customersDirectory === true,
|
|
7269
|
+
registrar: action.desired.registrar === true,
|
|
6561
7270
|
};
|
|
6562
7271
|
const { env, missing } = desiredBouleEnv(org, context.recordedIds(), issuer, archonUrl, mailWanted, credentialWants);
|
|
6563
7272
|
if (missing.length > 0) {
|
|
@@ -6646,11 +7355,34 @@ export class FoundryStepExecutor {
|
|
|
6646
7355
|
archonPushedAt = archon.pushedAt;
|
|
6647
7356
|
credentialEnvs.push({ key: BOULE_ARCHON_CREDENTIAL_ENV, value: archon.value });
|
|
6648
7357
|
}
|
|
7358
|
+
let directoryCredential = null;
|
|
6649
7359
|
if (credentialWants.customersDirectory) {
|
|
6650
|
-
|
|
6651
|
-
if (
|
|
6652
|
-
credentialEnvs.push({ key: BOULE_CUSTOMERS_CREDENTIAL_ENV, value:
|
|
6653
|
-
|
|
7360
|
+
directoryCredential = await this.ensureCustomersDirectoryCredential(org, context);
|
|
7361
|
+
if (directoryCredential) {
|
|
7362
|
+
credentialEnvs.push({ key: BOULE_CUSTOMERS_CREDENTIAL_ENV, value: directoryCredential });
|
|
7363
|
+
}
|
|
7364
|
+
}
|
|
7365
|
+
// F13: Boule's customer-grants entry, written with the rest and compared with the live value, so Boule
|
|
7366
|
+
// restarts only when the JSON itself changes.
|
|
7367
|
+
const grants = await this.customerGrantsForBoule(app.appId, org, context, action.desired.customerGrants === true, directoryCredential, liveRows);
|
|
7368
|
+
// Pushed with the credentials: the loop below registers the JSON for redaction and compares it with the live value.
|
|
7369
|
+
if (grants.value !== null)
|
|
7370
|
+
credentialEnvs.push({ key: CUSTOMER_GRANTS_ENV, value: grants.value });
|
|
7371
|
+
if (grants.removed)
|
|
7372
|
+
envChanged = true;
|
|
7373
|
+
// T3: the company's registrar key at the broker, the same held value the registrar step writes there.
|
|
7374
|
+
let registrarMint = null;
|
|
7375
|
+
// Only with the broker's address beside it: a key Boule cannot use is a key held for nothing.
|
|
7376
|
+
if (credentialWants.registrar && issuer) {
|
|
7377
|
+
const registrar = await this.registrarKey(org, context);
|
|
7378
|
+
registrarMint = registrar.mint;
|
|
7379
|
+
credentialEnvs.push({ key: AUTH_REGISTRAR_KEY_ENV, value: registrar.key });
|
|
7380
|
+
}
|
|
7381
|
+
if (await this.dropRegistrarEnv(app.appId, org, context, liveRows))
|
|
7382
|
+
envChanged = true;
|
|
7383
|
+
// A key Boule was given but has not run with yet (a restart that failed, or a key made again).
|
|
7384
|
+
const registrarUnstarted = registrarMint !== null &&
|
|
7385
|
+
context.recordedIds()[REGISTRAR_DELIVERED_SCOPE_KEY] !== registrarMint;
|
|
6654
7386
|
for (const credential of credentialEnvs) {
|
|
6655
7387
|
this.handledSecrets.add(credential.value);
|
|
6656
7388
|
envs.push(credential);
|
|
@@ -6664,6 +7396,11 @@ export class FoundryStepExecutor {
|
|
|
6664
7396
|
context.recordScopeId(BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, archonPushedAt);
|
|
6665
7397
|
await context.persistScopeIds();
|
|
6666
7398
|
}
|
|
7399
|
+
if (grants.value !== null) {
|
|
7400
|
+
context.recordScopeId(CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY, '1');
|
|
7401
|
+
await context.persistScopeIds();
|
|
7402
|
+
}
|
|
7403
|
+
await this.forgetDroppedCustomerGrants(org, context);
|
|
6667
7404
|
}
|
|
6668
7405
|
catch (error) {
|
|
6669
7406
|
const minted = [
|
|
@@ -6671,6 +7408,7 @@ export class FoundryStepExecutor {
|
|
|
6671
7408
|
bridgeSecret,
|
|
6672
7409
|
ownerSeedSecret,
|
|
6673
7410
|
...credentialEnvs.map((credential) => credential.value),
|
|
7411
|
+
...grants.secrets,
|
|
6674
7412
|
].filter((v) => v !== null);
|
|
6675
7413
|
if (minted.length === 0)
|
|
6676
7414
|
throw error;
|
|
@@ -6709,6 +7447,7 @@ export class FoundryStepExecutor {
|
|
|
6709
7447
|
bridgeSecret !== null ||
|
|
6710
7448
|
ownerSeedSecret !== null ||
|
|
6711
7449
|
secretsNotRestarted ||
|
|
7450
|
+
registrarUnstarted ||
|
|
6712
7451
|
dbPush.changed ||
|
|
6713
7452
|
mail.changed)) {
|
|
6714
7453
|
// A restart inside the mail rule already came after every env write above.
|
|
@@ -6718,20 +7457,115 @@ export class FoundryStepExecutor {
|
|
|
6718
7457
|
if (action.desired.platformOwner === true) {
|
|
6719
7458
|
context.recordScopeId(BOULE_SECRETS_RESTARTED_SCOPE_KEY, '1');
|
|
6720
7459
|
}
|
|
7460
|
+
// After the restart or deploy, so a key Boule never ran with is planned (and restarted) again.
|
|
7461
|
+
if (registrarMint !== null)
|
|
7462
|
+
context.recordScopeId(REGISTRAR_DELIVERED_SCOPE_KEY, registrarMint);
|
|
6721
7463
|
// Last: the login-host step points the sign-in at this app's own address only once the env is running.
|
|
6722
7464
|
this.recordSignInEnvLive(context, 'boule', bouleHostnameFor(org), env);
|
|
6723
7465
|
return { resourceId: app.appId };
|
|
6724
7466
|
}
|
|
7467
|
+
// ---- kosmos (foundry#949) --------------------------------------------------
|
|
6725
7468
|
/**
|
|
6726
|
-
*
|
|
6727
|
-
*
|
|
6728
|
-
*
|
|
6729
|
-
*
|
|
7469
|
+
* `plan/diff.ts`'s `planKosmos`: provisions the org's own kosmos
|
|
7470
|
+
* (https://github.com/wtfalch/kosmos) as one more Coolify application, from
|
|
7471
|
+
* that repo's root Dockerfile ({@link KOSMOS_REPO}), with a database of its
|
|
7472
|
+
* own, and pushes its sign-in env. The same shape as {@link coolifyBoule},
|
|
7473
|
+
* trimmed: kosmos is opt-in (`kosmosOn(org)` guards every caller, so this is
|
|
7474
|
+
* never reached while it is off), has no mail, no owner seeding and no
|
|
7475
|
+
* Archon credential.
|
|
7476
|
+
*
|
|
7477
|
+
* The sign-in env comes from `plan/kosmos.ts`'s `desiredKosmosEnv`, the same
|
|
7478
|
+
* function the plan compares the live app against; it never writes a
|
|
7479
|
+
* partial set. The database is `<slug>-kosmos-db`, split the same way
|
|
7480
|
+
* Boule's is: the superuser URL as `DATABASE_URL_OWNER` and a `<dbName>_rt`
|
|
7481
|
+
* role's URL as `DATABASE_URL`, both through `pushDatabaseUrl`.
|
|
7482
|
+
*
|
|
7483
|
+
* #504 (same reason {@link coolifyBoule}'s own comment gives): a brand new
|
|
7484
|
+
* app is never restarted, but it is deployed once ({@link KOSMOS_DEPLOYED_SCOPE_KEY}).
|
|
6730
7485
|
*/
|
|
6731
|
-
async
|
|
6732
|
-
|
|
6733
|
-
|
|
6734
|
-
|
|
7486
|
+
async coolifyKosmos(action, org, context) {
|
|
7487
|
+
// Park stops the app only; its database is never touched (#7 corrections 1).
|
|
7488
|
+
if (action.desired.enabled === false) {
|
|
7489
|
+
const appId = context.recordedIds()[`coolify:kosmos:${action.key}`];
|
|
7490
|
+
if (!appId) {
|
|
7491
|
+
throw new Error(`coolify:kosmos for ${org.slug}: no live app recorded, nothing to park`);
|
|
7492
|
+
}
|
|
7493
|
+
await this.ports.coolify.raw('POST', `/applications/${appId}/stop`);
|
|
7494
|
+
return { resourceId: appId };
|
|
7495
|
+
}
|
|
7496
|
+
const issuer = action.desired.issuer ?? null;
|
|
7497
|
+
const { env, missing } = desiredKosmosEnv(org, context.recordedIds(), issuer);
|
|
7498
|
+
if (missing.length > 0) {
|
|
7499
|
+
throw new Error(`kosmos for ${org.slug} cannot be configured: missing ${missing.join(', ')}. Nothing was written.`);
|
|
7500
|
+
}
|
|
7501
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
7502
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
7503
|
+
const name = `${org.slug}-kosmos`;
|
|
7504
|
+
const dbName = `${org.slug}-kosmos-db`.replace(/-/g, '_');
|
|
7505
|
+
// #444: on the company's server there is no Coolify database to make, like Boule's.
|
|
7506
|
+
const kosmosServer = this.homeOnServer(org, context, 'kosmos')
|
|
7507
|
+
? await this.serverHome(org, context, 'kosmos')
|
|
7508
|
+
: null;
|
|
7509
|
+
const dbId = kosmosServer
|
|
7510
|
+
? ''
|
|
7511
|
+
: await this.ensureCallsDatabase(org, context, projectUuid, environment, `${org.slug}-kosmos-db`, dbName, KOSMOS_DATABASE_SCOPE_KEY);
|
|
7512
|
+
if (!kosmosServer)
|
|
7513
|
+
context.recordScopeId(KOSMOS_DATABASE_SCOPE_KEY, dbId);
|
|
7514
|
+
const server = await this.resolveServer(org, context);
|
|
7515
|
+
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
7516
|
+
const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
7517
|
+
is_preview_deployments_enabled: false,
|
|
7518
|
+
project_uuid: projectUuid,
|
|
7519
|
+
server_uuid: server.uuid,
|
|
7520
|
+
environment_name: environment.name,
|
|
7521
|
+
environment_uuid: environment.uuid,
|
|
7522
|
+
github_app_uuid: githubApp.uuid,
|
|
7523
|
+
git_repository: KOSMOS_REPO,
|
|
7524
|
+
git_branch: 'main',
|
|
7525
|
+
build_pack: 'dockerfile',
|
|
7526
|
+
name,
|
|
7527
|
+
domains: coolifyTunnelDomains([kosmosHostnameFor(org)]),
|
|
7528
|
+
dockerfile_location: KOSMOS_APP_DOCKERFILE,
|
|
7529
|
+
}));
|
|
7530
|
+
// Every app foundry deploys keeps its own logs (#75), reused as-is here.
|
|
7531
|
+
await this.ensureLogVolume(app.appId, org, app.existed);
|
|
7532
|
+
await this.ports.coolify.updateApplication(app.appId, {
|
|
7533
|
+
...healthCheckSettings('/api/health', KOSMOS_PORT),
|
|
7534
|
+
...(await this.routedLabels(name, kosmosHostnameFor(org), KOSMOS_PORT)),
|
|
7535
|
+
});
|
|
7536
|
+
const liveRows = (await this.ports.coolify.listEnvs(app.appId)).filter((e) => !e.is_preview);
|
|
7537
|
+
const liveEnv = Object.fromEntries(liveRows.map((e) => [e.key, e.value]));
|
|
7538
|
+
const envChanged = kosmosEnvDrift(env, liveEnv).length > 0;
|
|
7539
|
+
const envs = Object.entries(env).map(([key, value]) => ({ key, value }));
|
|
7540
|
+
const cookieSecret = await this.ensureAuthCookieSecret(app.appId);
|
|
7541
|
+
if (cookieSecret)
|
|
7542
|
+
envs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
|
|
7543
|
+
await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
|
|
7544
|
+
const dbPush = await this.pushDatabaseUrl(app.appId, dbId, dbName, kosmosServer ?? undefined);
|
|
7545
|
+
if (!dbPush.pushed) {
|
|
7546
|
+
throw new Error(`kosmos for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
7547
|
+
}
|
|
7548
|
+
if (!context.recordedIds()[KOSMOS_DEPLOYED_SCOPE_KEY]) {
|
|
7549
|
+
const runtime = this.ports.runtime ?? {};
|
|
7550
|
+
const built = await waitForNewDeployment(this.ports.coolify, app.appId, publicHealthCheck(org, context.recordedIds(), kosmosHostnameFor(org), '/api/health'), runtime, runtime.sleep ?? defaultSleep, 'coolify:kosmos', `${org.slug}'s kosmos stays at its previous deploy`);
|
|
7551
|
+
await recordBuiltCommit(this.ports.coolify, { appId: app.appId, deploymentUuid: built.deploymentUuid, name: 'kosmos' }, (key, value) => context.recordScopeId(key, value));
|
|
7552
|
+
context.recordScopeId(KOSMOS_DEPLOYED_SCOPE_KEY, '1');
|
|
7553
|
+
}
|
|
7554
|
+
else if (app.existed && (envChanged || cookieSecret !== null || dbPush.changed)) {
|
|
7555
|
+
await this.ports.coolify.restart(app.appId);
|
|
7556
|
+
}
|
|
7557
|
+
return { resourceId: app.appId };
|
|
7558
|
+
}
|
|
7559
|
+
/**
|
|
7560
|
+
* C3: the Archon credential of an org's Boule. Archon's mint rotates on every call, so it is called only
|
|
7561
|
+
* when none was pushed or the recorded push time (no secret) is 335 days old. Otherwise the held value is
|
|
7562
|
+
* written again unchanged. The secret is held first; the caller records `pushedAt` only after the env write
|
|
7563
|
+
* succeeded, so a failed hold or a failed push is mended by the next apply minting again. Errors name no secret.
|
|
7564
|
+
*/
|
|
7565
|
+
async ensureBouleArchonCredential(org, context) {
|
|
7566
|
+
const at = `coolify:boule for ${org.slug}`;
|
|
7567
|
+
const mint = this.ports.mintBouleArchonCredential;
|
|
7568
|
+
const heldKeys = this.ports.heldKeys;
|
|
6735
7569
|
if (!mint)
|
|
6736
7570
|
throw new Error(`${at}: this executor was built without mintBouleArchonCredential`);
|
|
6737
7571
|
if (!heldKeys?.put) {
|
|
@@ -6786,6 +7620,261 @@ export class FoundryStepExecutor {
|
|
|
6786
7620
|
}
|
|
6787
7621
|
return minted.token;
|
|
6788
7622
|
}
|
|
7623
|
+
/** The held keys with a timeout on every call: a hung store stops the step with a plain line. */
|
|
7624
|
+
timedHeldKeys() {
|
|
7625
|
+
const held = this.ports.heldKeys;
|
|
7626
|
+
if (!held)
|
|
7627
|
+
return undefined;
|
|
7628
|
+
return {
|
|
7629
|
+
resolve: (ref) => withLabelledTimeout('held key read', HELD_KEY_READ_TIMEOUT_MS, () => held.resolve(ref)),
|
|
7630
|
+
...(typeof held.put === 'function'
|
|
7631
|
+
? {
|
|
7632
|
+
put: (ref, value) => withLabelledTimeout('held key write', HELD_KEY_READ_TIMEOUT_MS, () => held.put(ref, value)),
|
|
7633
|
+
}
|
|
7634
|
+
: {}),
|
|
7635
|
+
};
|
|
7636
|
+
}
|
|
7637
|
+
/** T3: the company's registrar key, held at `foundry:registrar:<org id>`; either step may make it. */
|
|
7638
|
+
registrarKey(org, context) {
|
|
7639
|
+
return ensureRegistrarKey({
|
|
7640
|
+
orgId: org.id,
|
|
7641
|
+
slug: org.slug,
|
|
7642
|
+
heldKeys: this.timedHeldKeys(),
|
|
7643
|
+
recorded: () => context.recordedIds(),
|
|
7644
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
7645
|
+
persist: () => context.persistScopeIds(),
|
|
7646
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
7647
|
+
log: (line) => console.info(`coolify: ${line}`),
|
|
7648
|
+
});
|
|
7649
|
+
}
|
|
7650
|
+
/**
|
|
7651
|
+
* T3, customers off: takes away the registrar env foundry gave Boule. The registrar step removes the record
|
|
7652
|
+
* at the broker and forgets the key. Returns true when Boule's env changed.
|
|
7653
|
+
*/
|
|
7654
|
+
async dropRegistrarEnv(appId, org, context, liveRows) {
|
|
7655
|
+
if (registrarWanted(org) || !context.recordedIds()[REGISTRAR_DELIVERED_SCOPE_KEY])
|
|
7656
|
+
return false;
|
|
7657
|
+
// In step with the record: while the broker may still take the key, Boule keeps it.
|
|
7658
|
+
const kept = !this.ports.broker
|
|
7659
|
+
? 'the host has not wired the broker port, so the record cannot be taken away'
|
|
7660
|
+
: context.recordedIds()[REGISTRAR_REMOVAL_HELD_SCOPE_KEY]
|
|
7661
|
+
? 'the last removal at the broker wrote nothing'
|
|
7662
|
+
: null;
|
|
7663
|
+
if (kept) {
|
|
7664
|
+
console.info(`coolify:boule for ${org.slug}: Boule keeps its registrar env: ${kept}`);
|
|
7665
|
+
return false;
|
|
7666
|
+
}
|
|
7667
|
+
let changed = false;
|
|
7668
|
+
for (const row of liveRows) {
|
|
7669
|
+
if (row.key !== AUTH_REGISTRAR_KEY_ENV && row.key !== AUTH_REGISTRAR_URL_ENV)
|
|
7670
|
+
continue;
|
|
7671
|
+
if (!row.uuid)
|
|
7672
|
+
continue;
|
|
7673
|
+
try {
|
|
7674
|
+
await this.ports.coolify.deleteEnv(appId, row.uuid);
|
|
7675
|
+
changed = true;
|
|
7676
|
+
}
|
|
7677
|
+
catch (error) {
|
|
7678
|
+
if (!isNotFoundError(error))
|
|
7679
|
+
throw error;
|
|
7680
|
+
}
|
|
7681
|
+
}
|
|
7682
|
+
context.recordScopeId(REGISTRAR_DELIVERED_SCOPE_KEY, '');
|
|
7683
|
+
return changed;
|
|
7684
|
+
}
|
|
7685
|
+
/**
|
|
7686
|
+
* T3: the company's record in the broker's `AUTH_REGISTRARS` (`plan/broker-registrar.ts`): its key (made
|
|
7687
|
+
* and held first if the Boule step has not), its sign-in organisation, customers organisations and login
|
|
7688
|
+
* host. With customers off it takes the record away, then forgets the key. The applied marker is recorded
|
|
7689
|
+
* last and never holds the key; every error is redacted of it.
|
|
7690
|
+
*/
|
|
7691
|
+
async brokerRegistrar(org, context) {
|
|
7692
|
+
const at = `coolify:broker-registrar for ${org.slug}`;
|
|
7693
|
+
const broker = this.ports.broker;
|
|
7694
|
+
if (!broker)
|
|
7695
|
+
throw new Error(`${at}: the broker port is not wired`);
|
|
7696
|
+
const recorded = context.recordedIds();
|
|
7697
|
+
const previousOrganizationId = appliedRegistrarOrganization(recorded);
|
|
7698
|
+
if (!registrarWanted(org)) {
|
|
7699
|
+
const keys = [];
|
|
7700
|
+
const heldKeys = this.timedHeldKeys();
|
|
7701
|
+
let removal;
|
|
7702
|
+
try {
|
|
7703
|
+
// The held key finds the record whatever organisation it names, also after a crash before the
|
|
7704
|
+
// applied marker; the recorded sign-in organisation does too.
|
|
7705
|
+
const ownKey = recorded[REGISTRAR_MINTED_SCOPE_KEY]
|
|
7706
|
+
? ((await heldKeys?.resolve(registrarRef(org.id))) ?? null)
|
|
7707
|
+
: null;
|
|
7708
|
+
if (ownKey) {
|
|
7709
|
+
keys.push(ownKey);
|
|
7710
|
+
this.handledSecrets.add(ownKey);
|
|
7711
|
+
}
|
|
7712
|
+
removal = await writeRegistrar(broker, {
|
|
7713
|
+
slug: org.slug,
|
|
7714
|
+
record: null,
|
|
7715
|
+
previousOrganizationId,
|
|
7716
|
+
ownOrganizationIds: [recorded[ZITADEL_ORG_SCOPE_KEY] ?? ''],
|
|
7717
|
+
ownKey,
|
|
7718
|
+
secret: (value) => {
|
|
7719
|
+
keys.push(value);
|
|
7720
|
+
this.handledSecrets.add(value);
|
|
7721
|
+
},
|
|
7722
|
+
log: (line) => console.info(redactSecrets(line, keys)),
|
|
7723
|
+
});
|
|
7724
|
+
}
|
|
7725
|
+
catch (error) {
|
|
7726
|
+
throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), keys));
|
|
7727
|
+
}
|
|
7728
|
+
if (removal.removalHeld) {
|
|
7729
|
+
// The record stays at the broker, so the key stays held; the plan says why.
|
|
7730
|
+
console.info(`${at}: the record is not taken away and the key is kept: ${removal.removalHeld}`);
|
|
7731
|
+
context.recordScopeId(REGISTRAR_REMOVAL_HELD_SCOPE_KEY, removal.removalHeld);
|
|
7732
|
+
return {};
|
|
7733
|
+
}
|
|
7734
|
+
context.recordScopeId(REGISTRAR_REMOVAL_HELD_SCOPE_KEY, '');
|
|
7735
|
+
context.recordScopeId(REGISTRAR_APPLIED_SCOPE_KEY, '');
|
|
7736
|
+
context.recordScopeId(REGISTRAR_DEPLOY_WAITS_SCOPE_KEY, '');
|
|
7737
|
+
await context.persistScopeIds();
|
|
7738
|
+
// Last: the broker no longer takes the key, so forgetting it leaves nothing that needs it.
|
|
7739
|
+
if (recorded[REGISTRAR_MINTED_SCOPE_KEY]) {
|
|
7740
|
+
if (!heldKeys?.put) {
|
|
7741
|
+
throw new Error(`${at}: the held keys cannot forget the registrar key (ref ${registrarRef(org.id)})`);
|
|
7742
|
+
}
|
|
7743
|
+
await heldKeys.put(registrarRef(org.id), '');
|
|
7744
|
+
context.recordScopeId(REGISTRAR_MINTED_SCOPE_KEY, '');
|
|
7745
|
+
}
|
|
7746
|
+
return {};
|
|
7747
|
+
}
|
|
7748
|
+
const wanted = wantedRegistrar(org, recorded);
|
|
7749
|
+
if ('missing' in wanted) {
|
|
7750
|
+
throw new Error(`${at}: ${wanted.missing.map((m) => m.what).join(', ')} not recorded yet`);
|
|
7751
|
+
}
|
|
7752
|
+
const { key, mint } = await this.registrarKey(org, context);
|
|
7753
|
+
// Every company's key the broker's env holds, so none can leave in an error.
|
|
7754
|
+
const keys = [key];
|
|
7755
|
+
let result;
|
|
7756
|
+
try {
|
|
7757
|
+
result = await writeRegistrar(broker, {
|
|
7758
|
+
slug: org.slug,
|
|
7759
|
+
record: { ...wanted.want, key },
|
|
7760
|
+
previousOrganizationId,
|
|
7761
|
+
secret: (value) => {
|
|
7762
|
+
keys.push(value);
|
|
7763
|
+
this.handledSecrets.add(value);
|
|
7764
|
+
},
|
|
7765
|
+
log: (line) => console.info(redactSecrets(line, keys)),
|
|
7766
|
+
});
|
|
7767
|
+
}
|
|
7768
|
+
catch (error) {
|
|
7769
|
+
throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), keys));
|
|
7770
|
+
}
|
|
7771
|
+
context.recordScopeId(REGISTRAR_DEPLOY_WAITS_SCOPE_KEY, result.waits.join(', '));
|
|
7772
|
+
// Last: a run that failed above plans the step again.
|
|
7773
|
+
context.recordScopeId(REGISTRAR_APPLIED_SCOPE_KEY, registrarAppliedValue(wanted.want, mint));
|
|
7774
|
+
return {};
|
|
7775
|
+
}
|
|
7776
|
+
customerGrantsCredential(org, entryKey, context) {
|
|
7777
|
+
return ensureCustomerGrantsCredential({
|
|
7778
|
+
orgId: org.id,
|
|
7779
|
+
slug: org.slug,
|
|
7780
|
+
entryKey,
|
|
7781
|
+
heldKeys: this.timedHeldKeys(),
|
|
7782
|
+
recorded: () => context.recordedIds(),
|
|
7783
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
7784
|
+
persist: () => context.persistScopeIds(),
|
|
7785
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
7786
|
+
});
|
|
7787
|
+
}
|
|
7788
|
+
/**
|
|
7789
|
+
* F13, app side: an entry with `uses.customerGrants` gets `CUSTOMER_GRANTS_CREDENTIAL` (made and held first,
|
|
7790
|
+
* once). An entry that had one and no longer declares the flag loses the env and the held value; each part is
|
|
7791
|
+
* repeated safely, so a run that died halfway is finished by the next.
|
|
7792
|
+
*/
|
|
7793
|
+
async customerGrantsAppEnv(appId, org, entry, context, envs) {
|
|
7794
|
+
if (entry.uses?.customerGrants) {
|
|
7795
|
+
envs.push({
|
|
7796
|
+
key: CUSTOMER_GRANTS_CREDENTIAL_ENV,
|
|
7797
|
+
value: await this.customerGrantsCredential(org, entry.key, context),
|
|
7798
|
+
});
|
|
7799
|
+
return;
|
|
7800
|
+
}
|
|
7801
|
+
// Either marker says the app may still hold the env: the held value may already be forgotten by Boule's step.
|
|
7802
|
+
const recorded = context.recordedIds();
|
|
7803
|
+
const minted = Boolean(recorded[customerGrantsMintedScopeKey(entry.key)]);
|
|
7804
|
+
const applied = (recorded[appEnvAppliedScopeKey(entry.key)] ?? '').includes(`"${CUSTOMER_GRANTS_CREDENTIAL_ENV}"`);
|
|
7805
|
+
if (!minted && !applied)
|
|
7806
|
+
return;
|
|
7807
|
+
const at = `coolify:app for ${org.slug}`;
|
|
7808
|
+
for (const row of await this.ports.coolify.listEnvs(appId)) {
|
|
7809
|
+
if (row.key !== CUSTOMER_GRANTS_CREDENTIAL_ENV || row.is_preview || !row.uuid)
|
|
7810
|
+
continue;
|
|
7811
|
+
try {
|
|
7812
|
+
await this.ports.coolify.deleteEnv(appId, row.uuid);
|
|
7813
|
+
}
|
|
7814
|
+
catch (error) {
|
|
7815
|
+
if (!isNotFoundError(error))
|
|
7816
|
+
throw error;
|
|
7817
|
+
}
|
|
7818
|
+
}
|
|
7819
|
+
if (minted)
|
|
7820
|
+
await this.forgetCustomerGrantsCredential(org, entry.key, context, at);
|
|
7821
|
+
}
|
|
7822
|
+
/** Forgets one entry's held credential and clears its marker. Held first, marker last, so a failed forget is repeated. */
|
|
7823
|
+
async forgetCustomerGrantsCredential(org, entryKey, context, at) {
|
|
7824
|
+
const heldKeys = this.timedHeldKeys();
|
|
7825
|
+
if (typeof heldKeys?.put !== 'function') {
|
|
7826
|
+
throw new Error(`${at}: the held keys cannot forget the customer grants credential of entry '${entryKey}' (ref ${customerGrantsRef(org.id, entryKey)})`);
|
|
7827
|
+
}
|
|
7828
|
+
await heldKeys.put(customerGrantsRef(org.id, entryKey), '');
|
|
7829
|
+
context.recordScopeId(customerGrantsMintedScopeKey(entryKey), '');
|
|
7830
|
+
await context.persistScopeIds();
|
|
7831
|
+
}
|
|
7832
|
+
/** F13: the held credentials of entries that no longer declare the flag, forgotten after Boule stopped listing them. */
|
|
7833
|
+
async forgetDroppedCustomerGrants(org, context) {
|
|
7834
|
+
for (const key of customerGrantsToDrop(org, context.recordedIds())) {
|
|
7835
|
+
await this.forgetCustomerGrantsCredential(org, key, context, `coolify:boule for ${org.slug}`);
|
|
7836
|
+
}
|
|
7837
|
+
}
|
|
7838
|
+
/**
|
|
7839
|
+
* F13, Boule side: the `CUSTOMER_GRANTS` value for the flagged entries (each credential made and held if it
|
|
7840
|
+
* is not yet), or `value: null`. With no flagged entry left and a value foundry wrote, the env row is
|
|
7841
|
+
* removed (`removed`). A flagged entry that cannot be listed yet changes nothing. Never a hand-set value
|
|
7842
|
+
* it did not write.
|
|
7843
|
+
*/
|
|
7844
|
+
async customerGrantsForBoule(appId, org, context, planned, directoryCredential, liveRows) {
|
|
7845
|
+
const none = { value: null, removed: false, secrets: [] };
|
|
7846
|
+
const at = `coolify:boule for ${org.slug}`;
|
|
7847
|
+
if (!planned) {
|
|
7848
|
+
if (customerGrantsEntries(org).length > 0)
|
|
7849
|
+
return none;
|
|
7850
|
+
if (!context.recordedIds()[CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY])
|
|
7851
|
+
return none;
|
|
7852
|
+
for (const row of liveRows) {
|
|
7853
|
+
if (row.key !== CUSTOMER_GRANTS_ENV || !row.uuid)
|
|
7854
|
+
continue;
|
|
7855
|
+
try {
|
|
7856
|
+
await this.ports.coolify.deleteEnv(appId, row.uuid);
|
|
7857
|
+
}
|
|
7858
|
+
catch (error) {
|
|
7859
|
+
if (!isNotFoundError(error))
|
|
7860
|
+
throw error;
|
|
7861
|
+
}
|
|
7862
|
+
}
|
|
7863
|
+
context.recordScopeId(CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY, '');
|
|
7864
|
+
await context.persistScopeIds();
|
|
7865
|
+
return { ...none, removed: liveRows.some((row) => row.key === CUSTOMER_GRANTS_ENV) };
|
|
7866
|
+
}
|
|
7867
|
+
const wanted = wantedCustomerGrants(org, context.recordedIds(), { live: true });
|
|
7868
|
+
if (!wanted.shape || !directoryCredential) {
|
|
7869
|
+
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`);
|
|
7870
|
+
}
|
|
7871
|
+
const credentials = {};
|
|
7872
|
+
for (const key of Object.keys(wanted.shape.apps)) {
|
|
7873
|
+
credentials[key] = await this.customerGrantsCredential(org, key, context);
|
|
7874
|
+
}
|
|
7875
|
+
const value = customerGrantsJson(wanted.shape, directoryCredential, credentials);
|
|
7876
|
+
return { value, removed: false, secrets: [...Object.values(credentials), value] };
|
|
7877
|
+
}
|
|
6789
7878
|
/** `put`, tried up to {@link BOULE_CREDENTIAL_ATTEMPTS} times; true once one attempt succeeds. The cause is dropped. */
|
|
6790
7879
|
async holdWithRetry(ref, value, put, label) {
|
|
6791
7880
|
for (let attempt = 1; attempt <= BOULE_CREDENTIAL_ATTEMPTS; attempt++) {
|
|
@@ -6817,7 +7906,9 @@ export class FoundryStepExecutor {
|
|
|
6817
7906
|
}
|
|
6818
7907
|
let changed = false;
|
|
6819
7908
|
for (const row of liveRows) {
|
|
6820
|
-
if (row.key !== BOULE_CUSTOMERS_ORG_ENV &&
|
|
7909
|
+
if (row.key !== BOULE_CUSTOMERS_ORG_ENV &&
|
|
7910
|
+
row.key !== BOULE_TEST_CUSTOMERS_ORG_ENV &&
|
|
7911
|
+
row.key !== BOULE_CUSTOMERS_CREDENTIAL_ENV)
|
|
6821
7912
|
continue;
|
|
6822
7913
|
if (!row.uuid)
|
|
6823
7914
|
continue;
|
|
@@ -7020,6 +8111,30 @@ export class FoundryStepExecutor {
|
|
|
7020
8111
|
project: { ensure: () => this.ensureCoolifyProject(org, context) },
|
|
7021
8112
|
});
|
|
7022
8113
|
}
|
|
8114
|
+
/**
|
|
8115
|
+
* `plan/own-diff.ts`'s `planOwnJobsRoute`: the org's scheduler on `jobs.<domain>`, through the company's own
|
|
8116
|
+
* edge-routed DNS and tunnel ports, dialling the address `coolify:own-jobs` recorded.
|
|
8117
|
+
*/
|
|
8118
|
+
async ownJobsRoute(action, org, context) {
|
|
8119
|
+
const own = this.ports.ownServices;
|
|
8120
|
+
if (!own)
|
|
8121
|
+
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
8122
|
+
const internalUrl = context.recordedIds()[JOBS_INTERNAL_URL_SCOPE_KEY];
|
|
8123
|
+
if (!internalUrl) {
|
|
8124
|
+
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`);
|
|
8125
|
+
}
|
|
8126
|
+
const scope = scopeFromOrg(context, {});
|
|
8127
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
8128
|
+
return provisionJobsRoute(scope, {
|
|
8129
|
+
coolify: limited(this.placedForCompany(own.provision.sfu.coolify, org, context), LIMITS.connector),
|
|
8130
|
+
network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:jobs-route`),
|
|
8131
|
+
}, orgTarget(org), {
|
|
8132
|
+
hostname: action.desired.hostname,
|
|
8133
|
+
apex: ownHostnames(org).zone,
|
|
8134
|
+
internalUrl,
|
|
8135
|
+
projectUuid,
|
|
8136
|
+
});
|
|
8137
|
+
}
|
|
7023
8138
|
/**
|
|
7024
8139
|
* `plan/own-diff.ts`'s `planOwnFiles`: the org's own files host in the
|
|
7025
8140
|
* org's own project, trusting the company's issuer `https://auth.<domain>`.
|
|
@@ -7426,6 +8541,168 @@ export class FoundryStepExecutor {
|
|
|
7426
8541
|
...(context.serverIpv4 ? { serverIpv4: context.serverIpv4 } : {}),
|
|
7427
8542
|
});
|
|
7428
8543
|
}
|
|
8544
|
+
/**
|
|
8545
|
+
* `plan/entry-jobs.ts`'s `planEntryJobs`: registers one entry as its own project in the platform's jobs
|
|
8546
|
+
* scheduler, holds the credentials the scheduler shows once, then writes `JOBS_SCHEDULER_URL`,
|
|
8547
|
+
* `JOBS_SIGNING_SECRET` and `JOBS_SYNC_KEY` to the entry's env and restarts the app.
|
|
8548
|
+
*
|
|
8549
|
+
* The project's id is recorded as undelivered and persisted before the credentials are held, and cleared
|
|
8550
|
+
* only once both are, so a failure in between never registers a second project: `planEntryJobs` blocks
|
|
8551
|
+
* on it. Credentials already held are never registered again, which is also how a failed env write is
|
|
8552
|
+
* retried. Errors name the project and the refs, never a credential.
|
|
8553
|
+
*/
|
|
8554
|
+
async jobsProject(action, org, context) {
|
|
8555
|
+
const entryKey = action.key.slice(org.id.length + 1);
|
|
8556
|
+
const at = `jobs:project for ${org.slug} entry '${entryKey}'`;
|
|
8557
|
+
const want = action.desired;
|
|
8558
|
+
if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.jobs)) {
|
|
8559
|
+
throw new Error(`${at}: the entry no longer declares uses.jobs`);
|
|
8560
|
+
}
|
|
8561
|
+
const appId = context.recordedIds()[`coolify:app:${appStepKey(org, entryKey)}`];
|
|
8562
|
+
if (!appId)
|
|
8563
|
+
throw new Error(`${at}: the entry's app is not recorded yet`);
|
|
8564
|
+
const held = this.ports.heldKeys;
|
|
8565
|
+
const put = held?.put?.bind(held);
|
|
8566
|
+
if (!held || !put)
|
|
8567
|
+
throw new Error(`${at}: the held keys cannot hold the project's credentials`);
|
|
8568
|
+
const refs = entryJobsRefs(org.id, entryKey);
|
|
8569
|
+
const read = async (ref) => {
|
|
8570
|
+
try {
|
|
8571
|
+
const value = await withTimeout(held.resolve(ref), JOBS_PROJECT_CALL_TIMEOUT_MS);
|
|
8572
|
+
return value || null;
|
|
8573
|
+
}
|
|
8574
|
+
catch {
|
|
8575
|
+
throw new Error(`${at}: the held key ${ref} could not be read`);
|
|
8576
|
+
}
|
|
8577
|
+
};
|
|
8578
|
+
let signingSecret = await read(refs.signingSecret);
|
|
8579
|
+
let syncKey = await read(refs.syncKey);
|
|
8580
|
+
if (Boolean(signingSecret) !== Boolean(syncKey)) {
|
|
8581
|
+
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`);
|
|
8582
|
+
}
|
|
8583
|
+
if (signingSecret)
|
|
8584
|
+
this.handledSecrets.add(signingSecret);
|
|
8585
|
+
if (syncKey)
|
|
8586
|
+
this.handledSecrets.add(syncKey);
|
|
8587
|
+
if (!signingSecret || !syncKey) {
|
|
8588
|
+
const scheduler = await this.jobsPort(org);
|
|
8589
|
+
let registered;
|
|
8590
|
+
try {
|
|
8591
|
+
registered = await withTimeout(scheduler.registerProject({ name: want.name, baseUrl: want.baseUrl }), JOBS_PROJECT_CALL_TIMEOUT_MS);
|
|
8592
|
+
}
|
|
8593
|
+
catch (error) {
|
|
8594
|
+
const detail = error instanceof Error ? error.message : 'no detail';
|
|
8595
|
+
throw new Error(`${at}: the scheduler refused or did not answer the registration (${detail})`);
|
|
8596
|
+
}
|
|
8597
|
+
if (registered.secret)
|
|
8598
|
+
this.handledSecrets.add(registered.secret);
|
|
8599
|
+
if (registered.syncKey)
|
|
8600
|
+
this.handledSecrets.add(registered.syncKey);
|
|
8601
|
+
context.recordScopeId(entryJobsUndeliveredScopeKey(entryKey), registered.id);
|
|
8602
|
+
await context.persistScopeIds();
|
|
8603
|
+
if (!registered.secret || !registered.syncKey) {
|
|
8604
|
+
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)}`);
|
|
8605
|
+
}
|
|
8606
|
+
for (const [ref, value] of [
|
|
8607
|
+
[refs.signingSecret, registered.secret],
|
|
8608
|
+
[refs.syncKey, registered.syncKey],
|
|
8609
|
+
]) {
|
|
8610
|
+
let stored = false;
|
|
8611
|
+
for (let attempt = 1; attempt <= JOBS_PROJECT_HOLD_ATTEMPTS && !stored; attempt++) {
|
|
8612
|
+
try {
|
|
8613
|
+
await withTimeout(put(ref, value), JOBS_PROJECT_CALL_TIMEOUT_MS);
|
|
8614
|
+
stored = true;
|
|
8615
|
+
}
|
|
8616
|
+
catch {
|
|
8617
|
+
// Retried; the error is dropped in case it echoes the credential.
|
|
8618
|
+
}
|
|
8619
|
+
}
|
|
8620
|
+
if (!stored) {
|
|
8621
|
+
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)}`);
|
|
8622
|
+
}
|
|
8623
|
+
}
|
|
8624
|
+
signingSecret = registered.secret;
|
|
8625
|
+
syncKey = registered.syncKey;
|
|
8626
|
+
forgetScopeId(context, entryJobsUndeliveredScopeKey(entryKey));
|
|
8627
|
+
await context.persistScopeIds();
|
|
8628
|
+
}
|
|
8629
|
+
try {
|
|
8630
|
+
await withTimeout(this.ports.coolify.bulkUpsertEnvs(appId, [
|
|
8631
|
+
{ key: 'JOBS_SCHEDULER_URL', value: want.schedulerUrl, literal: true },
|
|
8632
|
+
{ key: 'JOBS_SIGNING_SECRET', value: signingSecret, literal: true },
|
|
8633
|
+
{ key: 'JOBS_SYNC_KEY', value: syncKey, literal: true },
|
|
8634
|
+
]), JOBS_PROJECT_CALL_TIMEOUT_MS);
|
|
8635
|
+
await withTimeout(this.ports.coolify.restart(appId), JOBS_PROJECT_CALL_TIMEOUT_MS);
|
|
8636
|
+
}
|
|
8637
|
+
catch {
|
|
8638
|
+
// The error is dropped in case Coolify echoes the request body.
|
|
8639
|
+
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`);
|
|
8640
|
+
}
|
|
8641
|
+
context.recordScopeId(entryJobsAppliedScopeKey(entryKey), entryJobsWantMarker(want));
|
|
8642
|
+
return { resourceId: want.name };
|
|
8643
|
+
}
|
|
8644
|
+
/** `plan/entry-audit.ts`: makes the platform signer if none exists and registers one entry's ledger for sealing. */
|
|
8645
|
+
async auditLedger(action, org, context) {
|
|
8646
|
+
const entryKey = action.key.slice(org.id.length + 1);
|
|
8647
|
+
const at = `audit:ledger for ${org.slug} entry '${entryKey}'`;
|
|
8648
|
+
if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.audit === true)) {
|
|
8649
|
+
throw new Error(`${at}: the entry no longer declares uses.audit`);
|
|
8650
|
+
}
|
|
8651
|
+
const held = this.timedHeldKeys();
|
|
8652
|
+
if (typeof held?.put !== 'function') {
|
|
8653
|
+
throw new Error(`${at}: the held keys cannot hold the platform signer's key`);
|
|
8654
|
+
}
|
|
8655
|
+
return applyEntryAudit({
|
|
8656
|
+
heldKeys: { resolve: held.resolve, put: held.put },
|
|
8657
|
+
org: { id: org.id, slug: org.slug },
|
|
8658
|
+
entryKey,
|
|
8659
|
+
recorded: () => context.recordedIds(),
|
|
8660
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
8661
|
+
persist: () => context.persistScopeIds(),
|
|
8662
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
8663
|
+
});
|
|
8664
|
+
}
|
|
8665
|
+
/** F12, `plan/entry-repo-key.ts`: makes or removes one entry's deploy key. See `entry-repo-key-apply.ts`. */
|
|
8666
|
+
async githubDeployKey(action, org, context) {
|
|
8667
|
+
const entryKey = action.key.slice(org.id.length + 1);
|
|
8668
|
+
const at = `github:deploy-key for ${org.slug} entry '${entryKey}'`;
|
|
8669
|
+
const desired = action.desired;
|
|
8670
|
+
const github = this.ports.githubDeployKeys;
|
|
8671
|
+
if (!github)
|
|
8672
|
+
throw new Error(`${at}: the host gave no GitHub deploy-key client`);
|
|
8673
|
+
const held = this.timedHeldKeys();
|
|
8674
|
+
if (typeof held?.put !== 'function') {
|
|
8675
|
+
throw new Error(`${at}: the held keys cannot hold the key's private half`);
|
|
8676
|
+
}
|
|
8677
|
+
const deps = {
|
|
8678
|
+
github,
|
|
8679
|
+
heldKeys: { resolve: held.resolve, put: held.put },
|
|
8680
|
+
coolify: this.ports.coolify,
|
|
8681
|
+
org: { id: org.id, slug: org.slug },
|
|
8682
|
+
entryKey,
|
|
8683
|
+
appId: context.recordedIds()[`coolify:app:${appStepKey(org, entryKey)}`],
|
|
8684
|
+
recorded: () => context.recordedIds(),
|
|
8685
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
8686
|
+
persist: () => context.persistScopeIds(),
|
|
8687
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
8688
|
+
installationFor: (repo) => companyGithubFor(org.hosting, repo)?.installationId,
|
|
8689
|
+
};
|
|
8690
|
+
if (desired.remove) {
|
|
8691
|
+
await removeRepoKey(deps, desired.repo);
|
|
8692
|
+
return {};
|
|
8693
|
+
}
|
|
8694
|
+
if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.repo)) {
|
|
8695
|
+
throw new Error(`${at}: the entry no longer declares uses.repo`);
|
|
8696
|
+
}
|
|
8697
|
+
await ensureRepoKey(deps, {
|
|
8698
|
+
repo: desired.repo,
|
|
8699
|
+
access: desired.access === 'write' ? 'write' : 'read',
|
|
8700
|
+
...(companyGithubFor(org.hosting, desired.repo)
|
|
8701
|
+
? { installationId: companyGithubFor(org.hosting, desired.repo)?.installationId }
|
|
8702
|
+
: {}),
|
|
8703
|
+
});
|
|
8704
|
+
return {};
|
|
8705
|
+
}
|
|
7429
8706
|
async jobsRegistration(action, org, context) {
|
|
7430
8707
|
const scheduler = await this.jobsPort(org);
|
|
7431
8708
|
const name = action.desired.name;
|
|
@@ -7695,6 +8972,7 @@ export class FoundryStepExecutor {
|
|
|
7695
8972
|
}
|
|
7696
8973
|
}
|
|
7697
8974
|
await this.publishOikosEntryRecords(org, tunnel.id, previousIds, context);
|
|
8975
|
+
await this.publishAppsEntryRecords(org, tunnel.id, previousIds, context);
|
|
7698
8976
|
return { resourceId: tunnel.id };
|
|
7699
8977
|
}
|
|
7700
8978
|
/**
|
|
@@ -7708,19 +8986,41 @@ export class FoundryStepExecutor {
|
|
|
7708
8986
|
* halfway is planned again.
|
|
7709
8987
|
*/
|
|
7710
8988
|
async publishOikosEntryRecords(org, tunnelId, previousIds, context) {
|
|
7711
|
-
|
|
7712
|
-
|
|
8989
|
+
await this.publishPlatformZoneEntryRecords(org, tunnelId, previousIds, context, {
|
|
8990
|
+
zoneName: NO_DOMAIN_ZONE,
|
|
8991
|
+
wanted: entryOikosHostnames(org, context.recordedIds()),
|
|
8992
|
+
scopeKey: CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY,
|
|
8993
|
+
owns: (name) => ownsOikosName(org, name),
|
|
8994
|
+
});
|
|
8995
|
+
}
|
|
8996
|
+
/**
|
|
8997
|
+
* The entry names a company has in the platform's `companyincluded.app` zone: its apps' `hostedName`
|
|
8998
|
+
* addresses. Written the same way as {@link publishOikosEntryRecords}. Which company may hold a name is
|
|
8999
|
+
* the host's check (Archon), so the step owns any single-label name under the zone.
|
|
9000
|
+
*/
|
|
9001
|
+
async publishAppsEntryRecords(org, tunnelId, previousIds, context) {
|
|
9002
|
+
await this.publishPlatformZoneEntryRecords(org, tunnelId, previousIds, context, {
|
|
9003
|
+
zoneName: APPS_DOMAIN,
|
|
9004
|
+
wanted: entryAppsHostnames(org, context.recordedIds()),
|
|
9005
|
+
scopeKey: CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY,
|
|
9006
|
+
owns: (name) => ownsAppsZoneName(org, name),
|
|
9007
|
+
organisationId: org.id,
|
|
9008
|
+
});
|
|
9009
|
+
}
|
|
9010
|
+
async publishPlatformZoneEntryRecords(org, tunnelId, previousIds, context, zoneSpec) {
|
|
9011
|
+
const { zoneName, wanted, scopeKey, owns, organisationId } = zoneSpec;
|
|
9012
|
+
const recorded = context.recordedIds()[scopeKey];
|
|
7713
9013
|
const previous = recorded ? recorded.split(',') : [];
|
|
7714
9014
|
if (wanted.length === 0 && previous.length === 0)
|
|
7715
9015
|
return;
|
|
7716
9016
|
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 ${
|
|
9017
|
+
if (!owns(name)) {
|
|
9018
|
+
throw new Error(`cloudflare:tunnel for ${org.slug}: ${name} is not a name this company may write in the ${zoneName} zone`);
|
|
7719
9019
|
}
|
|
7720
9020
|
}
|
|
7721
|
-
const zone = await this.zonePort().findZoneByApex(
|
|
9021
|
+
const zone = await this.zonePort().findZoneByApex(zoneName);
|
|
7722
9022
|
if (!zone) {
|
|
7723
|
-
throw new Error(`cloudflare:tunnel for ${org.slug}: no Cloudflare zone found for ${
|
|
9023
|
+
throw new Error(`cloudflare:tunnel for ${org.slug}: no Cloudflare zone found for ${zoneName} to point its entry names at`);
|
|
7724
9024
|
}
|
|
7725
9025
|
const dns = this.dnsPort();
|
|
7726
9026
|
const dnsTarget = `${tunnelId}.cfargotunnel.com`;
|
|
@@ -7728,7 +9028,7 @@ export class FoundryStepExecutor {
|
|
|
7728
9028
|
dnsTarget,
|
|
7729
9029
|
...[...previousIds].map((id) => `${id}.cfargotunnel.com`),
|
|
7730
9030
|
]);
|
|
7731
|
-
const [shared] = await dns.findDnsRecord(zone.name, `*.${
|
|
9031
|
+
const [shared] = await dns.findDnsRecord(zone.name, `*.${zoneName}`, 'CNAME');
|
|
7732
9032
|
const writes = [];
|
|
7733
9033
|
const conflicts = [];
|
|
7734
9034
|
for (const name of wanted) {
|
|
@@ -7739,7 +9039,13 @@ export class FoundryStepExecutor {
|
|
|
7739
9039
|
continue;
|
|
7740
9040
|
}
|
|
7741
9041
|
const [cname] = await dns.findDnsRecord(zone.name, name, 'CNAME');
|
|
7742
|
-
const record = {
|
|
9042
|
+
const record = {
|
|
9043
|
+
type: 'CNAME',
|
|
9044
|
+
name,
|
|
9045
|
+
content: dnsTarget,
|
|
9046
|
+
proxied: true,
|
|
9047
|
+
...(organisationId === undefined ? {} : { organisationId }),
|
|
9048
|
+
};
|
|
7743
9049
|
if (!cname)
|
|
7744
9050
|
writes.push(() => dns.createDnsRecord(zone.name, record));
|
|
7745
9051
|
else if (cname.content === dnsTarget)
|
|
@@ -7756,14 +9062,14 @@ export class FoundryStepExecutor {
|
|
|
7756
9062
|
for (const write of writes)
|
|
7757
9063
|
await write();
|
|
7758
9064
|
for (const name of previous.filter((n) => !wanted.includes(n))) {
|
|
7759
|
-
if (!
|
|
9065
|
+
if (!owns(name))
|
|
7760
9066
|
continue;
|
|
7761
9067
|
for (const record of await dns.findDnsRecord(zone.name, name, 'CNAME')) {
|
|
7762
9068
|
if (ownTargets.has(record.content))
|
|
7763
9069
|
await dns.deleteDnsRecord(zone.name, record.id);
|
|
7764
9070
|
}
|
|
7765
9071
|
}
|
|
7766
|
-
context.recordScopeId(
|
|
9072
|
+
context.recordScopeId(scopeKey, wanted.join(','));
|
|
7767
9073
|
}
|
|
7768
9074
|
/**
|
|
7769
9075
|
* #236: when this tunnel's DNS was last published under a domain that
|
|
@@ -8814,8 +10120,9 @@ export class FoundryStepExecutor {
|
|
|
8814
10120
|
return {};
|
|
8815
10121
|
}
|
|
8816
10122
|
if (entryKey === BOULE_EDGE_ENTRY_KEY) {
|
|
8817
|
-
if (!org
|
|
8818
|
-
throw new Error(`${at}: Boule's edge key needs org.domain`);
|
|
10123
|
+
if (!bouleEdgeKeyWanted(org)) {
|
|
10124
|
+
throw new Error(`${at}: Boule's edge key needs org.domain or org.boule.apps`);
|
|
10125
|
+
}
|
|
8819
10126
|
}
|
|
8820
10127
|
else if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.edge)) {
|
|
8821
10128
|
throw new Error(`${at}: the entry no longer declares uses.edge`);
|
|
@@ -8823,7 +10130,7 @@ export class FoundryStepExecutor {
|
|
|
8823
10130
|
const appId = context.recordedIds()[edgeKeyAppRecordKey(org, entryKey)];
|
|
8824
10131
|
if (!appId)
|
|
8825
10132
|
throw new Error(`${at}: the entry's app is not recorded yet`);
|
|
8826
|
-
if (!org.domain) {
|
|
10133
|
+
if (entryKey !== BOULE_EDGE_ENTRY_KEY && !org.domain) {
|
|
8827
10134
|
throw new Error(`${at}: edge needs org.domain, and validateEntryEdge already refuses it without one`);
|
|
8828
10135
|
}
|
|
8829
10136
|
// A key left over from a previous mint whose delivery never succeeded
|
|
@@ -8846,7 +10153,7 @@ export class FoundryStepExecutor {
|
|
|
8846
10153
|
}
|
|
8847
10154
|
const minted = await edge.mintKey({
|
|
8848
10155
|
organisationId: org.id,
|
|
8849
|
-
domain: org
|
|
10156
|
+
domain: edgeKeyDomain(org),
|
|
8850
10157
|
name: desired.name,
|
|
8851
10158
|
expiresDays: EDGE_ENTRY_KEY_DAYS,
|
|
8852
10159
|
});
|
|
@@ -8874,6 +10181,150 @@ export class FoundryStepExecutor {
|
|
|
8874
10181
|
// Names the key id only; the underlying error is dropped in case it echoes the secret.
|
|
8875
10182
|
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
10183
|
}
|
|
10184
|
+
/**
|
|
10185
|
+
* F7 (#928): the company's signed-records registrar key (`plan/keys-registrar.ts`). Mint: a leftover
|
|
10186
|
+
* undelivered key is revoked first; the new key's id is recorded and persisted before it is written to
|
|
10187
|
+
* Boule's env (3 tries, then the error names the id only and the next apply revokes and mints again).
|
|
10188
|
+
* `url` rewrites the address only. Retire revokes and takes both env rows away. Never holds the key.
|
|
10189
|
+
*/
|
|
10190
|
+
async keysRegistrar(action, org, context) {
|
|
10191
|
+
const at = `coolify:keys-registrar for ${org.slug}`;
|
|
10192
|
+
const keys = this.ports.keysRegistrar;
|
|
10193
|
+
if (!keys)
|
|
10194
|
+
throw new Error(`${at}: this executor was built without a KeysRegistrarPort`);
|
|
10195
|
+
const desired = action.desired;
|
|
10196
|
+
const recorded = context.recordedIds();
|
|
10197
|
+
const appId = recorded[`coolify:boule:${org.id}`];
|
|
10198
|
+
const revoke = async (id) => {
|
|
10199
|
+
try {
|
|
10200
|
+
await keys.revokeCompanyKey(org.id, id);
|
|
10201
|
+
}
|
|
10202
|
+
catch (error) {
|
|
10203
|
+
if (!isNotFoundError(error))
|
|
10204
|
+
throw error;
|
|
10205
|
+
}
|
|
10206
|
+
};
|
|
10207
|
+
const tolerate = async (call, gone) => {
|
|
10208
|
+
try {
|
|
10209
|
+
return await call();
|
|
10210
|
+
}
|
|
10211
|
+
catch (error) {
|
|
10212
|
+
if (!isNotFoundError(error))
|
|
10213
|
+
throw error;
|
|
10214
|
+
return gone;
|
|
10215
|
+
}
|
|
10216
|
+
};
|
|
10217
|
+
if (desired.mode === 'retire') {
|
|
10218
|
+
for (const id of new Set([recorded[KEYS_REGISTRAR_SCOPE_KEY], recorded[KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY]].filter((id) => Boolean(id)))) {
|
|
10219
|
+
await revoke(id);
|
|
10220
|
+
}
|
|
10221
|
+
if (recorded[KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY]) {
|
|
10222
|
+
// A mint whose answer was lost may have left a key no id points at.
|
|
10223
|
+
await keys.revokeAllCompanyKeys(org.id);
|
|
10224
|
+
}
|
|
10225
|
+
if (appId) {
|
|
10226
|
+
const rows = await tolerate(() => this.ports.coolify.listEnvs(appId), []);
|
|
10227
|
+
for (const name of [KEYS_REGISTRAR_URL_ENV, KEYS_REGISTRAR_KEY_ENV]) {
|
|
10228
|
+
const row = rows.find((e) => e.key === name && !e.is_preview);
|
|
10229
|
+
if (row?.uuid) {
|
|
10230
|
+
const uuid = row.uuid;
|
|
10231
|
+
await tolerate(() => this.ports.coolify.deleteEnv(appId, uuid), undefined);
|
|
10232
|
+
}
|
|
10233
|
+
}
|
|
10234
|
+
}
|
|
10235
|
+
for (const scope of [
|
|
10236
|
+
KEYS_REGISTRAR_SCOPE_KEY,
|
|
10237
|
+
KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY,
|
|
10238
|
+
KEYS_REGISTRAR_MINTED_AT_SCOPE_KEY,
|
|
10239
|
+
KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY,
|
|
10240
|
+
KEYS_REGISTRAR_URL_APPLIED_SCOPE_KEY,
|
|
10241
|
+
]) {
|
|
10242
|
+
context.recordScopeId(scope, '');
|
|
10243
|
+
}
|
|
10244
|
+
return {};
|
|
10245
|
+
}
|
|
10246
|
+
if (!appsOn(org))
|
|
10247
|
+
throw new Error(`${at}: the company's Boule runs no apps (org.boule.apps)`);
|
|
10248
|
+
if (!appId)
|
|
10249
|
+
throw new Error(`${at}: Boule is not recorded yet`);
|
|
10250
|
+
if (!desired.keysUrl)
|
|
10251
|
+
throw new Error(`${at}: the plan gave no keys service address`);
|
|
10252
|
+
if (desired.mode === 'url') {
|
|
10253
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, [
|
|
10254
|
+
{ key: KEYS_REGISTRAR_URL_ENV, value: desired.keysUrl, literal: true },
|
|
10255
|
+
]);
|
|
10256
|
+
await this.ports.coolify.restart(appId);
|
|
10257
|
+
context.recordScopeId(KEYS_REGISTRAR_URL_APPLIED_SCOPE_KEY, desired.keysUrl);
|
|
10258
|
+
return {};
|
|
10259
|
+
}
|
|
10260
|
+
if (recorded[KEYS_REGISTRAR_SCOPE_KEY])
|
|
10261
|
+
return {};
|
|
10262
|
+
const stale = recorded[KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY];
|
|
10263
|
+
if (stale) {
|
|
10264
|
+
await revoke(stale);
|
|
10265
|
+
context.recordScopeId(KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY, '');
|
|
10266
|
+
await context.persistScopeIds();
|
|
10267
|
+
}
|
|
10268
|
+
if (recorded[KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY]) {
|
|
10269
|
+
// The last mint's answer never reached us, so a key may exist whose id was never recorded. Revoke all
|
|
10270
|
+
// of the company's registrar keys first (none is delivered: the delivered id is not recorded here).
|
|
10271
|
+
const revoked = await keys.revokeAllCompanyKeys(org.id);
|
|
10272
|
+
console.info(`${at}: revoked ${revoked} registrar key(s) left by a mint whose answer was lost, before minting again`);
|
|
10273
|
+
}
|
|
10274
|
+
context.recordScopeId(KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY, new Date(context.now?.() ?? Date.now()).toISOString());
|
|
10275
|
+
await context.persistScopeIds();
|
|
10276
|
+
const minted = await keys.mintCompanyKey({
|
|
10277
|
+
organisationId: org.id,
|
|
10278
|
+
name: desired.name ?? `${org.slug}-registrar`,
|
|
10279
|
+
expiresDays: KEYS_REGISTRAR_KEY_DAYS,
|
|
10280
|
+
});
|
|
10281
|
+
this.handledSecrets.add(minted.secret);
|
|
10282
|
+
context.recordScopeId(KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY, minted.id);
|
|
10283
|
+
context.recordScopeId(KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY, '');
|
|
10284
|
+
await context.persistScopeIds();
|
|
10285
|
+
const rows = [
|
|
10286
|
+
{ key: KEYS_REGISTRAR_URL_ENV, value: desired.keysUrl, literal: true },
|
|
10287
|
+
{ key: KEYS_REGISTRAR_KEY_ENV, value: minted.secret, literal: true },
|
|
10288
|
+
];
|
|
10289
|
+
let lastError;
|
|
10290
|
+
for (let attempt = 1; attempt <= KEYS_REGISTRAR_DELIVERY_ATTEMPTS; attempt++) {
|
|
10291
|
+
try {
|
|
10292
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, rows);
|
|
10293
|
+
await this.ports.coolify.restart(appId);
|
|
10294
|
+
context.recordScopeId(KEYS_REGISTRAR_SCOPE_KEY, minted.id);
|
|
10295
|
+
context.recordScopeId(KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY, '');
|
|
10296
|
+
context.recordScopeId(KEYS_REGISTRAR_MINTED_AT_SCOPE_KEY, new Date(context.now?.() ?? Date.now()).toISOString());
|
|
10297
|
+
context.recordScopeId(KEYS_REGISTRAR_URL_APPLIED_SCOPE_KEY, desired.keysUrl);
|
|
10298
|
+
return {};
|
|
10299
|
+
}
|
|
10300
|
+
catch (e) {
|
|
10301
|
+
lastError = e;
|
|
10302
|
+
}
|
|
10303
|
+
}
|
|
10304
|
+
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`);
|
|
10305
|
+
}
|
|
10306
|
+
/**
|
|
10307
|
+
* F3: `plan/entry-edge-key.ts`'s `planEdgeSlug` -- records the org's slug in edge. A slug another company
|
|
10308
|
+
* holds (edge's `409`) is a plain line naming the clash, not the raw provider error.
|
|
10309
|
+
*/
|
|
10310
|
+
async edgeSlug(action, org, context) {
|
|
10311
|
+
const edge = this.ports.edge;
|
|
10312
|
+
if (!edge) {
|
|
10313
|
+
throw new Error(`edge:slug for ${org.slug}: this executor was built without an EdgePort`);
|
|
10314
|
+
}
|
|
10315
|
+
const slug = action.desired.slug;
|
|
10316
|
+
try {
|
|
10317
|
+
await edge.setOrganisationSlug(org.id, slug);
|
|
10318
|
+
}
|
|
10319
|
+
catch (e) {
|
|
10320
|
+
if (e instanceof EdgeApiError && e.code === 'conflict') {
|
|
10321
|
+
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`);
|
|
10322
|
+
}
|
|
10323
|
+
throw e;
|
|
10324
|
+
}
|
|
10325
|
+
context.recordScopeId(EDGE_SLUG_APPLIED_SCOPE_KEY, slug);
|
|
10326
|
+
return {};
|
|
10327
|
+
}
|
|
8877
10328
|
/**
|
|
8878
10329
|
* F7 (#317): `plan/entry-edge-key.ts`'s `planEdgeReservedLabels` --
|
|
8879
10330
|
* tells the edge service every label this org's own entries already
|
|
@@ -9299,6 +10750,28 @@ export class FoundryStepExecutor {
|
|
|
9299
10750
|
throw error;
|
|
9300
10751
|
}
|
|
9301
10752
|
}
|
|
10753
|
+
const appsNames = (recordedIds[CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY] ?? '')
|
|
10754
|
+
.split(',')
|
|
10755
|
+
.filter((name) => name !== '' && name.endsWith(`.${APPS_DOMAIN}`));
|
|
10756
|
+
if (appsNames.length > 0) {
|
|
10757
|
+
try {
|
|
10758
|
+
const zone = await this.zonePort().findZoneByApex(APPS_DOMAIN);
|
|
10759
|
+
if (zone) {
|
|
10760
|
+
const dns = this.dnsPort();
|
|
10761
|
+
for (const name of appsNames) {
|
|
10762
|
+
for (const record of await dns.findDnsRecord(zone.name, name, 'CNAME')) {
|
|
10763
|
+
if (dnsTargets.has(record.content)) {
|
|
10764
|
+
await dns.deleteDnsRecord(zone.name, record.id);
|
|
10765
|
+
}
|
|
10766
|
+
}
|
|
10767
|
+
}
|
|
10768
|
+
}
|
|
10769
|
+
}
|
|
10770
|
+
catch (error) {
|
|
10771
|
+
if (!isNotFoundError(error))
|
|
10772
|
+
throw error;
|
|
10773
|
+
}
|
|
10774
|
+
}
|
|
9302
10775
|
await this.tunnelPort().deleteTunnel(target.id);
|
|
9303
10776
|
return;
|
|
9304
10777
|
}
|
|
@@ -9377,6 +10850,16 @@ export class FoundryStepExecutor {
|
|
|
9377
10850
|
case 'coolify:bg-app':
|
|
9378
10851
|
await this.ports.coolify.deleteApplication(target.id);
|
|
9379
10852
|
return;
|
|
10853
|
+
case 'coolify:github-source':
|
|
10854
|
+
try {
|
|
10855
|
+
await withLabelledTimeout(`coolify:github-source for ${org.slug}: deleting the source`, GITHUB_SOURCE_TIMEOUT_MS, () => this.ports.coolify.raw('DELETE', `/github-apps/${target.id}`));
|
|
10856
|
+
}
|
|
10857
|
+
catch (error) {
|
|
10858
|
+
// withTimeout hides the cause; a source already gone counts as deleted.
|
|
10859
|
+
if (!(await this.githubSourceGone(target.id)))
|
|
10860
|
+
throw error;
|
|
10861
|
+
}
|
|
10862
|
+
return;
|
|
9380
10863
|
case 'cloudflare:bg-access':
|
|
9381
10864
|
await this.accessPort().deleteAccessApp(target.id);
|
|
9382
10865
|
return;
|
|
@@ -9384,16 +10867,31 @@ export class FoundryStepExecutor {
|
|
|
9384
10867
|
await this.accessPort().removeServiceToken(target.id);
|
|
9385
10868
|
return;
|
|
9386
10869
|
case 'cloudflare:bg-dns': {
|
|
9387
|
-
// `target.id` is the recorded route ({ hostname, service })
|
|
9388
|
-
|
|
9389
|
-
|
|
9390
|
-
|
|
10870
|
+
// `target.id` is the recorded route ({ hostname, service }). The zone comes from the hostname
|
|
10871
|
+
// (`<label>-hd.<zone apex>`, one label deep), as the apps-off retire finds it: a company on
|
|
10872
|
+
// oikos.host has no `org.domain`, and the apps database's record lives in the platform zone.
|
|
10873
|
+
let hostname;
|
|
10874
|
+
try {
|
|
10875
|
+
hostname = JSON.parse(target.id).hostname;
|
|
10876
|
+
}
|
|
10877
|
+
catch {
|
|
10878
|
+
hostname = undefined;
|
|
10879
|
+
}
|
|
10880
|
+
if (typeof hostname !== 'string' || !hostname.includes('.'))
|
|
10881
|
+
return;
|
|
10882
|
+
const zone = await this.zonePort().findZoneByApex(hostname.slice(hostname.indexOf('.') + 1));
|
|
10883
|
+
if (!zone)
|
|
9391
10884
|
return;
|
|
9392
10885
|
const dns = this.dnsPort();
|
|
10886
|
+
// Only this company's own tunnel's record is deleted; a record that points anywhere else is not ours.
|
|
10887
|
+
const ownTunnel = recordedIds[`cloudflare:tunnel:${org.id}`];
|
|
9393
10888
|
for (const record of await dns.findDnsRecord(zone.name, hostname, 'CNAME')) {
|
|
9394
|
-
if (record.content.
|
|
10889
|
+
if (ownTunnel && record.content === `${ownTunnel}.cfargotunnel.com`) {
|
|
9395
10890
|
await dns.deleteDnsRecord(zone.name, record.id);
|
|
9396
10891
|
}
|
|
10892
|
+
else {
|
|
10893
|
+
console.info(`cloudflare:bg-dns for ${org.slug}: left the DNS record ${hostname} alone, it does not point at this company's tunnel`);
|
|
10894
|
+
}
|
|
9397
10895
|
}
|
|
9398
10896
|
return;
|
|
9399
10897
|
}
|
|
@@ -9415,6 +10913,26 @@ export class FoundryStepExecutor {
|
|
|
9415
10913
|
case 'coolify:database':
|
|
9416
10914
|
await this.ports.coolify.deleteDatabase(target.id);
|
|
9417
10915
|
return;
|
|
10916
|
+
case 'coolify:keys-registrar': {
|
|
10917
|
+
// F7: the company's registrar key at the keys service (live or never delivered).
|
|
10918
|
+
const keys = this.ports.keysRegistrar;
|
|
10919
|
+
if (!keys) {
|
|
10920
|
+
throw new Error(`coolify:keys-registrar for ${org.slug}: this executor was built without a KeysRegistrarPort; cannot revoke key ${target.id}`);
|
|
10921
|
+
}
|
|
10922
|
+
if (target.key === 'all') {
|
|
10923
|
+
// Every registrar key of the company, including one no recorded id points at.
|
|
10924
|
+
await keys.revokeAllCompanyKeys(org.id);
|
|
10925
|
+
return;
|
|
10926
|
+
}
|
|
10927
|
+
try {
|
|
10928
|
+
await keys.revokeCompanyKey(org.id, target.id);
|
|
10929
|
+
}
|
|
10930
|
+
catch (error) {
|
|
10931
|
+
if (!isNotFoundError(error))
|
|
10932
|
+
throw error;
|
|
10933
|
+
}
|
|
10934
|
+
return;
|
|
10935
|
+
}
|
|
9418
10936
|
case 'coolify:own-files-r2': {
|
|
9419
10937
|
// An own files bucket's storage key; a 404 is caught below as already gone.
|
|
9420
10938
|
const r2Keys = this.ports.ownServices?.provision.files.r2Keys;
|
|
@@ -9450,6 +10968,7 @@ export class FoundryStepExecutor {
|
|
|
9450
10968
|
return;
|
|
9451
10969
|
}
|
|
9452
10970
|
case 'zitadel:customers':
|
|
10971
|
+
case 'zitadel:customers-test':
|
|
9453
10972
|
case 'zitadel:org':
|
|
9454
10973
|
await this.ports.zitadel.deleteOrg(target.id);
|
|
9455
10974
|
return;
|