@wtfalch/foundry 0.76.1 → 0.77.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/app-canary-worker.d.ts +35 -0
- package/dist/app-canary-worker.js +56 -0
- package/dist/audit/connector.d.ts +6 -2
- package/dist/audit/connector.js +3 -2
- package/dist/audit/evidence.d.ts +4 -0
- package/dist/audit/evidence.js +14 -2
- package/dist/audit/host-store.d.ts +58 -0
- package/dist/audit/host-store.js +147 -0
- package/dist/audit/host.d.ts +53 -0
- package/dist/audit/host.js +294 -0
- package/dist/audit/index.d.ts +2 -0
- package/dist/audit/index.js +2 -0
- package/dist/audit/registry.d.ts +14 -0
- package/dist/audit/sealer-role.d.ts +12 -0
- package/dist/audit/sealer-role.js +26 -2
- package/dist/audit/tick.d.ts +10 -2
- package/dist/audit/tick.js +15 -2
- package/dist/catalogue/copy-en.js +5 -0
- package/dist/catalogue/integrations.js +5 -0
- package/dist/entry-rules.d.ts +35 -0
- package/dist/entry-rules.js +35 -0
- package/dist/hosted-label.js +2 -1
- package/dist/index.d.ts +11 -3
- package/dist/index.js +6 -1
- package/dist/migrations/0072_audit_host.sql +46 -0
- package/dist/migrations/0074_platform_app_canary.sql +11 -0
- package/dist/modules/ai.js +15 -1
- package/dist/modules/assistants.js +3 -0
- package/dist/modules/email.d.ts +6 -1
- package/dist/modules/email.js +88 -5
- package/dist/modules/files.js +79 -2
- package/dist/modules/index.js +7 -0
- package/dist/modules/jobs.d.ts +5 -1
- package/dist/modules/jobs.js +74 -4
- package/dist/modules/reporting.d.ts +9 -0
- package/dist/modules/reporting.js +29 -0
- package/dist/modules/sign-in.js +20 -2
- package/dist/modules/types.d.ts +2 -0
- package/dist/modules/workspaces.js +3 -0
- package/dist/org.d.ts +114 -13
- package/dist/org.js +233 -20
- package/dist/plan/ai-boule-key-apply.d.ts +65 -0
- package/dist/plan/ai-boule-key-apply.js +169 -0
- package/dist/plan/ai-boule-key.d.ts +89 -0
- package/dist/plan/ai-boule-key.js +195 -0
- package/dist/plan/analytics.d.ts +8 -1
- package/dist/plan/analytics.js +8 -1
- package/dist/plan/app-canary-apply.d.ts +49 -0
- package/dist/plan/app-canary-apply.js +232 -0
- package/dist/plan/app-canary.d.ts +44 -0
- package/dist/plan/app-canary.js +110 -0
- package/dist/plan/blue-green-apply.d.ts +6 -2
- package/dist/plan/blue-green-apply.js +14 -3
- package/dist/plan/broker-entries.js +20 -5
- package/dist/plan/company-backups.js +2 -0
- package/dist/plan/diff.d.ts +22 -0
- package/dist/plan/diff.js +231 -18
- package/dist/plan/entry-files-key.d.ts +39 -3
- package/dist/plan/entry-files-key.js +103 -6
- package/dist/plan/entry-jobs-boule-key-apply.d.ts +65 -0
- package/dist/plan/entry-jobs-boule-key-apply.js +192 -0
- package/dist/plan/entry-jobs-boule-key.d.ts +59 -0
- package/dist/plan/entry-jobs-boule-key.js +141 -0
- package/dist/plan/entry-jobs.d.ts +2 -0
- package/dist/plan/entry-reporting-key.d.ts +36 -0
- package/dist/plan/entry-reporting-key.js +136 -0
- package/dist/plan/executor.d.ts +108 -1
- package/dist/plan/executor.js +669 -23
- package/dist/plan/files-boule-key-apply.d.ts +73 -0
- package/dist/plan/files-boule-key-apply.js +223 -0
- package/dist/plan/files-boule-key.d.ts +92 -0
- package/dist/plan/files-boule-key.js +175 -0
- package/dist/plan/hostnames.js +4 -0
- package/dist/plan/index.d.ts +4 -0
- package/dist/plan/index.js +2 -0
- package/dist/plan/jobs-scheduler-http.d.ts +13 -0
- package/dist/plan/jobs-scheduler-http.js +39 -0
- package/dist/plan/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.d.ts +1 -1
- package/dist/plan/locked-hostnames.js +4 -1
- package/dist/plan/login-host.js +2 -2
- package/dist/plan/org-names.js +3 -0
- package/dist/plan/own-diff.js +3 -2
- package/dist/plan/own-jobs-route.d.ts +5 -2
- package/dist/plan/own-jobs-route.js +6 -2
- package/dist/plan/own-limits.d.ts +6 -0
- package/dist/plan/own-limits.js +1 -0
- package/dist/plan/plan-apply.d.ts +24 -0
- package/dist/plan/plan-apply.js +39 -4
- package/dist/plan/platform-diff.d.ts +2 -0
- package/dist/plan/platform-diff.js +15 -1
- package/dist/plan/platform-providers.d.ts +9 -0
- package/dist/plan/platform-providers.js +21 -0
- package/dist/plan/providers-readers.js +25 -1
- package/dist/plan/registered-app-env.d.ts +31 -0
- package/dist/plan/registered-app-env.js +63 -0
- package/dist/plan/registered-app.d.ts +121 -0
- package/dist/plan/registered-app.js +262 -0
- package/dist/plan/release-host.js +2 -1
- package/dist/plan/reporting-boule.d.ts +18 -0
- package/dist/plan/reporting-boule.js +44 -0
- package/dist/plan/reporting.d.ts +90 -0
- package/dist/plan/reporting.js +135 -0
- package/dist/plan/stage-env.js +1 -0
- package/dist/plan/stage-isolation.d.ts +3 -1
- package/dist/plan/stage-isolation.js +20 -2
- package/dist/plan/stage-services.d.ts +3 -1
- package/dist/plan/stage-services.js +8 -1
- package/dist/plan/stage-sink.d.ts +2 -2
- package/dist/plan/stage-sink.js +2 -2
- package/dist/plan/workspace-client.d.ts +1 -1
- package/dist/plan/workspace-client.js +2 -1
- package/dist/plan/workspace-sign-in.d.ts +1 -1
- package/dist/plan/workspace-sign-in.js +39 -7
- package/dist/platform.d.ts +25 -0
- package/dist/platform.js +20 -0
- package/dist/service-labels.d.ts +8 -0
- package/dist/service-labels.js +8 -0
- package/dist/slug.d.ts +1 -1
- package/dist/slug.js +5 -2
- package/dist/store/postgres.js +13 -0
- package/dist/store/schema.d.ts +485 -2
- package/dist/store/schema.js +38 -1
- package/dist/worker-app-config.js +2 -2
- package/package.json +1 -1
package/dist/plan/executor.js
CHANGED
|
@@ -6,17 +6,20 @@ import { coolifyImageFor } from '../bootstrap/versions.js';
|
|
|
6
6
|
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
7
7
|
import { resolveCallsSfuUrls } from '../calls-sfu.js';
|
|
8
8
|
import { NATIVE_BUILD_KEY_ENV } from '../native-build.js';
|
|
9
|
-
import { AI_ENTRY_PERMISSIONS, APPS_DOMAIN, APP_ENTRY_KEY, FILES_ENTRY_PERMISSIONS, 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';
|
|
9
|
+
import { AI_ENTRY_PERMISSIONS, APPS_DOMAIN, APP_ENTRY_KEY, FILES_ENTRY_PERMISSIONS, FILES_SETTINGS_ENV_NAMES, FULL_COMMIT_SHA, MAIL_APP_KEY_ENV, MAIL_SETTINGS_ENV_NAMES, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, appsOn, companyGithubFor, filesCollectionEnvName, heldEnvRef, isLiveEntry, isMainEntry, kosmosOn, mailAppKeyOf, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownsServices, sendsMail, sharedSecretRef, signsInBoth, signsInCustomers, } from '../org.js';
|
|
10
10
|
import { adoptedKeysUnreadable, parseAdoptSnapshot } from './adopt.js';
|
|
11
11
|
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';
|
|
12
|
+
import { ensureAiBouleKey } from './ai-boule-key-apply.js';
|
|
13
|
+
import { AI_KEY_PERMISSIONS_SCOPE_KEY, AI_KEY_PREVIOUS_SCOPE_KEY, AI_OFFERS_SCOPE_KEY, parseIds as parseAiIds, } from './ai-boule-key.js';
|
|
12
14
|
import { ANALYTICS_BOULE_APPLIED_SCOPE_KEY, ANALYTICS_BOULE_ENV_NAMES, analyticsBouleMarker, } from './analytics-boule.js';
|
|
13
|
-
import { ANALYTICS_DATABASE_SCOPE_KEY, ANALYTICS_DEPLOYED_SCOPE_KEY, ANALYTICS_OPERATOR_KEY_ENV, analyticsEnvDrift, analyticsHostnameFor, analyticsUrlFor, desiredAnalyticsEnv, } from './analytics.js';
|
|
15
|
+
import { ANALYTICS_DATABASE_SCOPE_KEY, ANALYTICS_DEPLOYED_SCOPE_KEY, ANALYTICS_OPERATOR_KEY_ENV, ANALYTICS_PERSON_KEY_BYTES, ANALYTICS_PERSON_KEY_ENV, analyticsEnvDrift, analyticsHostnameFor, analyticsUrlFor, desiredAnalyticsEnv, } from './analytics.js';
|
|
14
16
|
import { APPS_DATABASE_ADMIN_URL_ENV, applyAppsDbRoute, retireAppsDbRoute, } from './apps-db-route.js';
|
|
15
17
|
import { appsDbAdminUrl, provisionAppsDb } from './apps-db.js';
|
|
16
18
|
import { APPS_ZONES_APPLIED_SCOPE_KEY, applyAppsZones, retireAppsZones } from './apps-zones.js';
|
|
17
19
|
import { applyBlueGreenConnectors, applyBlueGreenServer, applyBlueGreenWeights, applyWorkspaceServer, } from './blue-green-apply.js';
|
|
18
20
|
import { actionsKeyName, bgKey, blueGreenSubjects, colorAppName, healthTokenName, liveAppNameFor, mintActionsKey, mintPinKey, parseGuards, pinKeyName, splitServers, } from './blue-green.js';
|
|
19
21
|
import { BouleOwnersError, httpBouleOwners } from './boule-owners-http.js';
|
|
22
|
+
import { registrarUrl } from './boule.js';
|
|
20
23
|
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_ROLES_SCOPE_KEY, CUSTOMERS_DIRECTORY_ROLES_VERSION, CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, CUSTOMERS_POLICY_SCOPE_KEY, CUSTOMERS_POLICY_VERSION, 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';
|
|
21
24
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, entryMcp, entryMcpProjectScopeKey, mcpCallbackUri, removeBrokerEntry, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
22
25
|
import { REGISTRAR_TOKEN_ORGS_SCOPE_KEY, REGISTRAR_TOKEN_USER_NAME, registrarOrgIdsOf, registrarOrgsMarker, } from './broker-registrar-token.js';
|
|
@@ -47,10 +50,13 @@ import { applyEntryAudit } from './entry-audit.js';
|
|
|
47
50
|
import { AUTH_EVENTS_ENV, authEventsAppliedScopeKey, authEventsMarker, authEventsWanted, ensureAuthEventsSecret, heldAuthEventsSecret, } from './entry-auth-events.js';
|
|
48
51
|
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';
|
|
49
52
|
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';
|
|
50
|
-
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';
|
|
53
|
+
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_PERMISSIONS_SCOPE_KEY, FILES_ROOT_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, bouleKeyBelow, entryFilesAppliedScopeKey, entryFilesCollectionsScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, knownCollectionNames, parseIdList as parseFilesIdList, } from './entry-files-key.js';
|
|
54
|
+
import { ensureJobsBouleKey, retireJobsBouleKey } from './entry-jobs-boule-key-apply.js';
|
|
51
55
|
import { entryJobsAppliedScopeKey, entryJobsRefs, entryJobsUndeliveredScopeKey, entryJobsWantMarker, } from './entry-jobs.js';
|
|
52
56
|
import { ensureRepoKey, removeRepoKey } from './entry-repo-key-apply.js';
|
|
57
|
+
import { ENTRY_REPORTING_KEY_DELIVERY_ATTEMPTS, entryReportingAppliedScopeKey, entryReportingRetiredScopeKey, entryReportingUndeliveredScopeKey, stageReportingKeyName, } from './entry-reporting-key.js';
|
|
53
58
|
import { BROKER_WORKSPACES_DATABASE_ENV, ensureWorkspacesKey, heldWorkspacesDatabase, heldWorkspacesKey, workspacesDatabaseRef, workspacesKeyHash, workspacesKeyRef, } from './entry-workspaces.js';
|
|
59
|
+
import { ensureFilesBouleKey, retireFilesBouleKey } from './files-boule-key-apply.js';
|
|
54
60
|
import { GATE_CLAIM_NAME, gateHostnamesScopeKey, gateIdRecordKey, isBehindGate } from './gates.js';
|
|
55
61
|
import { GITHUB_SOURCE_INSTALLATION_SCOPE_KEY, GITHUB_SOURCE_TIMEOUT_MS, ensureGithubSource, githubSourceResourceKey, } from './github-source.js';
|
|
56
62
|
import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, wgetProbe, } from './health-check-settings.js';
|
|
@@ -78,8 +84,12 @@ import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_
|
|
|
78
84
|
import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_AUTH_ORG_NAME, PLATFORM_AUTH_PROJECT, PLATFORM_MAIL_SERVER_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionDatabaseBackup, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
|
|
79
85
|
import { companyAppPostLogoutUris, postLogoutAppliedValue, postLogoutUrisFor, zitadelAppPostLogoutAppliedScopeKey, } from './post-logout-uris.js';
|
|
80
86
|
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';
|
|
87
|
+
import { liveRegisteredClientIds, registeredAppAppliedScopeKey, registeredAppEnvScopeKey, } from './registered-app-env.js';
|
|
88
|
+
import { REGISTERED_APP_KIND, copyClientKey, registeredCopies, sendRegistration, wantedRegisteredApp, } from './registered-app.js';
|
|
81
89
|
import { STAGE_DEPLOY_ATTEMPTS, STAGE_DEPLOY_INTERVAL_MS, createReleaseHost, } from './release-host.js';
|
|
82
90
|
import { TUNNEL_HTTP_INGRESS_SCOPE_KEY, applyRemoteDatabase, mergeTcpIngress, recordedTcpRoutes, } from './remote-database.js';
|
|
91
|
+
import { REPORTING_BOULE_APPLIED_SCOPE_KEY, REPORTING_BOULE_ENV_NAMES, reportingBouleMarker, } from './reporting-boule.js';
|
|
92
|
+
import { REPORTING_DATABASE_SCOPE_KEY, REPORTING_DEPLOYED_SCOPE_KEY, REPORTING_OPERATOR_KEY_ENV, desiredReportingEnv, reportingEnvDrift, reportingHostnameFor, reportingUrlFor, } from './reporting.js';
|
|
83
93
|
import { editSignInBindings } from './sign-in-bindings.js';
|
|
84
94
|
import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
|
|
85
95
|
import { SIGN_IN_BASE_PATH, SIGN_IN_ENV_LIVE_SCOPE_PREFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_RATE_LIMIT_DESCRIPTION, SIGN_IN_RATE_LIMIT_EXPRESSION, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, SIGN_IN_UPSTREAM_ENV, inAppBindingFor, inAppSignIn, inAppStateHosts, inAppStatePairs, inAppTargets, signInEnvLiveHosts, } from './sign-in.js';
|
|
@@ -416,6 +426,13 @@ export const KOSMOS_PORT = 3000;
|
|
|
416
426
|
export const ANALYTICS_REPO = 'wtfalch/analytics';
|
|
417
427
|
export const ANALYTICS_APP_DOCKERFILE = '/Dockerfile';
|
|
418
428
|
export const ANALYTICS_PORT = 3000;
|
|
429
|
+
/**
|
|
430
|
+
* The company's reporting backend (https://github.com/wtfalch/reporting, `apps/host`): root Dockerfile, serves
|
|
431
|
+
* on 3000 and answers `/api/health`, migrates in its own start script, as the analytics host does.
|
|
432
|
+
*/
|
|
433
|
+
export const REPORTING_REPO = 'wtfalch/reporting';
|
|
434
|
+
export const REPORTING_APP_DOCKERFILE = '/Dockerfile';
|
|
435
|
+
export const REPORTING_PORT = 3000;
|
|
419
436
|
/**
|
|
420
437
|
* Every app foundry deploys keeps its own logs across a redeploy, so a
|
|
421
438
|
* database outage does not also erase the only other copy of the evidence
|
|
@@ -965,6 +982,7 @@ function limitProfileOf(action) {
|
|
|
965
982
|
return undefined;
|
|
966
983
|
case 'coolify:broker-entry':
|
|
967
984
|
case 'coolify:workspace-broker-entry':
|
|
985
|
+
case `coolify:${REGISTERED_APP_KIND}`:
|
|
968
986
|
case 'coolify:workspaces-database':
|
|
969
987
|
return LIMITS.brokerEntry;
|
|
970
988
|
case 'cms:site':
|
|
@@ -985,6 +1003,8 @@ function limitProfileOf(action) {
|
|
|
985
1003
|
return LIMITS.kosmos;
|
|
986
1004
|
case 'coolify:analytics':
|
|
987
1005
|
return LIMITS.analytics;
|
|
1006
|
+
case 'coolify:reporting':
|
|
1007
|
+
return LIMITS.reporting;
|
|
988
1008
|
case 'coolify:broker-registrar':
|
|
989
1009
|
return LIMITS.brokerEntry;
|
|
990
1010
|
case 'coolify:keys-registrar':
|
|
@@ -1372,6 +1392,8 @@ export class FoundryStepExecutor {
|
|
|
1372
1392
|
return this.coolifyBrokerEntry(action, org, context);
|
|
1373
1393
|
case 'coolify:workspace-broker-entry':
|
|
1374
1394
|
return this.coolifyWorkspaceBrokerEntry(action, org, context);
|
|
1395
|
+
case `coolify:${REGISTERED_APP_KIND}`:
|
|
1396
|
+
return this.coolifyRegisteredApp(action, org, context);
|
|
1375
1397
|
case 'coolify:workspaces-database':
|
|
1376
1398
|
return this.coolifyWorkspacesDatabase(org, context);
|
|
1377
1399
|
case 'coolify:database':
|
|
@@ -1417,13 +1439,17 @@ export class FoundryStepExecutor {
|
|
|
1417
1439
|
}
|
|
1418
1440
|
case 'files:organisation':
|
|
1419
1441
|
return this.filesOrganisation(action, org, context);
|
|
1442
|
+
case 'files:boule-key':
|
|
1443
|
+
return this.filesBouleKey(action, org, context);
|
|
1420
1444
|
case 'files:key':
|
|
1421
1445
|
// The org's own key is keyed `<orgId>`, an entry's `<orgId>:<entry>` (F6b).
|
|
1422
1446
|
return action.key.includes(':')
|
|
1423
1447
|
? this.filesEntryKey(action, org, context)
|
|
1424
1448
|
: this.filesRootKey(action, org, context);
|
|
1425
1449
|
case 'ai:organisation':
|
|
1426
|
-
return this.aiOrganisation(action, org);
|
|
1450
|
+
return this.aiOrganisation(action, org, context);
|
|
1451
|
+
case 'ai:boule-key':
|
|
1452
|
+
return this.aiBouleKey(action, org, context);
|
|
1427
1453
|
case 'ai:budget':
|
|
1428
1454
|
return this.aiBudget(action, org, context);
|
|
1429
1455
|
case 'ai:key':
|
|
@@ -1439,6 +1465,8 @@ export class FoundryStepExecutor {
|
|
|
1439
1465
|
return this.edgeSlug(action, org, context);
|
|
1440
1466
|
case 'jobs:project':
|
|
1441
1467
|
return this.jobsProject(action, org, context);
|
|
1468
|
+
case 'jobs:boule-key':
|
|
1469
|
+
return this.jobsBouleKey(action, org, context);
|
|
1442
1470
|
case 'github:deploy-key':
|
|
1443
1471
|
return this.githubDeployKey(action, org, context);
|
|
1444
1472
|
case 'audit:ledger':
|
|
@@ -1469,6 +1497,12 @@ export class FoundryStepExecutor {
|
|
|
1469
1497
|
return this.analyticsBoule(action, org, context);
|
|
1470
1498
|
case 'analytics:key':
|
|
1471
1499
|
return this.analyticsEntryKey(action, org, context);
|
|
1500
|
+
case 'coolify:reporting':
|
|
1501
|
+
return this.coolifyReporting(action, org, context);
|
|
1502
|
+
case 'coolify:reporting-boule':
|
|
1503
|
+
return this.reportingBoule(action, org, context);
|
|
1504
|
+
case 'reporting:key':
|
|
1505
|
+
return this.reportingEntryKey(action, org, context);
|
|
1472
1506
|
case 'coolify:broker-registrar':
|
|
1473
1507
|
return this.brokerRegistrar(org, context);
|
|
1474
1508
|
case 'coolify:keys-registrar':
|
|
@@ -3460,7 +3494,7 @@ export class FoundryStepExecutor {
|
|
|
3460
3494
|
isRunning: isRunningOrStarting,
|
|
3461
3495
|
stageGuards: this.stageGuards(org, subject, recorded(), action.desired.accessProviderId, context),
|
|
3462
3496
|
guardInputs: parseGuards(action.desired.guards),
|
|
3463
|
-
stageSink:
|
|
3497
|
+
stageSink: stageSinkFor(org, subject.builtin ? undefined : subject.id),
|
|
3464
3498
|
};
|
|
3465
3499
|
if (action.kind === 'bluegreen-lb') {
|
|
3466
3500
|
await applyBlueGreenWeights(deps, subject, { uuid: serverUuid, primaryUuid });
|
|
@@ -3520,7 +3554,7 @@ export class FoundryStepExecutor {
|
|
|
3520
3554
|
logDir: LOG_DIR,
|
|
3521
3555
|
isRunning: isRunningOrStarting,
|
|
3522
3556
|
stageGuards: guards,
|
|
3523
|
-
stageSink:
|
|
3557
|
+
stageSink: stageSinkFor(org, entry.key),
|
|
3524
3558
|
stageAppUrl: `https://${host}`,
|
|
3525
3559
|
...(signIn ? { signIn } : {}),
|
|
3526
3560
|
}, subject, serverUuid);
|
|
@@ -3599,13 +3633,14 @@ export class FoundryStepExecutor {
|
|
|
3599
3633
|
const liveMcp = org.hosting.entries.filter((live) => live.uses?.auth?.mcp);
|
|
3600
3634
|
return {
|
|
3601
3635
|
env,
|
|
3602
|
-
customers: auth
|
|
3636
|
+
customers: signsInCustomers(auth),
|
|
3603
3637
|
redirectUri: `https://${workspaceClientHosts(org, entry)[0]}/auth/callback`,
|
|
3604
3638
|
live: {
|
|
3605
3639
|
...(recorded[`zitadel:customers:${org.id}`]
|
|
3606
3640
|
? { customersOrgId: recorded[`zitadel:customers:${org.id}`] }
|
|
3607
3641
|
: {}),
|
|
3608
3642
|
clientIds: liveClientIds(org, recorded),
|
|
3643
|
+
teamClientIds: liveRegisteredClientIds(org, recorded).teamClientIds,
|
|
3609
3644
|
workspacesKeys,
|
|
3610
3645
|
mcpAudiences: liveMcp.flatMap((m) => recorded[entryMcpProjectScopeKey(m.key)] ?? []),
|
|
3611
3646
|
mcpResources: liveMcp.flatMap((m) => entryMcp(org, m, recorded)?.resource ?? []),
|
|
@@ -3646,6 +3681,101 @@ export class FoundryStepExecutor {
|
|
|
3646
3681
|
});
|
|
3647
3682
|
return {};
|
|
3648
3683
|
}
|
|
3684
|
+
/**
|
|
3685
|
+
* `coolify:registered-app` (`plan/registered-app.ts`, ADR 0003): one copy of an entry whose customers and team
|
|
3686
|
+
* both sign in, registered at the sign-in broker through the company's registrar with
|
|
3687
|
+
* `users: ['customers', 'staff']`. The answer (the customers' and the team's clients, both organisations and a
|
|
3688
|
+
* new `AUTH_APP_KEY`) goes to the copy's app (the entry's app, or the workspace's stage), which restarts. The
|
|
3689
|
+
* ids are recorded after the env is written, the want last, so a failure anywhere registers again on the next
|
|
3690
|
+
* apply (the broker keeps the key it replaced valid for a window). The registrar key and the app key are
|
|
3691
|
+
* registered for redaction and never recorded; errors name the copy and a status at most.
|
|
3692
|
+
*/
|
|
3693
|
+
async coolifyRegisteredApp(action, org, context) {
|
|
3694
|
+
const at = `coolify:${REGISTERED_APP_KIND} for ${org.slug}`;
|
|
3695
|
+
const api = this.ports.registrarApi;
|
|
3696
|
+
if (!api)
|
|
3697
|
+
throw new Error(`${at}: the registrar API port is not wired`);
|
|
3698
|
+
const copy = action.desired.copy;
|
|
3699
|
+
const entry = org.hosting.entries.find((e) => e.key === String(action.desired.entry ?? '') && signsInBoth(e.uses?.auth));
|
|
3700
|
+
if (!entry || !registeredCopies(entry).includes(copy)) {
|
|
3701
|
+
throw new Error(`${at}: no entry both sign in to for ${action.key}`);
|
|
3702
|
+
}
|
|
3703
|
+
const authIssuer = action.desired.authIssuer ?? null;
|
|
3704
|
+
if (!authIssuer)
|
|
3705
|
+
throw new Error(`${at}: the sign-in issuer is not known yet`);
|
|
3706
|
+
const recorded = context.recordedIds();
|
|
3707
|
+
const wanted = wantedRegisteredApp(org, entry, copy, recorded);
|
|
3708
|
+
if ('missing' in wanted) {
|
|
3709
|
+
throw new Error(`${at}: ${wanted.missing.map((m) => m.what).join(', ')} not recorded yet`);
|
|
3710
|
+
}
|
|
3711
|
+
const clientKey = copyClientKey(entry, copy);
|
|
3712
|
+
let appId;
|
|
3713
|
+
if (copy === 'live') {
|
|
3714
|
+
appId = recorded[`coolify:app:${appStepKey(org, entry.key)}`];
|
|
3715
|
+
}
|
|
3716
|
+
else {
|
|
3717
|
+
const stagePrefix = `bg.${entry.key}.app.`;
|
|
3718
|
+
appId = Object.entries(recorded).find(([key, id]) => id && key.startsWith(stagePrefix) && key.endsWith('.stage'))?.[1];
|
|
3719
|
+
}
|
|
3720
|
+
if (!appId)
|
|
3721
|
+
throw new Error(`${at}: the app of ${clientKey} is not recorded yet`);
|
|
3722
|
+
const held = this.ports.heldKeys;
|
|
3723
|
+
const ref = registrarRef(org.id);
|
|
3724
|
+
const registrarKey = held
|
|
3725
|
+
? await withLabelledTimeout('held key read', HELD_KEY_READ_TIMEOUT_MS, () => held.resolve(ref)).catch(() => {
|
|
3726
|
+
throw new Error(`${at}: the registrar key could not be read (ref ${ref})`);
|
|
3727
|
+
})
|
|
3728
|
+
: null;
|
|
3729
|
+
if (!registrarKey)
|
|
3730
|
+
throw new Error(`${at}: the registrar key is not held (ref ${ref})`);
|
|
3731
|
+
this.handledSecrets.add(registrarKey);
|
|
3732
|
+
const { env, appKey } = await sendRegistration({
|
|
3733
|
+
want: wanted.want,
|
|
3734
|
+
registrarUrl: registrarUrl(authIssuer),
|
|
3735
|
+
registrarKey,
|
|
3736
|
+
api,
|
|
3737
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
3738
|
+
// A workspace copy must hold none of the live app's clients.
|
|
3739
|
+
refusedClientIds: copy === 'workspace' ? liveClientIds(org, recorded) : [],
|
|
3740
|
+
label: `${at} (${clientKey})`,
|
|
3741
|
+
});
|
|
3742
|
+
const envs = [
|
|
3743
|
+
{ key: 'AUTH_ISSUER', value: authIssuer },
|
|
3744
|
+
{ key: 'AUTH_ORGANIZATION_ID', value: env.AUTH_ORGANIZATION_ID },
|
|
3745
|
+
{ key: 'AUTH_CLIENT_ID', value: env.AUTH_CLIENT_ID },
|
|
3746
|
+
{ key: 'AUTH_TEAM_CLIENT_ID', value: env.AUTH_TEAM_CLIENT_ID },
|
|
3747
|
+
{ key: 'AUTH_TEAM_ORGANIZATION_ID', value: env.AUTH_TEAM_ORGANIZATION_ID },
|
|
3748
|
+
{ key: 'AUTH_APP_KEY', value: appKey },
|
|
3749
|
+
];
|
|
3750
|
+
let written = false;
|
|
3751
|
+
for (let attempt = 0; attempt < 3 && !written; attempt++) {
|
|
3752
|
+
try {
|
|
3753
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, envs);
|
|
3754
|
+
written = true;
|
|
3755
|
+
}
|
|
3756
|
+
catch {
|
|
3757
|
+
// Tried again below; the error that leaves names the copy only.
|
|
3758
|
+
}
|
|
3759
|
+
}
|
|
3760
|
+
if (!written) {
|
|
3761
|
+
throw new Error(`${at}: the sign-in env of ${clientKey} could not be written; the next apply registers it again`);
|
|
3762
|
+
}
|
|
3763
|
+
await this.ports.coolify.restart(appId);
|
|
3764
|
+
context.recordScopeId(registeredAppEnvScopeKey(clientKey), JSON.stringify(env));
|
|
3765
|
+
// What the app step and the workspace step compare against, so neither pushes the same env again.
|
|
3766
|
+
const now = context.recordedIds();
|
|
3767
|
+
if (copy === 'live') {
|
|
3768
|
+
context.recordScopeId(entryAuthEnvAppliedScopeKey(entry.key), JSON.stringify(entryAuthEnv(org, entry, now, authIssuer)));
|
|
3769
|
+
}
|
|
3770
|
+
else {
|
|
3771
|
+
const marker = workspaceSignInMarker(org, entry, now, authIssuer);
|
|
3772
|
+
if (marker)
|
|
3773
|
+
context.recordScopeId(wsKey.signIn(entry.key), marker);
|
|
3774
|
+
}
|
|
3775
|
+
context.recordScopeId(registeredAppAppliedScopeKey(clientKey), JSON.stringify(wanted.want));
|
|
3776
|
+
await context.persistScopeIds();
|
|
3777
|
+
return {};
|
|
3778
|
+
}
|
|
3649
3779
|
/**
|
|
3650
3780
|
* `coolify:workspaces-database` (`plan/broker-workspaces-database.ts`): the broker's workspaces database, made
|
|
3651
3781
|
* beside the broker (the platform's project and network, or the company's own for an org that owns its services)
|
|
@@ -3984,8 +4114,43 @@ export class FoundryStepExecutor {
|
|
|
3984
4114
|
};
|
|
3985
4115
|
},
|
|
3986
4116
|
};
|
|
4117
|
+
const reporting = {
|
|
4118
|
+
held: held('reporting'),
|
|
4119
|
+
available: async () => canHold &&
|
|
4120
|
+
Boolean(this.ports.reporting) &&
|
|
4121
|
+
Boolean(context.recordedIds()[`coolify:reporting:${org.id}`]) &&
|
|
4122
|
+
entry?.uses?.reporting === true,
|
|
4123
|
+
create: async () => {
|
|
4124
|
+
const port = this.ports.reporting;
|
|
4125
|
+
if (!port)
|
|
4126
|
+
throw new Error('no reporting port');
|
|
4127
|
+
const operatorKey = await this.reportingOperatorKey(org, context);
|
|
4128
|
+
const name = stageReportingKeyName(org, subject.id);
|
|
4129
|
+
// Site = the entry key, as the live key's: Boule reads the workspace's errors with environment `stage`.
|
|
4130
|
+
const minted = await port.mintSiteKey({
|
|
4131
|
+
baseUrl: reportingUrlFor(org),
|
|
4132
|
+
operatorKey,
|
|
4133
|
+
name,
|
|
4134
|
+
site: subject.id,
|
|
4135
|
+
environment: 'stage',
|
|
4136
|
+
});
|
|
4137
|
+
return {
|
|
4138
|
+
id: minted.id,
|
|
4139
|
+
name,
|
|
4140
|
+
keyIds: [minted.id],
|
|
4141
|
+
secrets: [{ env: 'REPORTING_SITE_KEY', value: minted.secret }],
|
|
4142
|
+
};
|
|
4143
|
+
},
|
|
4144
|
+
};
|
|
3987
4145
|
const prodJobsProjectId = context.recordedIds()[`jobs:registration:${org.id}`];
|
|
3988
|
-
return {
|
|
4146
|
+
return {
|
|
4147
|
+
files,
|
|
4148
|
+
ai,
|
|
4149
|
+
jobs,
|
|
4150
|
+
analytics,
|
|
4151
|
+
reporting,
|
|
4152
|
+
...(prodJobsProjectId ? { prodJobsProjectId } : {}),
|
|
4153
|
+
};
|
|
3989
4154
|
}
|
|
3990
4155
|
/**
|
|
3991
4156
|
* Stage's own sign-in (`plan/stage-signin.ts`): a client named `<production client>-stage` on production's
|
|
@@ -5027,6 +5192,11 @@ export class FoundryStepExecutor {
|
|
|
5027
5192
|
// #75 "What belongs where") once `ensureLogVolume` has mounted the
|
|
5028
5193
|
// matching volume.
|
|
5029
5194
|
{ key: 'LOG_DIR', value: LOG_DIR },
|
|
5195
|
+
// An entry that sends mail tags each send with its key (`@wtfalch/mailer` 0.10.0), so Archon's mail reads
|
|
5196
|
+
// split mail per app. Not in the appEnv marker: `planApp` plans it from the live env names, once.
|
|
5197
|
+
...(sendsMail(entry)
|
|
5198
|
+
? [{ key: MAIL_APP_KEY_ENV, value: mailAppKeyOf(entry.key, 'live') }]
|
|
5199
|
+
: []),
|
|
5030
5200
|
];
|
|
5031
5201
|
// An entry with `uses.auth` signs in one organisation, with its own client
|
|
5032
5202
|
// and the issuer; any other entry keeps the org's staff organisation and
|
|
@@ -5035,7 +5205,7 @@ export class FoundryStepExecutor {
|
|
|
5035
5205
|
if (entry.uses?.auth) {
|
|
5036
5206
|
// A customers entry never falls back to the staff organisation's id.
|
|
5037
5207
|
if (!authEnv.AUTH_ORGANIZATION_ID) {
|
|
5038
|
-
throw new Error(`coolify:app for ${org.slug}: entry '${entry.key}' signs in ${entry.uses.auth
|
|
5208
|
+
throw new Error(`coolify:app for ${org.slug}: entry '${entry.key}' signs in ${signsInCustomers(entry.uses.auth) ? 'customers' : 'staff'}, whose organisation id is not recorded yet`);
|
|
5039
5209
|
}
|
|
5040
5210
|
for (const [key, value] of Object.entries(authEnv))
|
|
5041
5211
|
envs.push({ key, value });
|
|
@@ -5135,7 +5305,14 @@ export class FoundryStepExecutor {
|
|
|
5135
5305
|
const live = await this.ports.coolify.listEnvs(appId);
|
|
5136
5306
|
heldChanged = heldEnvs.some(({ key, value }) => !live.some((e) => e.key === key && !e.is_preview && e.value === value));
|
|
5137
5307
|
}
|
|
5308
|
+
// `MAIL_APP_KEY` is not in the marker either: a running app that lacks it, or holds another, restarts once.
|
|
5309
|
+
let mailKeyChanged = false;
|
|
5310
|
+
if (sendsMail(entry)) {
|
|
5311
|
+
const want = mailAppKeyOf(entry.key, 'live');
|
|
5312
|
+
mailKeyChanged = !(await this.ports.coolify.listEnvs(appId)).some((e) => e.key === MAIL_APP_KEY_ENV && !e.is_preview && e.value === want);
|
|
5313
|
+
}
|
|
5138
5314
|
await this.ports.coolify.bulkUpsertEnvs(appId, envs);
|
|
5315
|
+
await this.dropUnsetSettingsEnv(appId, entry, recorded, resolved.marker);
|
|
5139
5316
|
// `coolifyApp` records `applied` once the restart succeeded, so `planApp`
|
|
5140
5317
|
// replans this step when the declared map changes (rule 3). Ids and a
|
|
5141
5318
|
// domain, never a secret.
|
|
@@ -5146,6 +5323,7 @@ export class FoundryStepExecutor {
|
|
|
5146
5323
|
return {
|
|
5147
5324
|
changed: applied !== (recorded[appEnvAppliedScopeKey(entry.key)] ?? '{}') ||
|
|
5148
5325
|
heldChanged ||
|
|
5326
|
+
mailKeyChanged ||
|
|
5149
5327
|
(Boolean(entry.uses?.auth) &&
|
|
5150
5328
|
(authApplied !== (recorded[entryAuthEnvAppliedScopeKey(entry.key)] ?? '{}') ||
|
|
5151
5329
|
authEventsMarker(entry) !== (recorded[authEventsAppliedScopeKey(entry.key)] ?? ''))),
|
|
@@ -5154,6 +5332,37 @@ export class FoundryStepExecutor {
|
|
|
5154
5332
|
eventsApplied: authEventsMarker(entry),
|
|
5155
5333
|
};
|
|
5156
5334
|
}
|
|
5335
|
+
/**
|
|
5336
|
+
* #1116: takes a module setting's env name (`MAIL_REPLY_TO`, ...) out of the app's env once the setting is
|
|
5337
|
+
* unset or its module is off. Only a name the last applied env wrote (the recorded marker) and the entry's
|
|
5338
|
+
* own `env` does not declare, so a value the company set by hand stays.
|
|
5339
|
+
*/
|
|
5340
|
+
async dropUnsetSettingsEnv(appId, entry, recorded, marker) {
|
|
5341
|
+
let last = {};
|
|
5342
|
+
try {
|
|
5343
|
+
const parsed = JSON.parse(recorded[appEnvAppliedScopeKey(entry.key)] ?? '{}');
|
|
5344
|
+
if (parsed && typeof parsed === 'object')
|
|
5345
|
+
last = parsed;
|
|
5346
|
+
}
|
|
5347
|
+
catch {
|
|
5348
|
+
return;
|
|
5349
|
+
}
|
|
5350
|
+
const gone = [...FILES_SETTINGS_ENV_NAMES, ...MAIL_SETTINGS_ENV_NAMES].filter((name) => name in last && !(name in marker) && !(name in (entry.env ?? {})));
|
|
5351
|
+
if (gone.length === 0)
|
|
5352
|
+
return;
|
|
5353
|
+
const rows = await this.ports.coolify.listEnvs(appId);
|
|
5354
|
+
for (const row of rows) {
|
|
5355
|
+
if (!gone.includes(row.key) || !row.uuid)
|
|
5356
|
+
continue;
|
|
5357
|
+
try {
|
|
5358
|
+
await this.ports.coolify.deleteEnv(appId, row.uuid);
|
|
5359
|
+
}
|
|
5360
|
+
catch (error) {
|
|
5361
|
+
if (!isNotFoundError(error))
|
|
5362
|
+
throw error;
|
|
5363
|
+
}
|
|
5364
|
+
}
|
|
5365
|
+
}
|
|
5157
5366
|
/** Takes `AUTH_EVENTS_SECRET` out of the app's env once the entry stops declaring events. The held value stays: the held store has no delete. */
|
|
5158
5367
|
async dropAuthEventsEnv(appId) {
|
|
5159
5368
|
const rows = await this.ports.coolify.listEnvs(appId);
|
|
@@ -5416,12 +5625,12 @@ export class FoundryStepExecutor {
|
|
|
5416
5625
|
* third, bare copy of this check (foundry#970, thermo note 1 in #957). Omitted, the three existing
|
|
5417
5626
|
* callers below keep their original behaviour.
|
|
5418
5627
|
*/
|
|
5419
|
-
async mintIfAbsent(appId, key, bytes, minLength) {
|
|
5628
|
+
async mintIfAbsent(appId, key, bytes, minLength, encoding = 'base64url') {
|
|
5420
5629
|
const envs = await this.ports.coolify.listEnvs(appId);
|
|
5421
5630
|
const already = envs.some((e) => e.key === key &&
|
|
5422
5631
|
!e.is_preview &&
|
|
5423
5632
|
(minLength === undefined || (e.value ?? '').length >= minLength));
|
|
5424
|
-
return already ? null : randomBytes(bytes).toString(
|
|
5633
|
+
return already ? null : randomBytes(bytes).toString(encoding);
|
|
5425
5634
|
}
|
|
5426
5635
|
/**
|
|
5427
5636
|
* foundry#987: nothing reads `NATIVE_BUILD_KEY` any more -- Boule calls Archon's org build
|
|
@@ -5629,6 +5838,8 @@ export class FoundryStepExecutor {
|
|
|
5629
5838
|
add(recorded[`coolify:app:${appStepKey(org, entry.key)}`]);
|
|
5630
5839
|
}
|
|
5631
5840
|
}
|
|
5841
|
+
// The company's reporting backend sends its alerts with the same token (foundry#1128).
|
|
5842
|
+
add(recorded[`coolify:reporting:${org.id}`]);
|
|
5632
5843
|
add(self.id);
|
|
5633
5844
|
const envsOf = (id) => id === self.id
|
|
5634
5845
|
? this.ports.coolify.listEnvs(id)
|
|
@@ -5669,6 +5880,7 @@ export class FoundryStepExecutor {
|
|
|
5669
5880
|
allProjects = [...projects, created.project];
|
|
5670
5881
|
token = created.token;
|
|
5671
5882
|
}
|
|
5883
|
+
this.handledSecrets.add(token);
|
|
5672
5884
|
// A healthy company: this step writes only its own env; a peer that lacks
|
|
5673
5885
|
// the token gets it in its own step. A re-mint kills the old token, so it
|
|
5674
5886
|
// reaches every live consumer here.
|
|
@@ -8076,7 +8288,9 @@ export class FoundryStepExecutor {
|
|
|
8076
8288
|
*
|
|
8077
8289
|
* `ANALYTICS_OPERATOR_KEY` is made once here (`mintIfAbsent`) and written only to this app's env. No outside
|
|
8078
8290
|
* service shows it once, so no undelivered marker is needed: a failed write leaves nothing live to mint twice.
|
|
8079
|
-
* Steps that call the backend's operator API read it back from this env.
|
|
8291
|
+
* Steps that call the backend's operator API read it back from this env. `ANALYTICS_PERSON_KEY` (64 hex
|
|
8292
|
+
* characters, analytics#109: the per-site key that counts a signed-in person once) is made the same way and
|
|
8293
|
+
* never rotated: a value already in the env, made here or by hand, is kept.
|
|
8080
8294
|
*
|
|
8081
8295
|
* A brand new app is deployed once ({@link ANALYTICS_DEPLOYED_SCOPE_KEY}), as for Boule (#504).
|
|
8082
8296
|
*/
|
|
@@ -8122,13 +8336,20 @@ export class FoundryStepExecutor {
|
|
|
8122
8336
|
this.handledSecrets.add(operatorKey);
|
|
8123
8337
|
envs.push({ key: ANALYTICS_OPERATOR_KEY_ENV, value: operatorKey });
|
|
8124
8338
|
}
|
|
8339
|
+
// foundry#1137: rotating this key would count every signed-in person twice, so a held value is kept.
|
|
8340
|
+
const personKey = await this.mintIfAbsent(app.appId, ANALYTICS_PERSON_KEY_ENV, ANALYTICS_PERSON_KEY_BYTES, undefined, 'hex');
|
|
8341
|
+
if (personKey) {
|
|
8342
|
+
this.handledSecrets.add(personKey);
|
|
8343
|
+
envs.push({ key: ANALYTICS_PERSON_KEY_ENV, value: personKey });
|
|
8344
|
+
}
|
|
8345
|
+
const minted = [operatorKey, personKey].filter((k) => k !== null);
|
|
8125
8346
|
try {
|
|
8126
8347
|
await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
|
|
8127
8348
|
}
|
|
8128
8349
|
catch (error) {
|
|
8129
|
-
if (
|
|
8350
|
+
if (minted.length === 0)
|
|
8130
8351
|
throw error;
|
|
8131
|
-
throw new Error(redactSecrets(error instanceof Error ? error.message : String(error),
|
|
8352
|
+
throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), minted));
|
|
8132
8353
|
}
|
|
8133
8354
|
const dbPush = await this.pushDatabaseUrl(app.appId, dbId, dbName);
|
|
8134
8355
|
if (!dbPush.pushed) {
|
|
@@ -8140,7 +8361,7 @@ export class FoundryStepExecutor {
|
|
|
8140
8361
|
await recordBuiltCommit(this.ports.coolify, { appId: app.appId, deploymentUuid: built.deploymentUuid, name: 'analytics' }, (key, value) => context.recordScopeId(key, value));
|
|
8141
8362
|
context.recordScopeId(ANALYTICS_DEPLOYED_SCOPE_KEY, '1');
|
|
8142
8363
|
}
|
|
8143
|
-
else if (app.existed && (envChanged ||
|
|
8364
|
+
else if (app.existed && (envChanged || minted.length > 0 || dbPush.changed)) {
|
|
8144
8365
|
await this.ports.coolify.restart(app.appId);
|
|
8145
8366
|
}
|
|
8146
8367
|
return { resourceId: app.appId };
|
|
@@ -8314,6 +8535,264 @@ export class FoundryStepExecutor {
|
|
|
8314
8535
|
}
|
|
8315
8536
|
throw new Error(`${at}: key ${minted.id} was minted but could not be written to the app's env after ${ENTRY_ANALYTICS_KEY_DELIVERY_ATTEMPTS} attempts (${lastError instanceof Error ? lastError.name : 'error'})`);
|
|
8316
8537
|
}
|
|
8538
|
+
// ---- reporting backend (wtfalch/reporting ADR 0002) ----------------------
|
|
8539
|
+
/**
|
|
8540
|
+
* `plan/diff.ts`'s `planReportingBackend`: the company's reporting backend as one more Coolify application
|
|
8541
|
+
* from https://github.com/wtfalch/reporting's root Dockerfile ({@link REPORTING_REPO}), with a database of its
|
|
8542
|
+
* own, a copy of {@link coolifyAnalytics}. Its start script migrates as the owner role and then serves as the
|
|
8543
|
+
* runtime role, so `pushDatabaseUrl` gives it both URLs. It needs no sign-in client: it reads only
|
|
8544
|
+
* `AUTH_ORGANIZATION_ID` (the tenant id derives from it), `APP_URL` and the operator key.
|
|
8545
|
+
*
|
|
8546
|
+
* `REPORTING_OPERATOR_KEY` is made once here (`mintIfAbsent`) and written only to this app's env. No outside
|
|
8547
|
+
* service shows it once, so no undelivered marker is needed: a failed write leaves nothing live to mint twice.
|
|
8548
|
+
* Steps that call the backend's operator API read it back from this env.
|
|
8549
|
+
*
|
|
8550
|
+
* A brand new app is deployed once ({@link REPORTING_DEPLOYED_SCOPE_KEY}), as for Boule (#504).
|
|
8551
|
+
*/
|
|
8552
|
+
async coolifyReporting(action, org, context) {
|
|
8553
|
+
// Mail only when the plan showed it (`desired.mail`), from the recorded domain.
|
|
8554
|
+
const mailDomain = action.desired.mail === true ? (context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY] ?? null) : null;
|
|
8555
|
+
const { env, missing } = desiredReportingEnv(org, context.recordedIds(), mailDomain);
|
|
8556
|
+
if (missing.length > 0) {
|
|
8557
|
+
throw new Error(`the reporting backend for ${org.slug} cannot be configured: missing ${missing.join(', ')}. Nothing was written.`);
|
|
8558
|
+
}
|
|
8559
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
8560
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
8561
|
+
const name = `${org.slug}-reporting`;
|
|
8562
|
+
const dbName = `${org.slug}-reporting-db`.replace(/-/g, '_');
|
|
8563
|
+
const dbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, `${org.slug}-reporting-db`, dbName, REPORTING_DATABASE_SCOPE_KEY);
|
|
8564
|
+
context.recordScopeId(REPORTING_DATABASE_SCOPE_KEY, dbId);
|
|
8565
|
+
const server = await this.resolveServer(org, context);
|
|
8566
|
+
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
8567
|
+
const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
8568
|
+
is_preview_deployments_enabled: false,
|
|
8569
|
+
project_uuid: projectUuid,
|
|
8570
|
+
server_uuid: server.uuid,
|
|
8571
|
+
environment_name: environment.name,
|
|
8572
|
+
environment_uuid: environment.uuid,
|
|
8573
|
+
github_app_uuid: githubApp.uuid,
|
|
8574
|
+
git_repository: REPORTING_REPO,
|
|
8575
|
+
git_branch: 'main',
|
|
8576
|
+
build_pack: 'dockerfile',
|
|
8577
|
+
name,
|
|
8578
|
+
domains: coolifyTunnelDomains([reportingHostnameFor(org)]),
|
|
8579
|
+
dockerfile_location: REPORTING_APP_DOCKERFILE,
|
|
8580
|
+
}));
|
|
8581
|
+
await this.ensureLogVolume(app.appId, org, app.existed);
|
|
8582
|
+
await this.ports.coolify.updateApplication(app.appId, {
|
|
8583
|
+
...healthCheckSettings('/api/health', REPORTING_PORT),
|
|
8584
|
+
...(await this.routedLabels(name, reportingHostnameFor(org), REPORTING_PORT)),
|
|
8585
|
+
});
|
|
8586
|
+
const liveEnv = Object.fromEntries((await this.ports.coolify.listEnvs(app.appId))
|
|
8587
|
+
.filter((e) => !e.is_preview)
|
|
8588
|
+
.map((e) => [e.key, e.value]));
|
|
8589
|
+
const envChanged = reportingEnvDrift(env, liveEnv).length > 0;
|
|
8590
|
+
const envs = Object.entries(env).map(([key, value]) => ({ key, value }));
|
|
8591
|
+
const operatorKey = await this.mintIfAbsent(app.appId, REPORTING_OPERATOR_KEY_ENV, 32);
|
|
8592
|
+
if (operatorKey) {
|
|
8593
|
+
this.handledSecrets.add(operatorKey);
|
|
8594
|
+
envs.push({ key: REPORTING_OPERATOR_KEY_ENV, value: operatorKey });
|
|
8595
|
+
}
|
|
8596
|
+
try {
|
|
8597
|
+
await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
|
|
8598
|
+
}
|
|
8599
|
+
catch (error) {
|
|
8600
|
+
if (!operatorKey)
|
|
8601
|
+
throw error;
|
|
8602
|
+
throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), [operatorKey]));
|
|
8603
|
+
}
|
|
8604
|
+
const dbPush = await this.pushDatabaseUrl(app.appId, dbId, dbName);
|
|
8605
|
+
if (!dbPush.pushed) {
|
|
8606
|
+
throw new Error(`the reporting backend for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
8607
|
+
}
|
|
8608
|
+
// The company's `<slug>-app` Lettermint token, joined as a consumer before the first deploy, so the
|
|
8609
|
+
// backend starts with it. `MAIL_FROM` went in with the env above.
|
|
8610
|
+
const mail = mailDomain
|
|
8611
|
+
? await this.ensureBouleMail(app.appId, org, context, action, app.existed && Boolean(context.recordedIds()[REPORTING_DEPLOYED_SCOPE_KEY]), 'reporting')
|
|
8612
|
+
: { changed: false, restarted: false };
|
|
8613
|
+
if (!context.recordedIds()[REPORTING_DEPLOYED_SCOPE_KEY]) {
|
|
8614
|
+
const runtime = this.ports.runtime ?? {};
|
|
8615
|
+
const built = await waitForNewDeployment(this.ports.coolify, app.appId, publicHealthCheck(org, context.recordedIds(), reportingHostnameFor(org), '/api/health'), runtime, runtime.sleep ?? defaultSleep, 'coolify:reporting', `${org.slug}'s reporting backend stays at its previous deploy`);
|
|
8616
|
+
await recordBuiltCommit(this.ports.coolify, { appId: app.appId, deploymentUuid: built.deploymentUuid, name: 'reporting' }, (key, value) => context.recordScopeId(key, value));
|
|
8617
|
+
context.recordScopeId(REPORTING_DEPLOYED_SCOPE_KEY, '1');
|
|
8618
|
+
}
|
|
8619
|
+
else if (app.existed &&
|
|
8620
|
+
!mail.restarted &&
|
|
8621
|
+
(envChanged || operatorKey !== null || dbPush.changed || mail.changed)) {
|
|
8622
|
+
await this.ports.coolify.restart(app.appId);
|
|
8623
|
+
}
|
|
8624
|
+
return { resourceId: app.appId };
|
|
8625
|
+
}
|
|
8626
|
+
/** The backend's operator key, read from its own env. Fixed text when the backend or the key is not there. */
|
|
8627
|
+
async reportingOperatorKey(org, context) {
|
|
8628
|
+
const appId = context.recordedIds()[`coolify:reporting:${org.id}`];
|
|
8629
|
+
if (!appId)
|
|
8630
|
+
throw new Error(`the reporting backend for ${org.slug} is not recorded yet`);
|
|
8631
|
+
const rows = await this.ports.coolify.listEnvs(appId);
|
|
8632
|
+
const key = rows.find((e) => e.key === REPORTING_OPERATOR_KEY_ENV && !e.is_preview)?.value;
|
|
8633
|
+
if (!key)
|
|
8634
|
+
throw new Error(`the reporting backend for ${org.slug} has no operator key yet`);
|
|
8635
|
+
this.handledSecrets.add(key);
|
|
8636
|
+
return key;
|
|
8637
|
+
}
|
|
8638
|
+
/**
|
|
8639
|
+
* `coolify:reporting-boule` (`plan/reporting-boule.ts`): the backend's address and operator key into
|
|
8640
|
+
* Boule's env, then Boule restarts. `reporting_boule_applied` is recorded last. Retire takes both names away.
|
|
8641
|
+
*/
|
|
8642
|
+
async reportingBoule(action, org, context) {
|
|
8643
|
+
const at = `coolify:reporting-boule for ${org.slug}`;
|
|
8644
|
+
const recorded = context.recordedIds();
|
|
8645
|
+
const bouleId = recorded[`coolify:boule:${org.id}`];
|
|
8646
|
+
if (!bouleId)
|
|
8647
|
+
throw new Error(`${at}: Boule is not recorded yet`);
|
|
8648
|
+
if (action.desired.mode === 'retire') {
|
|
8649
|
+
const rows = await this.ports.coolify.listEnvs(bouleId);
|
|
8650
|
+
for (const name of REPORTING_BOULE_ENV_NAMES) {
|
|
8651
|
+
const row = rows.find((e) => e.key === name && !e.is_preview);
|
|
8652
|
+
if (row?.uuid) {
|
|
8653
|
+
try {
|
|
8654
|
+
await this.ports.coolify.deleteEnv(bouleId, row.uuid);
|
|
8655
|
+
}
|
|
8656
|
+
catch (error) {
|
|
8657
|
+
if (!isNotFoundError(error))
|
|
8658
|
+
throw error;
|
|
8659
|
+
}
|
|
8660
|
+
}
|
|
8661
|
+
}
|
|
8662
|
+
await this.ports.coolify.restart(bouleId);
|
|
8663
|
+
context.recordScopeId(REPORTING_BOULE_APPLIED_SCOPE_KEY, '');
|
|
8664
|
+
return {};
|
|
8665
|
+
}
|
|
8666
|
+
if (!appsOn(org))
|
|
8667
|
+
throw new Error(`${at}: the company's Boule runs no apps (org.boule.apps)`);
|
|
8668
|
+
const backendId = recorded[`coolify:reporting:${org.id}`];
|
|
8669
|
+
if (!backendId)
|
|
8670
|
+
throw new Error(`${at}: the reporting backend is not recorded yet`);
|
|
8671
|
+
const operatorKey = await this.reportingOperatorKey(org, context);
|
|
8672
|
+
try {
|
|
8673
|
+
await this.ports.coolify.bulkUpsertEnvs(bouleId, [
|
|
8674
|
+
{ key: 'REPORTING_URL', value: reportingUrlFor(org), literal: true },
|
|
8675
|
+
{ key: REPORTING_OPERATOR_KEY_ENV, value: operatorKey, literal: true },
|
|
8676
|
+
]);
|
|
8677
|
+
await this.ports.coolify.restart(bouleId);
|
|
8678
|
+
}
|
|
8679
|
+
catch (error) {
|
|
8680
|
+
throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), [operatorKey]));
|
|
8681
|
+
}
|
|
8682
|
+
context.recordScopeId(REPORTING_BOULE_APPLIED_SCOPE_KEY, reportingBouleMarker(org, backendId));
|
|
8683
|
+
return {};
|
|
8684
|
+
}
|
|
8685
|
+
/**
|
|
8686
|
+
* `plan/entry-reporting-key.ts`: one server entry's site key (CLAUDE.md "Platform steps" rule 1). Mint: the
|
|
8687
|
+
* key's id is recorded as undelivered and persisted before anything is written; the env write is tried 3
|
|
8688
|
+
* times with the same secret; the applied address and the cleared marker come last; every try failing leaves
|
|
8689
|
+
* the marker, and the error names the id only. `url` rewrites the address. Retire revokes the key and the
|
|
8690
|
+
* env names. The operator key is read from the backend's env and never recorded.
|
|
8691
|
+
*/
|
|
8692
|
+
async reportingEntryKey(action, org, context) {
|
|
8693
|
+
const entryKey = action.key.slice(org.id.length + 1);
|
|
8694
|
+
const at = `reporting:key for ${org.slug} entry '${entryKey}'`;
|
|
8695
|
+
const reporting = this.ports.reporting;
|
|
8696
|
+
if (!reporting)
|
|
8697
|
+
throw new Error(`${at}: this executor was built without an reporting port`);
|
|
8698
|
+
const desired = action.desired;
|
|
8699
|
+
const recorded = context.recordedIds();
|
|
8700
|
+
const appId = recorded[`coolify:app:${action.key}`];
|
|
8701
|
+
const baseUrl = reportingUrlFor(org);
|
|
8702
|
+
const tolerate = async (call, gone) => {
|
|
8703
|
+
try {
|
|
8704
|
+
return await call();
|
|
8705
|
+
}
|
|
8706
|
+
catch (error) {
|
|
8707
|
+
if (!isNotFoundError(error))
|
|
8708
|
+
throw error;
|
|
8709
|
+
return gone;
|
|
8710
|
+
}
|
|
8711
|
+
};
|
|
8712
|
+
if (desired.mode === 'retire') {
|
|
8713
|
+
const mainId = recorded[`reporting:key:${action.key}`];
|
|
8714
|
+
const undelivered = recorded[entryReportingUndeliveredScopeKey(entryKey)];
|
|
8715
|
+
const ids = [...new Set([mainId, undelivered].filter((id) => Boolean(id)))];
|
|
8716
|
+
if (ids.length > 0) {
|
|
8717
|
+
const operatorKey = await this.reportingOperatorKey(org, context);
|
|
8718
|
+
for (const keyId of ids) {
|
|
8719
|
+
await tolerate(() => reporting.revokeSiteKey({ baseUrl, operatorKey, keyId }), undefined);
|
|
8720
|
+
}
|
|
8721
|
+
}
|
|
8722
|
+
if (appId) {
|
|
8723
|
+
const rows = await tolerate(() => this.ports.coolify.listEnvs(appId), []);
|
|
8724
|
+
let removed = false;
|
|
8725
|
+
for (const name of ['REPORTING_URL', 'REPORTING_SITE_KEY']) {
|
|
8726
|
+
const row = rows.find((e) => e.key === name && !e.is_preview);
|
|
8727
|
+
if (row?.uuid) {
|
|
8728
|
+
const uuid = row.uuid;
|
|
8729
|
+
await tolerate(() => this.ports.coolify.deleteEnv(appId, uuid), undefined);
|
|
8730
|
+
removed = true;
|
|
8731
|
+
}
|
|
8732
|
+
}
|
|
8733
|
+
if (removed)
|
|
8734
|
+
await tolerate(() => this.ports.coolify.restart(appId), undefined);
|
|
8735
|
+
}
|
|
8736
|
+
context.recordScopeId(entryReportingUndeliveredScopeKey(entryKey), '');
|
|
8737
|
+
context.recordScopeId(entryReportingAppliedScopeKey(entryKey), '');
|
|
8738
|
+
if (mainId ?? undelivered) {
|
|
8739
|
+
context.recordScopeId(entryReportingRetiredScopeKey(entryKey), (mainId ?? undelivered));
|
|
8740
|
+
}
|
|
8741
|
+
return {};
|
|
8742
|
+
}
|
|
8743
|
+
if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.reporting)) {
|
|
8744
|
+
throw new Error(`${at}: the entry no longer declares uses.reporting`);
|
|
8745
|
+
}
|
|
8746
|
+
if (!appId)
|
|
8747
|
+
throw new Error(`${at}: the entry's app is not recorded yet`);
|
|
8748
|
+
if (desired.mode === 'url') {
|
|
8749
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, [
|
|
8750
|
+
{ key: 'REPORTING_URL', value: baseUrl, literal: true },
|
|
8751
|
+
]);
|
|
8752
|
+
await this.ports.coolify.restart(appId);
|
|
8753
|
+
context.recordScopeId(entryReportingAppliedScopeKey(entryKey), baseUrl);
|
|
8754
|
+
return {};
|
|
8755
|
+
}
|
|
8756
|
+
if (recorded[entryReportingUndeliveredScopeKey(entryKey)]) {
|
|
8757
|
+
throw new Error(`${at}: a key was minted and never delivered; it is not minted again`);
|
|
8758
|
+
}
|
|
8759
|
+
const operatorKey = await this.reportingOperatorKey(org, context);
|
|
8760
|
+
let minted;
|
|
8761
|
+
try {
|
|
8762
|
+
minted = await reporting.mintSiteKey({
|
|
8763
|
+
baseUrl,
|
|
8764
|
+
operatorKey,
|
|
8765
|
+
name: desired.name,
|
|
8766
|
+
site: entryKey,
|
|
8767
|
+
environment: 'production',
|
|
8768
|
+
});
|
|
8769
|
+
}
|
|
8770
|
+
catch (error) {
|
|
8771
|
+
const status = error?.status;
|
|
8772
|
+
throw new Error(`${at}: the backend did not mint a key${typeof status === 'number' ? ` (status ${status})` : ''}`);
|
|
8773
|
+
}
|
|
8774
|
+
this.handledSecrets.add(minted.secret);
|
|
8775
|
+
context.recordScopeId(entryReportingUndeliveredScopeKey(entryKey), minted.id);
|
|
8776
|
+
await context.persistScopeIds();
|
|
8777
|
+
const rows = [
|
|
8778
|
+
{ key: 'REPORTING_URL', value: baseUrl, literal: true },
|
|
8779
|
+
{ key: 'REPORTING_SITE_KEY', value: minted.secret, literal: true },
|
|
8780
|
+
];
|
|
8781
|
+
let lastError;
|
|
8782
|
+
for (let attempt = 1; attempt <= ENTRY_REPORTING_KEY_DELIVERY_ATTEMPTS; attempt++) {
|
|
8783
|
+
try {
|
|
8784
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, rows);
|
|
8785
|
+
await this.ports.coolify.restart(appId);
|
|
8786
|
+
context.recordScopeId(entryReportingAppliedScopeKey(entryKey), baseUrl);
|
|
8787
|
+
context.recordScopeId(entryReportingUndeliveredScopeKey(entryKey), '');
|
|
8788
|
+
return { resourceId: minted.id };
|
|
8789
|
+
}
|
|
8790
|
+
catch (error) {
|
|
8791
|
+
lastError = error;
|
|
8792
|
+
}
|
|
8793
|
+
}
|
|
8794
|
+
throw new Error(`${at}: key ${minted.id} was minted but could not be written to the app's env after ${ENTRY_REPORTING_KEY_DELIVERY_ATTEMPTS} attempts (${lastError instanceof Error ? lastError.name : 'error'})`);
|
|
8795
|
+
}
|
|
8317
8796
|
// ---- kosmos (foundry#949) --------------------------------------------------
|
|
8318
8797
|
/**
|
|
8319
8798
|
* `plan/diff.ts`'s `planKosmos`: provisions the org's own kosmos
|
|
@@ -8849,8 +9328,11 @@ export class FoundryStepExecutor {
|
|
|
8849
9328
|
* Boule's env. Returns whether Boule's token changed, so the caller
|
|
8850
9329
|
* restarts a running Boule, unless `restarted`: the shared rule then already
|
|
8851
9330
|
* restarted it (`running`), before it deleted the old project.
|
|
9331
|
+
*
|
|
9332
|
+
* The company's reporting backend (foundry#1128) takes its token the same
|
|
9333
|
+
* way, as `role: 'reporting'`. The token is registered for redaction.
|
|
8852
9334
|
*/
|
|
8853
|
-
async ensureBouleMail(appId, org, context, action, running) {
|
|
9335
|
+
async ensureBouleMail(appId, org, context, action, running, role = 'boule') {
|
|
8854
9336
|
const none = { changed: false, restarted: false };
|
|
8855
9337
|
const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
|
|
8856
9338
|
if (!mailDomain)
|
|
@@ -8860,8 +9342,9 @@ export class FoundryStepExecutor {
|
|
|
8860
9342
|
if (platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase()) {
|
|
8861
9343
|
const heldToken = (await this.ports.heldKeys?.resolve(PLATFORM_AUTH_BROKER_MAIL_KEY_REF)) ?? null;
|
|
8862
9344
|
if (!heldToken) {
|
|
8863
|
-
throw new Error(`coolify
|
|
9345
|
+
throw new Error(`coolify:${role} for ${org.slug}: the platform's own Lettermint token is not held yet (held-key ref ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF})`);
|
|
8864
9346
|
}
|
|
9347
|
+
this.handledSecrets.add(heldToken);
|
|
8865
9348
|
const current = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'LETTERMINT_API_TOKEN' && !e.is_preview)?.value;
|
|
8866
9349
|
if (current === heldToken)
|
|
8867
9350
|
return none;
|
|
@@ -8874,7 +9357,7 @@ export class FoundryStepExecutor {
|
|
|
8874
9357
|
org,
|
|
8875
9358
|
context,
|
|
8876
9359
|
mailDomain,
|
|
8877
|
-
self: { role
|
|
9360
|
+
self: { role, id: appId, restart: running },
|
|
8878
9361
|
mailFrom: null,
|
|
8879
9362
|
platformApex,
|
|
8880
9363
|
platformLettermintProjectId,
|
|
@@ -9586,6 +10069,55 @@ export class FoundryStepExecutor {
|
|
|
9586
10069
|
context.recordScopeId(entryJobsAppliedScopeKey(entryKey), entryJobsWantMarker(want));
|
|
9587
10070
|
return { resourceId: want.name };
|
|
9588
10071
|
}
|
|
10072
|
+
/**
|
|
10073
|
+
* foundry#1119, `plan/entry-jobs-boule-key.ts`: Boule's key for one entry's jobs project, minted under rule 1,
|
|
10074
|
+
* held, and written to Boule's `JOBS_PROJECTS`; or, in the retire form, revoked and taken out. See
|
|
10075
|
+
* `entry-jobs-boule-key-apply.ts`.
|
|
10076
|
+
*/
|
|
10077
|
+
async jobsBouleKey(action, org, context) {
|
|
10078
|
+
const entryKey = action.key.slice(org.id.length + 1);
|
|
10079
|
+
const at = `jobs:boule-key for ${org.slug} entry '${entryKey}'`;
|
|
10080
|
+
const desired = action.desired;
|
|
10081
|
+
const port = await this.jobsPort(org);
|
|
10082
|
+
const mint = port.mintProjectKey?.bind(port);
|
|
10083
|
+
const revoke = port.revokeProjectKey?.bind(port);
|
|
10084
|
+
if (!mint || !revoke) {
|
|
10085
|
+
throw new Error(`${at}: the jobs scheduler port cannot mint or revoke project keys`);
|
|
10086
|
+
}
|
|
10087
|
+
const held = this.ports.heldKeys;
|
|
10088
|
+
const put = held?.put?.bind(held);
|
|
10089
|
+
if (!held || !put)
|
|
10090
|
+
throw new Error(`${at}: the held keys cannot hold Boule's key`);
|
|
10091
|
+
const deps = {
|
|
10092
|
+
scheduler: { mintProjectKey: mint, revokeProjectKey: revoke },
|
|
10093
|
+
heldKeys: { resolve: held.resolve.bind(held), put },
|
|
10094
|
+
coolify: this.ports.coolify,
|
|
10095
|
+
org: { id: org.id, slug: org.slug },
|
|
10096
|
+
entryKey,
|
|
10097
|
+
project: desired.name,
|
|
10098
|
+
bouleAppId: context.recordedIds()[`coolify:boule:${org.id}`],
|
|
10099
|
+
recorded: () => context.recordedIds(),
|
|
10100
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
10101
|
+
forget: (key) => forgetScopeId(context, key),
|
|
10102
|
+
persist: () => context.persistScopeIds(),
|
|
10103
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
10104
|
+
};
|
|
10105
|
+
if (desired.mode === 'retire') {
|
|
10106
|
+
if (org.hosting.enabled &&
|
|
10107
|
+
org.hosting.entries.some((e) => e.key === entryKey && e.uses?.jobs && e.live !== false)) {
|
|
10108
|
+
throw new Error(`${at}: the entry declares uses.jobs again; nothing was revoked`);
|
|
10109
|
+
}
|
|
10110
|
+
await retireJobsBouleKey(deps);
|
|
10111
|
+
return {};
|
|
10112
|
+
}
|
|
10113
|
+
if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.jobs)) {
|
|
10114
|
+
throw new Error(`${at}: the entry no longer declares uses.jobs`);
|
|
10115
|
+
}
|
|
10116
|
+
if (!desired.url)
|
|
10117
|
+
throw new Error(`${at}: the scheduler's public address is not in the step`);
|
|
10118
|
+
await ensureJobsBouleKey(deps, desired.url);
|
|
10119
|
+
return {};
|
|
10120
|
+
}
|
|
9589
10121
|
/** `plan/entry-audit.ts`: makes the platform signer if none exists and registers one entry's ledger for sealing. */
|
|
9590
10122
|
async auditLedger(action, org, context) {
|
|
9591
10123
|
const entryKey = action.key.slice(org.id.length + 1);
|
|
@@ -10747,13 +11279,16 @@ export class FoundryStepExecutor {
|
|
|
10747
11279
|
* on their own (`AiOrganisationPort`'s doc comment), so both are called
|
|
10748
11280
|
* every time regardless of whether they already ran.
|
|
10749
11281
|
*/
|
|
10750
|
-
async aiOrganisation(action, org) {
|
|
11282
|
+
async aiOrganisation(action, org, context) {
|
|
10751
11283
|
const ai = await this.aiPort();
|
|
10752
11284
|
const provisioned = await ai.provisionOrganisation({
|
|
10753
11285
|
organisationId: org.id,
|
|
10754
11286
|
limitMicros: action.desired.limitMicros ?? null,
|
|
10755
11287
|
});
|
|
10756
|
-
|
|
11288
|
+
const offers = action.desired.offers ?? [];
|
|
11289
|
+
await ai.setOrganisationOffers(org.id, offers);
|
|
11290
|
+
// foundry#1122: what is offered now, so a narrower set is offered again (`planAiOrganisation`).
|
|
11291
|
+
context.recordScopeId(AI_OFFERS_SCOPE_KEY, JSON.stringify(offers));
|
|
10757
11292
|
return { resourceId: provisioned.organisationId };
|
|
10758
11293
|
}
|
|
10759
11294
|
async aiBudget(action, org, context) {
|
|
@@ -10801,6 +11336,16 @@ export class FoundryStepExecutor {
|
|
|
10801
11336
|
for (let attempt = 1; attempt <= AI_KEY_DELIVERY_ATTEMPTS; attempt++) {
|
|
10802
11337
|
try {
|
|
10803
11338
|
await this.ports.deliverSecret(`ai:key:${org.slug}`, issued.secret);
|
|
11339
|
+
// foundry#1122: what the key holds, so a narrower one is minted again (`planAiKey`). A key it
|
|
11340
|
+
// replaced stays live until it expires (no revoke route yet, ai#157); the plan lists it.
|
|
11341
|
+
context.recordScopeId(AI_KEY_PERMISSIONS_SCOPE_KEY, JSON.stringify(action.desired.permissions ?? []));
|
|
11342
|
+
const replaced = context.recordedIds()[`ai:key:${org.id}`];
|
|
11343
|
+
if (replaced && replaced !== issued.id) {
|
|
11344
|
+
const previous = parseAiIds(context.recordedIds()[AI_KEY_PREVIOUS_SCOPE_KEY]);
|
|
11345
|
+
if (!previous.includes(replaced))
|
|
11346
|
+
previous.push(replaced);
|
|
11347
|
+
context.recordScopeId(AI_KEY_PREVIOUS_SCOPE_KEY, JSON.stringify(previous));
|
|
11348
|
+
}
|
|
10804
11349
|
return { resourceId: issued.id };
|
|
10805
11350
|
}
|
|
10806
11351
|
catch (e) {
|
|
@@ -10916,6 +11461,9 @@ export class FoundryStepExecutor {
|
|
|
10916
11461
|
name: d.name,
|
|
10917
11462
|
expiresDays: d.expiresDays,
|
|
10918
11463
|
permissions: d.permissions,
|
|
11464
|
+
// foundry#1122: the key's runs belong to this app's live copy (ai#190). A key minted before this
|
|
11465
|
+
// joins its app at its next renewal.
|
|
11466
|
+
app: { key: entryKey, copy: 'live' },
|
|
10919
11467
|
});
|
|
10920
11468
|
context.recordScopeId(entryAiUndeliveredScopeKey(entryKey), minted.id);
|
|
10921
11469
|
context.recordScopeId(entryAiPendingExpiresScopeKey(entryKey), minted.expiresAt);
|
|
@@ -10955,6 +11503,35 @@ export class FoundryStepExecutor {
|
|
|
10955
11503
|
}
|
|
10956
11504
|
return { resourceId };
|
|
10957
11505
|
}
|
|
11506
|
+
/**
|
|
11507
|
+
* foundry#1122, `plan/ai-boule-key.ts`: Boule's ai key, minted below the org's own ai key under rule 1, held,
|
|
11508
|
+
* and written to Boule's `AI_URL`, `AI_ORGANISATION_ID` and `AI_API_KEY`. See `ai-boule-key-apply.ts`.
|
|
11509
|
+
*/
|
|
11510
|
+
async aiBouleKey(action, org, context) {
|
|
11511
|
+
const at = `ai:boule-key for ${org.slug}`;
|
|
11512
|
+
const ai = await this.aiPort();
|
|
11513
|
+
const held = this.ports.heldKeys;
|
|
11514
|
+
const put = held?.put?.bind(held);
|
|
11515
|
+
if (!held || !put)
|
|
11516
|
+
throw new Error(`${at}: the held keys cannot hold Boule's key`);
|
|
11517
|
+
await ensureAiBouleKey({
|
|
11518
|
+
ai: {
|
|
11519
|
+
mintNestedKey: ai.mintNestedKey.bind(ai),
|
|
11520
|
+
...(ai.revokeKey ? { revokeKey: ai.revokeKey.bind(ai) } : {}),
|
|
11521
|
+
},
|
|
11522
|
+
heldKeys: { resolve: held.resolve.bind(held), put },
|
|
11523
|
+
coolify: this.ports.coolify,
|
|
11524
|
+
org: { id: org.id, slug: org.slug },
|
|
11525
|
+
bouleAppId: context.recordedIds()[`coolify:boule:${org.id}`],
|
|
11526
|
+
recorded: () => context.recordedIds(),
|
|
11527
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
11528
|
+
forget: (key) => forgetScopeId(context, key),
|
|
11529
|
+
persist: () => context.persistScopeIds(),
|
|
11530
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
11531
|
+
now: () => context.now?.() ?? Date.now(),
|
|
11532
|
+
}, action.desired);
|
|
11533
|
+
return {};
|
|
11534
|
+
}
|
|
10958
11535
|
/**
|
|
10959
11536
|
* REVOKE SEAM (ai#157): revokes through `revokeKey` when the port has one.
|
|
10960
11537
|
* Without it, or when it fails, the id joins the entry's list of keys left
|
|
@@ -11332,7 +11909,8 @@ export class FoundryStepExecutor {
|
|
|
11332
11909
|
.every((e) => recorded[`files:key:${org.id}:${e.key}`] &&
|
|
11333
11910
|
recorded[entryFilesMinterScopeKey(e.key)] === rootId &&
|
|
11334
11911
|
!recorded[entryFilesUndeliveredScopeKey(e.key)]);
|
|
11335
|
-
|
|
11912
|
+
// foundry#1121: Boule's files key too, or Boule's Files page would lose its key.
|
|
11913
|
+
if (!ready || !bouleKeyBelow(recorded, rootId))
|
|
11336
11914
|
return {};
|
|
11337
11915
|
const unrevoked = parseFilesIdList(recorded[FILES_ROOT_UNREVOKED_SCOPE_KEY]);
|
|
11338
11916
|
for (const id of previous) {
|
|
@@ -11374,6 +11952,8 @@ export class FoundryStepExecutor {
|
|
|
11374
11952
|
// The expiry and the replaced id follow the delivery: before it, the
|
|
11375
11953
|
// recorded key is still the one in use.
|
|
11376
11954
|
context.recordScopeId(FILES_ROOT_EXPIRES_SCOPE_KEY, issued.expiresAt);
|
|
11955
|
+
// foundry#1121: what the key holds, so a narrower one is renewed (`planFilesKeys`).
|
|
11956
|
+
context.recordScopeId(FILES_ROOT_PERMISSIONS_SCOPE_KEY, JSON.stringify(action.desired.permissions));
|
|
11377
11957
|
const replaced = recorded[`files:key:${org.id}`];
|
|
11378
11958
|
if (mode === 'renew' && replaced) {
|
|
11379
11959
|
const previous = parseFilesIdList(recorded[FILES_ROOT_PREVIOUS_SCOPE_KEY]);
|
|
@@ -11458,7 +12038,11 @@ export class FoundryStepExecutor {
|
|
|
11458
12038
|
{ key: 'FILES_ORGANISATION_ID', value: org.id },
|
|
11459
12039
|
...(d.shareOrigin ? [{ key: 'FILES_SHARE_ORIGIN', value: d.shareOrigin }] : []),
|
|
11460
12040
|
];
|
|
11461
|
-
|
|
12041
|
+
// foundry#1121: each app's collections are named apart in files; one the entry already had keeps its
|
|
12042
|
+
// name. The app's env names the label and holds the id, as before.
|
|
12043
|
+
const names = knownCollectionNames(entryKey, recorded);
|
|
12044
|
+
for (const label of d.collections) {
|
|
12045
|
+
const name = d.collectionNames?.[label] ?? names[label] ?? label;
|
|
11462
12046
|
let collection;
|
|
11463
12047
|
try {
|
|
11464
12048
|
collection = await files.ensureCollection(org.id, name);
|
|
@@ -11466,7 +12050,12 @@ export class FoundryStepExecutor {
|
|
|
11466
12050
|
catch (error) {
|
|
11467
12051
|
throw new Error(`${at}: collection ${name} could not be ensured (${filesErrorCause(error)})`);
|
|
11468
12052
|
}
|
|
11469
|
-
|
|
12053
|
+
names[label] = name;
|
|
12054
|
+
envs.push({ key: filesCollectionEnvName(label), value: collection.id });
|
|
12055
|
+
}
|
|
12056
|
+
if (d.collections.length > 0 || recorded[entryFilesAppliedScopeKey(entryKey)]) {
|
|
12057
|
+
context.recordScopeId(entryFilesCollectionsScopeKey(entryKey), JSON.stringify(names));
|
|
12058
|
+
await context.persistScopeIds();
|
|
11470
12059
|
}
|
|
11471
12060
|
if (d.mode !== 'settings') {
|
|
11472
12061
|
const minterId = recorded[`files:key:${org.id}`];
|
|
@@ -11524,12 +12113,59 @@ export class FoundryStepExecutor {
|
|
|
11524
12113
|
collections: d.collections,
|
|
11525
12114
|
filesUrl: d.filesUrl,
|
|
11526
12115
|
...(d.shareOrigin ? { shareOrigin: d.shareOrigin } : {}),
|
|
12116
|
+
collectionNames: d.collectionNames ?? {},
|
|
11527
12117
|
}));
|
|
11528
12118
|
if (expiresAt && previousKeyId && previousKeyId !== resourceId) {
|
|
11529
12119
|
await this.revokeFilesEntryKey(files, org, entryKey, previousKeyId, context);
|
|
11530
12120
|
}
|
|
11531
12121
|
return { resourceId };
|
|
11532
12122
|
}
|
|
12123
|
+
/**
|
|
12124
|
+
* foundry#1121, `plan/files-boule-key.ts`: Boule's files key, minted below the org's own files key under rule
|
|
12125
|
+
* 1, held, and written to Boule's `FILES_BOULE_KEYS`; or, in the retire form, revoked and taken out. See
|
|
12126
|
+
* `files-boule-key-apply.ts`.
|
|
12127
|
+
*/
|
|
12128
|
+
async filesBouleKey(action, org, context) {
|
|
12129
|
+
const at = `files:boule-key for ${org.slug}`;
|
|
12130
|
+
const files = await this.filesPort();
|
|
12131
|
+
const held = this.ports.heldKeys;
|
|
12132
|
+
const put = held?.put?.bind(held);
|
|
12133
|
+
const desired = action.desired;
|
|
12134
|
+
const deps = {
|
|
12135
|
+
files: {
|
|
12136
|
+
issueKeyBelow: files.issueKeyBelow.bind(files),
|
|
12137
|
+
revokeOrganisationKey: files.revokeOrganisationKey.bind(files),
|
|
12138
|
+
},
|
|
12139
|
+
heldKeys: {
|
|
12140
|
+
resolve: async (ref) => (held ? held.resolve(ref) : null),
|
|
12141
|
+
put: async (ref, value) => {
|
|
12142
|
+
if (!put)
|
|
12143
|
+
throw new Error('the held keys cannot put');
|
|
12144
|
+
await put(ref, value);
|
|
12145
|
+
},
|
|
12146
|
+
},
|
|
12147
|
+
coolify: this.ports.coolify,
|
|
12148
|
+
org: { id: org.id, slug: org.slug },
|
|
12149
|
+
bouleAppId: context.recordedIds()[`coolify:boule:${org.id}`],
|
|
12150
|
+
recorded: () => context.recordedIds(),
|
|
12151
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
12152
|
+
forget: (key) => forgetScopeId(context, key),
|
|
12153
|
+
persist: () => context.persistScopeIds(),
|
|
12154
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
12155
|
+
now: () => context.now?.() ?? Date.now(),
|
|
12156
|
+
};
|
|
12157
|
+
if (desired.mode === 'retire') {
|
|
12158
|
+
if (org.hosting.enabled && org.hosting.entries.some((e) => e.uses?.files)) {
|
|
12159
|
+
throw new Error(`${at}: an entry declares uses.files again; nothing was revoked`);
|
|
12160
|
+
}
|
|
12161
|
+
await retireFilesBouleKey(deps);
|
|
12162
|
+
return {};
|
|
12163
|
+
}
|
|
12164
|
+
if (!held || !put)
|
|
12165
|
+
throw new Error(`${at}: the held keys cannot hold Boule's key`);
|
|
12166
|
+
await ensureFilesBouleKey(deps, action.desired);
|
|
12167
|
+
return {};
|
|
12168
|
+
}
|
|
11533
12169
|
/**
|
|
11534
12170
|
* Revokes one entry key. When the call fails the id joins the entry's list
|
|
11535
12171
|
* of keys left live, which the plan shows, and the step goes on: the app
|
|
@@ -12096,6 +12732,16 @@ export async function setOidcAppLoginBaseUri(zitadel, orgId, projectId, appId, l
|
|
|
12096
12732
|
}
|
|
12097
12733
|
}
|
|
12098
12734
|
/** A recorded stage service key (`bg.<id>.stage<service>keys` or `...org`) with its last part swapped. */
|
|
12735
|
+
/**
|
|
12736
|
+
* The stage mail sink of `org`, with the `MAIL_APP_KEY` its stage copy of entry `entryKey` sends as (`<key>-work`)
|
|
12737
|
+
* when that entry sends mail. Boule, Agora and kosmos are not entries: their stage gets the sink alone.
|
|
12738
|
+
*/
|
|
12739
|
+
function stageSinkFor(org, entryKey) {
|
|
12740
|
+
const entry = entryKey ? org.hosting.entries.find((e) => e.key === entryKey) : undefined;
|
|
12741
|
+
return entry && sendsMail(entry)
|
|
12742
|
+
? { slug: org.slug, mailAppKey: mailAppKeyOf(entry.key, 'work') }
|
|
12743
|
+
: { slug: org.slug };
|
|
12744
|
+
}
|
|
12099
12745
|
function stageServiceKey(key, part) {
|
|
12100
12746
|
const match = /^(bg\..+\.stage(?:files|ai|jobs))(?:keys|org)$/.exec(key);
|
|
12101
12747
|
if (!match?.[1])
|