@wtfalch/foundry 0.15.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap/declare.d.ts +2 -2
- package/dist/bootstrap/declare.js +6 -6
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/migrations/0034_owners.sql +42 -0
- package/dist/migrations/0035_chat_on_by_default.sql +10 -0
- package/dist/org.d.ts +80 -17
- package/dist/org.js +221 -18
- package/dist/plan/agora.d.ts +19 -1
- package/dist/plan/agora.js +37 -2
- package/dist/plan/chat-matrix.d.ts +99 -0
- package/dist/plan/chat-matrix.js +194 -0
- package/dist/plan/diff.d.ts +5 -3
- package/dist/plan/diff.js +143 -83
- package/dist/plan/entry-ai-key.d.ts +58 -0
- package/dist/plan/entry-ai-key.js +209 -0
- package/dist/plan/entry-files-key.d.ts +82 -0
- package/dist/plan/entry-files-key.js +327 -0
- package/dist/plan/executor.d.ts +244 -16
- package/dist/plan/executor.js +866 -84
- package/dist/plan/hostnames.d.ts +14 -1
- package/dist/plan/hostnames.js +26 -1
- package/dist/plan/index.d.ts +1 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/plan-apply.d.ts +2 -0
- package/dist/plan/plan-apply.js +42 -2
- package/dist/plan/platform-org.d.ts +17 -5
- package/dist/plan/platform-org.js +15 -7
- package/dist/plan/platform-providers.js +4 -3
- package/dist/plan/proxy-labels.d.ts +42 -0
- package/dist/plan/proxy-labels.js +73 -0
- package/dist/plan/zitadel-machine-user.d.ts +5 -1
- package/dist/plan/zitadel-machine-user.js +5 -1
- package/dist/plan/zitadel-project-roles.d.ts +25 -0
- package/dist/plan/zitadel-project-roles.js +104 -0
- package/dist/store/postgres.js +4 -17
- package/dist/store/schema.d.ts +13 -45
- package/dist/store/schema.js +5 -8
- package/package.json +1 -1
package/dist/plan/executor.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { URL } from 'node:url';
|
|
3
3
|
import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
|
|
4
|
-
import { APP_ENTRY_KEY, appEntry } from '../org.js';
|
|
5
|
-
import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_ORG_SCOPE_KEY, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
|
|
4
|
+
import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, } from '../org.js';
|
|
5
|
+
import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
|
|
6
6
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
7
7
|
import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
8
|
+
import { CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, chatInternalNames, chatMasConfigYaml, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
|
|
8
9
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
9
|
-
import {
|
|
10
|
+
import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScopeKey, entryAiExpiresScopeKey, entryAiPendingExpiresScopeKey, entryAiRetiredScopeKey, entryAiUndeliveredScopeKey, entryAiUnrevokedScopeKey, entryAiWebhookPendingScopeKey, entryAiWrittenScopeKey, parseIdList, } from './entry-ai-key.js';
|
|
11
|
+
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';
|
|
12
|
+
import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
|
|
10
13
|
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
11
14
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
12
15
|
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, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, } from './platform-diff.js';
|
|
16
|
+
import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
|
|
13
17
|
/**
|
|
14
18
|
* #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
|
|
15
19
|
* listApps` -- a plain array, with no `loginBaseUri` or completeness signal
|
|
@@ -36,8 +40,8 @@ export function adaptZitadelListApps(client) {
|
|
|
36
40
|
// ---------------------------------------------------------------------------
|
|
37
41
|
/**
|
|
38
42
|
* Thrown by name for a step this executor deliberately does not implement:
|
|
39
|
-
* chat
|
|
40
|
-
*
|
|
43
|
+
* moving chat or the issuer onto or off the org's domain (chat's re-key is
|
|
44
|
+
* not built; the issuer's is built, but not in v1 — domain verification and passkey
|
|
41
45
|
* invalidation need their own design). Never carries a token, secret or key
|
|
42
46
|
* (task brief: "errors carry the step and the provider's status only").
|
|
43
47
|
*/
|
|
@@ -274,6 +278,15 @@ const CMS_DATABASE_SCOPE_KEY = 'coolify_cms_database';
|
|
|
274
278
|
* `resend_mail_domain`.
|
|
275
279
|
*/
|
|
276
280
|
const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
|
|
281
|
+
/**
|
|
282
|
+
* A `TXT` record's leading tag, lowercased: `v=spf1`, `v=dkim1`, `v=dmarc1`,
|
|
283
|
+
* or for an untagged value its first word. Cloudflare may hand content back
|
|
284
|
+
* wrapped in quotes, so those are dropped first.
|
|
285
|
+
*/
|
|
286
|
+
function txtRecordTag(content) {
|
|
287
|
+
const bare = content.trim().replace(/^"/, '').trimStart();
|
|
288
|
+
return (bare.split(/[\s;]/, 1)[0] ?? '').toLowerCase();
|
|
289
|
+
}
|
|
277
290
|
/**
|
|
278
291
|
* #235: the Coolify app running `cloudflared` for this org's own tunnel --
|
|
279
292
|
* one connector per org tunnel (Decided, William, 2026-10-01), the same
|
|
@@ -397,19 +410,8 @@ function parseTunnelPreviousIds(recorded) {
|
|
|
397
410
|
function tunnelPreviousIdsState(ids) {
|
|
398
411
|
return [...ids].sort().join(',');
|
|
399
412
|
}
|
|
400
|
-
/**
|
|
401
|
-
|
|
402
|
-
* `applyOrgMail` succeeds, alongside {@link MAIL_DOMAIN_SCOPE_KEY} -- another
|
|
403
|
-
* flat scope id for the same reason, no `:`. Recorded with the address
|
|
404
|
-
* itself (not just a boolean) so a future step could read it back the same
|
|
405
|
-
* way {@link MAIL_DOMAIN_SCOPE_KEY} lets `ensureAppLettermintMail` read the
|
|
406
|
-
* domain back; `ensureOwnerMailbox` only ever checks that this key is set at
|
|
407
|
-
* all before deciding whether to call `MailPort.createMailbox` again.
|
|
408
|
-
* `plan/diff.ts` keeps its own copy of this literal (`MAIL_OWNER_MAILBOX_SCOPE_KEY`
|
|
409
|
-
* there) rather than importing it, the same restated-not-shared rule this
|
|
410
|
-
* file's own `LOG_DIR` doc comment names for `diff.ts`.
|
|
411
|
-
*/
|
|
412
|
-
const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
|
|
413
|
+
/** The role on the org's `<slug>-messaging` project that opens /_chat/manage. */
|
|
414
|
+
const CHAT_ADMIN_ROLE = 'chat.admin';
|
|
413
415
|
/**
|
|
414
416
|
* #149 review finding: `MailPort.hasOrgRelay` only confirms Stalwart's route
|
|
415
417
|
* holds *a* secret, not that it is the one {@link FoundryStepExecutor
|
|
@@ -418,7 +420,7 @@ const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
|
|
|
418
420
|
* project that has since been lost and reprovisioned under the same name
|
|
419
421
|
* would otherwise read as healthy. Recorded as `<project id>@<mail domain>`
|
|
420
422
|
* right after a successful `MailPort.applyOrgRelay`, a flat scope id (no
|
|
421
|
-
* `:`) the same way
|
|
423
|
+
* `:`) the same way `mail_owner_mailbox` is. The domain half
|
|
422
424
|
* also catches a domain switch: the healthy branch never calls
|
|
423
425
|
* `applyOrgRelay` again, so without this the Stalwart route would stay
|
|
424
426
|
* restricted to the org's previous domain forever.
|
|
@@ -440,6 +442,12 @@ const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
|
|
|
440
442
|
const AI_KEY_UNDELIVERED_SCOPE_KEY = 'ai_key_undelivered';
|
|
441
443
|
/** How many times `aiKey` tries `deliverSecret` before giving up. */
|
|
442
444
|
const AI_KEY_DELIVERY_ATTEMPTS = 3;
|
|
445
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
446
|
+
/** What a failed files call says about itself: its HTTP status, never its message, which can echo a value. */
|
|
447
|
+
function filesErrorCause(error) {
|
|
448
|
+
const status = error?.status;
|
|
449
|
+
return typeof status === 'number' ? `HTTP ${status}` : 'no HTTP status';
|
|
450
|
+
}
|
|
443
451
|
/**
|
|
444
452
|
* #173: the id of a jobs project `jobsRegistration` has registered but not
|
|
445
453
|
* yet delivered the credentials of, a flat scope id (no `:`). `serviceIds` is
|
|
@@ -491,6 +499,25 @@ const CALLS_SECRET_ENV_KEYS = {
|
|
|
491
499
|
masRsaKeyPem: 'CALLS_MATRIX_MAS_RSA_KEY_PEM',
|
|
492
500
|
masEcKeyPem: 'CALLS_MATRIX_MAS_EC_KEY_PEM',
|
|
493
501
|
};
|
|
502
|
+
/**
|
|
503
|
+
* `coolifyChat`'s flat scope ids, for the same reason as the calls stack's
|
|
504
|
+
* above: this step's one auto-recorded `resourceId` (under
|
|
505
|
+
* `coolify:chat:<orgId>`) is the MAS app's id, the one every secret lives on.
|
|
506
|
+
*/
|
|
507
|
+
const CHAT_SYNAPSE_SCOPE_KEY = 'coolify_chat_synapse';
|
|
508
|
+
const CHAT_SERVICE_SCOPE_KEY = 'coolify_chat_service';
|
|
509
|
+
const CHAT_SYNAPSE_DB_SCOPE_KEY = 'coolify_chat_synapse_database';
|
|
510
|
+
const CHAT_MAS_DB_SCOPE_KEY = 'coolify_chat_mas_database';
|
|
511
|
+
const CHAT_SERVICE_DB_SCOPE_KEY = 'coolify_chat_service_database';
|
|
512
|
+
/** {@link CALLS_SECRET_ENV_KEYS}' counterpart on chat's own MAS app. */
|
|
513
|
+
const CHAT_SECRET_ENV_KEYS = {
|
|
514
|
+
masSynapseSharedSecret: 'CHAT_MATRIX_MAS_SYNAPSE_SHARED_SECRET',
|
|
515
|
+
masClientId: 'CHAT_MATRIX_MAS_CLIENT_ID',
|
|
516
|
+
masClientSecret: 'CHAT_MATRIX_MAS_CLIENT_SECRET',
|
|
517
|
+
masEncryptionKey: 'CHAT_MATRIX_MAS_ENCRYPTION_KEY',
|
|
518
|
+
masRsaKeyPem: 'CHAT_MATRIX_MAS_RSA_KEY_PEM',
|
|
519
|
+
masEcKeyPem: 'CHAT_MATRIX_MAS_EC_KEY_PEM',
|
|
520
|
+
};
|
|
494
521
|
/** `FROM ${MAS_IMAGE}` plus the entry command its published image needs (chat's own `MAS_DOCKERFILE`, same reason: no arguments by default). */
|
|
495
522
|
const CALLS_MAS_DOCKERFILE = [
|
|
496
523
|
`FROM ${MAS_IMAGE}`,
|
|
@@ -564,7 +591,12 @@ export class FoundryStepExecutor {
|
|
|
564
591
|
case 'cloudflare:ratelimit':
|
|
565
592
|
return this.cloudflareRateLimit(org);
|
|
566
593
|
case 'files:organisation':
|
|
567
|
-
return this.filesOrganisation(action, org);
|
|
594
|
+
return this.filesOrganisation(action, org, context);
|
|
595
|
+
case 'files:key':
|
|
596
|
+
// The org's own key is keyed `<orgId>`, an entry's `<orgId>:<entry>` (F6b).
|
|
597
|
+
return action.key.includes(':')
|
|
598
|
+
? this.filesEntryKey(action, org, context)
|
|
599
|
+
: this.filesRootKey(action, org, context);
|
|
568
600
|
case 'files:storage':
|
|
569
601
|
return this.filesStorage(action, org);
|
|
570
602
|
case 'ai:organisation':
|
|
@@ -572,13 +604,16 @@ export class FoundryStepExecutor {
|
|
|
572
604
|
case 'ai:budget':
|
|
573
605
|
return this.aiBudget(action, org);
|
|
574
606
|
case 'ai:key':
|
|
575
|
-
|
|
607
|
+
// The org's own key is keyed `<orgId>`, an entry's `<orgId>:<entry>` (F6a).
|
|
608
|
+
return action.key.includes(':')
|
|
609
|
+
? this.aiEntryKey(action, org, context)
|
|
610
|
+
: this.aiKey(action, org, context);
|
|
576
611
|
case 'coolify:mail':
|
|
577
612
|
return this.coolifyMail(action, org, context);
|
|
578
613
|
case 'coolify:mail-copy':
|
|
579
614
|
return this.mailCopy(org, context);
|
|
580
615
|
case 'coolify:chat':
|
|
581
|
-
|
|
616
|
+
return this.coolifyChat(action, org, context);
|
|
582
617
|
case 'cms:site':
|
|
583
618
|
return this.cmsSite(action, org, context);
|
|
584
619
|
case 'coolify:agora':
|
|
@@ -701,7 +736,7 @@ export class FoundryStepExecutor {
|
|
|
701
736
|
* `plan/diff.ts`'s `planOwner`: invites the org's owner into its ZITADEL
|
|
702
737
|
* org by email. Never logs, returns or throws the email itself -- only the
|
|
703
738
|
* invited user id, handed back as `resourceId` so `runSteps` records it
|
|
704
|
-
* under `zitadel:owner:<orgId>`, which is exactly what makes the next
|
|
739
|
+
* under `zitadel:owner:<orgId>:<email>`, which is exactly what makes the next
|
|
705
740
|
* `plan()` call idempotent (`planOwner` plans nothing once that key is
|
|
706
741
|
* recorded).
|
|
707
742
|
*/
|
|
@@ -715,6 +750,18 @@ export class FoundryStepExecutor {
|
|
|
715
750
|
familyName: action.desired.familyName,
|
|
716
751
|
email: action.desired.email,
|
|
717
752
|
});
|
|
753
|
+
// An owner invited after chat was built never reaches `coolifyChat`
|
|
754
|
+
// again, so the role is granted here once the messaging project exists.
|
|
755
|
+
const messagingProjectId = context.recordedIds()[ZITADEL_MESSAGING_PROJECT_SCOPE_KEY];
|
|
756
|
+
if (org.chat.enabled && messagingProjectId) {
|
|
757
|
+
await this.ports.zitadel.ensureProjectRole(zitadelOrgId, messagingProjectId, {
|
|
758
|
+
key: CHAT_ADMIN_ROLE,
|
|
759
|
+
displayName: 'Chat admin',
|
|
760
|
+
});
|
|
761
|
+
await this.ports.zitadel.grantProjectRoles(zitadelOrgId, userId, messagingProjectId, [
|
|
762
|
+
CHAT_ADMIN_ROLE,
|
|
763
|
+
]);
|
|
764
|
+
}
|
|
718
765
|
return { resourceId: userId };
|
|
719
766
|
}
|
|
720
767
|
/**
|
|
@@ -1161,7 +1208,7 @@ export class FoundryStepExecutor {
|
|
|
1161
1208
|
git_branch: entry.branch,
|
|
1162
1209
|
build_pack: entry.buildPack,
|
|
1163
1210
|
name: appNameFor(org, entry),
|
|
1164
|
-
domains: names.length > 0 ? names
|
|
1211
|
+
domains: names.length > 0 ? coolifyDomains(names) : undefined,
|
|
1165
1212
|
autogenerate_domain: names.length > 0 ? undefined : false,
|
|
1166
1213
|
// A static site is served by Coolify's own web server on port 80.
|
|
1167
1214
|
ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
|
|
@@ -1192,10 +1239,31 @@ export class FoundryStepExecutor {
|
|
|
1192
1239
|
// here (rule 3). The internal name is set after create, as the calls
|
|
1193
1240
|
// stack's apps do.
|
|
1194
1241
|
if (JSON.stringify(names) !== JSON.stringify(haveNames))
|
|
1195
|
-
patch.domains = names
|
|
1242
|
+
patch.domains = coolifyDomains(names);
|
|
1196
1243
|
if (names.length === 0 && (!existing || patch.domains !== undefined)) {
|
|
1197
1244
|
patch.custom_internal_name = appNameFor(org, entry);
|
|
1198
1245
|
}
|
|
1246
|
+
// A `*` entry's proxy labels: Coolify takes `custom_labels` base64 encoded
|
|
1247
|
+
// and uses them instead of the labels it generates from `domains`. Rule 4:
|
|
1248
|
+
// decided from the live app. A created app has none, so it is always
|
|
1249
|
+
// patched; a live app is patched when its labels differ from the wanted
|
|
1250
|
+
// set (a hand edit included). Only when Coolify does not report
|
|
1251
|
+
// `custom_labels` does the recorded hash decide. The hash is recorded
|
|
1252
|
+
// below, after the redeploy, so a failed run repeats this.
|
|
1253
|
+
const labelsKey = appLabelsAppliedScopeKey(entry.key);
|
|
1254
|
+
const haveLabelsHash = context.recordedIds()[labelsKey];
|
|
1255
|
+
const labelSpec = entryLabelSpec(org, entry, haveLabelsHash !== undefined);
|
|
1256
|
+
const wantLabelsHash = labelSpec ? entryLabelHash(labelSpec) : undefined;
|
|
1257
|
+
const wantLabels = labelSpec ? entryProxyLabels(appId, labelSpec) : undefined;
|
|
1258
|
+
const liveLabels = existing ? live?.custom_labels : undefined;
|
|
1259
|
+
const labelsDiffer = wantLabels !== undefined &&
|
|
1260
|
+
(!existing ||
|
|
1261
|
+
(liveLabels !== undefined
|
|
1262
|
+
? !sameLabelSets(Buffer.from(liveLabels ?? '', 'base64').toString('utf8'), wantLabels)
|
|
1263
|
+
: wantLabelsHash !== haveLabelsHash));
|
|
1264
|
+
if (wantLabels !== undefined && labelsDiffer) {
|
|
1265
|
+
patch.custom_labels = Buffer.from(wantLabels).toString('base64');
|
|
1266
|
+
}
|
|
1199
1267
|
if (Object.keys(patch).length > 0)
|
|
1200
1268
|
await this.ports.coolify.updateApplication(appId, patch);
|
|
1201
1269
|
// The container builds and listens from these only at its next deploy.
|
|
@@ -1203,7 +1271,8 @@ export class FoundryStepExecutor {
|
|
|
1203
1271
|
patch.publish_directory ||
|
|
1204
1272
|
patch.ports_exposes ||
|
|
1205
1273
|
patch.domains !== undefined ||
|
|
1206
|
-
patch.custom_internal_name
|
|
1274
|
+
patch.custom_internal_name ||
|
|
1275
|
+
patch.custom_labels);
|
|
1207
1276
|
// The database plans before the app (`diff.ts`'s `computePlanDetail`,
|
|
1208
1277
|
// hosting-wiring campaign) so that, on the same apply, the database's
|
|
1209
1278
|
// resource id is already recorded by the time this runs -- but this also
|
|
@@ -1247,6 +1316,8 @@ export class FoundryStepExecutor {
|
|
|
1247
1316
|
// one and `planApp` reports it instead of planning it.
|
|
1248
1317
|
context.recordScopeId(settingsKey, JSON.stringify({ ...want, port: want.port ?? have.port }));
|
|
1249
1318
|
context.recordScopeId(domainsKey, JSON.stringify(names));
|
|
1319
|
+
if (wantLabelsHash)
|
|
1320
|
+
context.recordScopeId(labelsKey, wantLabelsHash);
|
|
1250
1321
|
context.recordScopeId(appEnvAppliedScopeKey(entry.key), appEnv.applied);
|
|
1251
1322
|
if (entry.uses?.auth) {
|
|
1252
1323
|
context.recordScopeId(entryAuthEnvAppliedScopeKey(entry.key), appEnv.authApplied);
|
|
@@ -1431,9 +1502,9 @@ export class FoundryStepExecutor {
|
|
|
1431
1502
|
* `zitadel:issuer` (`AUTH_ORGANIZATION_ID`), `zitadel:owner`
|
|
1432
1503
|
* (`OWNER_USER_ID`, gap P3) and every `zitadel:app` step
|
|
1433
1504
|
* (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
|
|
1434
|
-
* reason. `AUTH_APP_KEY`
|
|
1435
|
-
*
|
|
1436
|
-
*
|
|
1505
|
+
* reason. `AUTH_APP_KEY` is pushed here too, for an entry that declares
|
|
1506
|
+
* `uses: [auth]` (`ensureAuthAppKey`); the host's `BrokerPort` decides which
|
|
1507
|
+
* broker the org's app registers with (#206). `DATABASE_URL_OWNER`/
|
|
1437
1508
|
* `DATABASE_URL` are pushed by `pushDatabaseUrl` instead, not here --
|
|
1438
1509
|
* that call runs only once the `app` entry's `database` is declared and
|
|
1439
1510
|
* provisioned, where this one is unconditional (#239).
|
|
@@ -1477,7 +1548,10 @@ export class FoundryStepExecutor {
|
|
|
1477
1548
|
// them its own owner in authz on first sign-in. `runSteps` records this
|
|
1478
1549
|
// under the same `system:kind:key` shape every step's `resourceId` uses
|
|
1479
1550
|
// (`zitadelOwner`'s own doc comment), not a scope id.
|
|
1480
|
-
const
|
|
1551
|
+
const firstOwner = org.owners[0];
|
|
1552
|
+
const ownerUserId = firstOwner
|
|
1553
|
+
? recorded[`zitadel:owner:${org.id}:${firstOwner.email.toLowerCase()}`]
|
|
1554
|
+
: undefined;
|
|
1481
1555
|
if (ownerUserId)
|
|
1482
1556
|
envs.push({ key: 'OWNER_USER_ID', value: ownerUserId });
|
|
1483
1557
|
// Idempotent the same way `pushDatabaseUrl`'s credential is treated:
|
|
@@ -2104,7 +2178,7 @@ export class FoundryStepExecutor {
|
|
|
2104
2178
|
git_branch: 'main',
|
|
2105
2179
|
build_pack: 'dockerfile',
|
|
2106
2180
|
name: input.name,
|
|
2107
|
-
domains: input.domain,
|
|
2181
|
+
domains: coolifyDomains([input.domain]),
|
|
2108
2182
|
dockerfile_location: input.dockerfileLocation,
|
|
2109
2183
|
});
|
|
2110
2184
|
return { appId: created.uuid, existed: false };
|
|
@@ -2271,14 +2345,19 @@ export class FoundryStepExecutor {
|
|
|
2271
2345
|
* by `coolifyCallsService` (#145) for the calls service app itself -- the
|
|
2272
2346
|
* same find-by-name-then-start-or-create shape applies unchanged.
|
|
2273
2347
|
*/
|
|
2274
|
-
async findOrCreateCallsApp(name, environmentId, create) {
|
|
2348
|
+
async findOrCreateCallsApp(name, environmentId, create, options = {}) {
|
|
2275
2349
|
const existing = await this.ports.coolify.findApplicationByName(name, {
|
|
2276
2350
|
environmentIds: [environmentId],
|
|
2277
2351
|
});
|
|
2278
2352
|
if (existing) {
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2353
|
+
// `startIfStopped: false` (chat): the caller writes the app's config
|
|
2354
|
+
// first and lets its own deploy start it, so a stopped app is never
|
|
2355
|
+
// started with config that is not there yet.
|
|
2356
|
+
if (options.startIfStopped !== false) {
|
|
2357
|
+
const live = await this.ports.coolify.getApplication(existing.uuid);
|
|
2358
|
+
if (!isRunningOrStarting(live.status)) {
|
|
2359
|
+
await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
|
|
2360
|
+
}
|
|
2282
2361
|
}
|
|
2283
2362
|
return { appId: existing.uuid, existed: true };
|
|
2284
2363
|
}
|
|
@@ -2324,10 +2403,10 @@ export class FoundryStepExecutor {
|
|
|
2324
2403
|
* still treated as "mint fresh", since MAS's shared secret and Synapse's
|
|
2325
2404
|
* copy of it must always agree.
|
|
2326
2405
|
*/
|
|
2327
|
-
async ensureCallsMatrixSecrets(masAppId) {
|
|
2406
|
+
async ensureCallsMatrixSecrets(masAppId, secretEnvKeys = CALLS_SECRET_ENV_KEYS) {
|
|
2328
2407
|
const envs = await this.ports.coolify.listEnvs(masAppId);
|
|
2329
2408
|
const read = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
|
|
2330
|
-
const fields = Object.entries(
|
|
2409
|
+
const fields = Object.entries(secretEnvKeys);
|
|
2331
2410
|
const existing = {};
|
|
2332
2411
|
for (const [field, envKey] of fields) {
|
|
2333
2412
|
const value = read(envKey);
|
|
@@ -2365,6 +2444,209 @@ export class FoundryStepExecutor {
|
|
|
2365
2444
|
});
|
|
2366
2445
|
}
|
|
2367
2446
|
}
|
|
2447
|
+
// ---- chat -----------------------------------------------------------------
|
|
2448
|
+
/**
|
|
2449
|
+
* Creates the `chat.admin` role on the org's messaging project and grants
|
|
2450
|
+
* it to each owner whose invite is recorded (`zitadel:owner:<orgId>:<email>`,
|
|
2451
|
+
* email lowercased).
|
|
2452
|
+
*/
|
|
2453
|
+
async ensureChatAdmins(org, zitadelOrgId, projectId, recorded) {
|
|
2454
|
+
await this.ports.zitadel.ensureProjectRole(zitadelOrgId, projectId, {
|
|
2455
|
+
key: CHAT_ADMIN_ROLE,
|
|
2456
|
+
displayName: 'Chat admin',
|
|
2457
|
+
});
|
|
2458
|
+
for (const owner of org.owners) {
|
|
2459
|
+
const userId = recorded[`zitadel:owner:${org.id}:${owner.email.toLowerCase()}`];
|
|
2460
|
+
if (userId) {
|
|
2461
|
+
await this.ports.zitadel.grantProjectRoles(zitadelOrgId, userId, projectId, [
|
|
2462
|
+
CHAT_ADMIN_ROLE,
|
|
2463
|
+
]);
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
/**
|
|
2468
|
+
* `plan/diff.ts`'s `planChat`: the org's chat stack at level 1 -- three
|
|
2469
|
+
* Coolify-managed Postgres databases (Synapse, MAS, chat), Synapse with the
|
|
2470
|
+
* `wtfalch_chat_gate` module and MAS (both with a config file this step
|
|
2471
|
+
* writes, `chat-matrix.ts`), and the chat service. All four apps are
|
|
2472
|
+
* internal: Agora is the one public origin and proxies to them
|
|
2473
|
+
* (`AGORA_CHAT_UPSTREAM`, `AGORA_MATRIX_UPSTREAM`, `AGORA_MAS_UPSTREAM`).
|
|
2474
|
+
* It also finds or creates the org's ZITADEL messaging project, the
|
|
2475
|
+
* audience the chat service requires, and records its id for Agora.
|
|
2476
|
+
*
|
|
2477
|
+
* Idempotent: every resource is found by name or recorded id before it is
|
|
2478
|
+
* created, minted secrets are read back from the MAS app's envs, and an app
|
|
2479
|
+
* is deployed when it is new, its config was freshly minted, or it is not running. A
|
|
2480
|
+
* recorded chat resource means the stack already exists, so a second
|
|
2481
|
+
* `coolify:chat` step for it is a re-key (onto the company domain, or back)
|
|
2482
|
+
* and neither is built: both are refused by name, before anything is touched.
|
|
2483
|
+
* No secret reaches an error: errors name the org and the missing input only.
|
|
2484
|
+
*/
|
|
2485
|
+
async coolifyChat(action, org, context) {
|
|
2486
|
+
const recorded = context.recordedIds();
|
|
2487
|
+
if (recorded[`coolify:chat:${action.key}`]) {
|
|
2488
|
+
if (action.desired.usesDomain === false) {
|
|
2489
|
+
throw new NotBuiltError(`coolify:chat reverse re-key for ${org.slug}`, 'moving its chat back onto the original server_name is not built (#7 amendment 3)');
|
|
2490
|
+
}
|
|
2491
|
+
throw new NotBuiltError(`coolify:chat re-key for ${org.slug}`, 'moving its chat onto the company domain is not built; chat stays on its level-1 server_name');
|
|
2492
|
+
}
|
|
2493
|
+
const issuer = action.desired.issuer;
|
|
2494
|
+
if (typeof issuer !== 'string' || issuer === '') {
|
|
2495
|
+
throw new Error(`coolify:chat for ${org.slug}: the step carries no issuer`);
|
|
2496
|
+
}
|
|
2497
|
+
const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
2498
|
+
if (!zitadelOrgId) {
|
|
2499
|
+
throw new Error(`coolify:chat for ${org.slug}: the org has no ZITADEL org recorded yet -- zitadel:issuer must run first`);
|
|
2500
|
+
}
|
|
2501
|
+
// Found or created by name, so a re-run after a crash reuses it. Its id is
|
|
2502
|
+
// not a secret.
|
|
2503
|
+
const { project } = await this.ports.zitadel.findOrCreateProject(zitadelOrgId, `${org.slug}-messaging`);
|
|
2504
|
+
context.recordScopeId(ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, project.id);
|
|
2505
|
+
// The chat service gates /_chat/manage on this role. Without it nobody
|
|
2506
|
+
// passes. Every owner already invited gets it; an owner invited later
|
|
2507
|
+
// gets it from `zitadelOwner`. Both are idempotent.
|
|
2508
|
+
await this.ensureChatAdmins(org, zitadelOrgId, project.id, recorded);
|
|
2509
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
2510
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
2511
|
+
const server = await this.resolveServer();
|
|
2512
|
+
const names = chatInternalNames(org.slug);
|
|
2513
|
+
const synapseDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.synapseDb, 'synapse', CHAT_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
|
|
2514
|
+
context.recordScopeId(CHAT_SYNAPSE_DB_SCOPE_KEY, synapseDbId);
|
|
2515
|
+
const masDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.masDb, 'mas', CHAT_MAS_DB_SCOPE_KEY);
|
|
2516
|
+
context.recordScopeId(CHAT_MAS_DB_SCOPE_KEY, masDbId);
|
|
2517
|
+
const serviceDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.serviceDb, 'chat', CHAT_SERVICE_DB_SCOPE_KEY);
|
|
2518
|
+
context.recordScopeId(CHAT_SERVICE_DB_SCOPE_KEY, serviceDbId);
|
|
2519
|
+
const [synapseDbRow, masDbRow, serviceDbRow] = await Promise.all([
|
|
2520
|
+
this.ports.coolify.getDatabase(synapseDbId),
|
|
2521
|
+
this.ports.coolify.getDatabase(masDbId),
|
|
2522
|
+
this.ports.coolify.getDatabase(serviceDbId),
|
|
2523
|
+
]);
|
|
2524
|
+
const synapseDb = synapseDbRow.internal_db_url
|
|
2525
|
+
? parseCallsDbUrl(synapseDbRow.internal_db_url)
|
|
2526
|
+
: null;
|
|
2527
|
+
const masDb = masDbRow.internal_db_url ? parseCallsDbUrl(masDbRow.internal_db_url) : null;
|
|
2528
|
+
if (!synapseDb || !masDb || !serviceDbRow.internal_db_url) {
|
|
2529
|
+
// Before any app is touched: Coolify can take a moment to report a new
|
|
2530
|
+
// database's url, and the next apply retries from a clean state.
|
|
2531
|
+
throw new Error(`coolify:chat for ${org.slug}: a database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
2532
|
+
}
|
|
2533
|
+
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
2534
|
+
const synapse = await this.findOrCreateCallsApp(names.synapse, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
2535
|
+
project_uuid: projectUuid,
|
|
2536
|
+
server_uuid: server.uuid,
|
|
2537
|
+
environment_name: environment.name,
|
|
2538
|
+
environment_uuid: environment.uuid,
|
|
2539
|
+
github_app_uuid: githubApp.uuid,
|
|
2540
|
+
git_repository: CHAT_REPO,
|
|
2541
|
+
git_branch: 'main',
|
|
2542
|
+
build_pack: 'dockerfile',
|
|
2543
|
+
name: names.synapse,
|
|
2544
|
+
dockerfile_location: CHAT_SYNAPSE_DOCKERFILE,
|
|
2545
|
+
ports_exposes: CHAT_SYNAPSE_PORT,
|
|
2546
|
+
autogenerate_domain: false,
|
|
2547
|
+
}), { startIfStopped: false });
|
|
2548
|
+
const mas = await this.findOrCreateCallsApp(names.mas, environment.id, () => this.ports.coolify.createDockerfileApplication({
|
|
2549
|
+
project_uuid: projectUuid,
|
|
2550
|
+
server_uuid: server.uuid,
|
|
2551
|
+
environment_name: environment.name,
|
|
2552
|
+
environment_uuid: environment.uuid,
|
|
2553
|
+
dockerfile: CALLS_MAS_DOCKERFILE,
|
|
2554
|
+
name: names.mas,
|
|
2555
|
+
description: `matrix-authentication-service for ${org.slug}'s chat`,
|
|
2556
|
+
instant_deploy: false,
|
|
2557
|
+
}), { startIfStopped: false });
|
|
2558
|
+
const service = await this.findOrCreateCallsApp(names.service, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
2559
|
+
project_uuid: projectUuid,
|
|
2560
|
+
server_uuid: server.uuid,
|
|
2561
|
+
environment_name: environment.name,
|
|
2562
|
+
environment_uuid: environment.uuid,
|
|
2563
|
+
github_app_uuid: githubApp.uuid,
|
|
2564
|
+
git_repository: CHAT_REPO,
|
|
2565
|
+
git_branch: 'main',
|
|
2566
|
+
build_pack: 'dockerfile',
|
|
2567
|
+
name: names.service,
|
|
2568
|
+
dockerfile_location: CHAT_SERVICE_DOCKERFILE,
|
|
2569
|
+
ports_exposes: CHAT_SERVICE_PORT,
|
|
2570
|
+
autogenerate_domain: false,
|
|
2571
|
+
}), { startIfStopped: false });
|
|
2572
|
+
// Fixed internal names, set on every run: the three apps and Agora
|
|
2573
|
+
// address each other by them. Neither Synapse's nor MAS's health check
|
|
2574
|
+
// can pass before the other is up, so both are off (the calls stack's own
|
|
2575
|
+
// rule).
|
|
2576
|
+
await this.ports.coolify.updateApplication(synapse.appId, {
|
|
2577
|
+
custom_internal_name: names.synapse,
|
|
2578
|
+
...(synapse.existed ? {} : { health_check_enabled: false }),
|
|
2579
|
+
});
|
|
2580
|
+
await this.ports.coolify.updateApplication(mas.appId, {
|
|
2581
|
+
custom_internal_name: names.mas,
|
|
2582
|
+
...(mas.existed ? {} : { health_check_enabled: false }),
|
|
2583
|
+
});
|
|
2584
|
+
await this.ports.coolify.updateApplication(service.appId, {
|
|
2585
|
+
custom_internal_name: names.service,
|
|
2586
|
+
});
|
|
2587
|
+
context.recordScopeId(CHAT_SYNAPSE_SCOPE_KEY, synapse.appId);
|
|
2588
|
+
context.recordScopeId(CHAT_SERVICE_SCOPE_KEY, service.appId);
|
|
2589
|
+
const synapseStorages = await this.ports.coolify.listStorages(synapse.appId);
|
|
2590
|
+
if (!(synapseStorages.persistent_storages ?? []).some((s) => s.mount_path === '/data')) {
|
|
2591
|
+
await this.ports.coolify.createPersistentStorage(synapse.appId, {
|
|
2592
|
+
name: `${names.synapse}-data`,
|
|
2593
|
+
mount_path: '/data',
|
|
2594
|
+
});
|
|
2595
|
+
}
|
|
2596
|
+
const secrets = await this.ensureCallsMatrixSecrets(mas.appId, CHAT_SECRET_ENV_KEYS);
|
|
2597
|
+
const agoraHost = agoraHostnameFor(org);
|
|
2598
|
+
await this.ensureCallsFileStorage(synapse.appId, '/data/homeserver.yaml', chatSynapseHomeserverYaml({
|
|
2599
|
+
orgSlug: org.slug,
|
|
2600
|
+
db: synapseDb,
|
|
2601
|
+
masHost: names.mas,
|
|
2602
|
+
masSharedSecret: secrets.value.masSynapseSharedSecret,
|
|
2603
|
+
}));
|
|
2604
|
+
await this.ports.coolify.bulkUpsertEnvs(synapse.appId, [
|
|
2605
|
+
{ key: 'SYNAPSE_CONFIG_PATH', value: '/data/homeserver.yaml' },
|
|
2606
|
+
{ key: 'UID', value: '0' },
|
|
2607
|
+
{ key: 'GID', value: '0' },
|
|
2608
|
+
]);
|
|
2609
|
+
await this.ensureCallsFileStorage(mas.appId, '/config/config.yaml', chatMasConfigYaml({
|
|
2610
|
+
orgSlug: org.slug,
|
|
2611
|
+
publicHost: agoraHost,
|
|
2612
|
+
db: masDb,
|
|
2613
|
+
synapseHost: names.synapse,
|
|
2614
|
+
sharedSecret: secrets.value.masSynapseSharedSecret,
|
|
2615
|
+
clientId: secrets.value.masClientId,
|
|
2616
|
+
clientSecret: secrets.value.masClientSecret,
|
|
2617
|
+
encryptionKey: secrets.value.masEncryptionKey,
|
|
2618
|
+
rsaKeyPem: secrets.value.masRsaKeyPem,
|
|
2619
|
+
ecKeyPem: secrets.value.masEcKeyPem,
|
|
2620
|
+
}));
|
|
2621
|
+
await this.ports.coolify.bulkUpsertEnvs(service.appId, chatServiceEnvs({
|
|
2622
|
+
orgSlug: org.slug,
|
|
2623
|
+
companyName: org.displayName,
|
|
2624
|
+
issuer,
|
|
2625
|
+
audience: project.id,
|
|
2626
|
+
zitadelOrgId,
|
|
2627
|
+
agoraHost,
|
|
2628
|
+
masClientId: secrets.value.masClientId,
|
|
2629
|
+
masClientSecret: secrets.value.masClientSecret,
|
|
2630
|
+
databaseUrl: serviceDbRow.internal_db_url,
|
|
2631
|
+
}));
|
|
2632
|
+
// A file-storage write alone never reaches a running container. Deploy an
|
|
2633
|
+
// app that is new, whose config was freshly minted, or that is not
|
|
2634
|
+
// running now. The last test reads the live status, not what this run
|
|
2635
|
+
// did, so a run that failed after creating the apps (or after deploying
|
|
2636
|
+
// only some of them) is repaired by the next one. An unchanged, running
|
|
2637
|
+
// stack is not redeployed, or this step would never converge. MAS first:
|
|
2638
|
+
// Synapse asks it for token introspection.
|
|
2639
|
+
for (const app of [mas, synapse, service]) {
|
|
2640
|
+
if (!app.existed ||
|
|
2641
|
+
secrets.changed ||
|
|
2642
|
+
!isRunningOrStarting((await this.ports.coolify.getApplication(app.appId)).status)) {
|
|
2643
|
+
await this.ports.coolify.deploy(app.appId);
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
// Last: the marker tells `desiredAgoraEnv` the names and envs are in place.
|
|
2647
|
+
context.recordScopeId(CHAT_SERVICE_URL_SCOPE_KEY, chatUpstreams(org.slug).chat);
|
|
2648
|
+
return { resourceId: mas.appId };
|
|
2649
|
+
}
|
|
2368
2650
|
// ---- calls service (#145, part 2 of #128) -------------------------------
|
|
2369
2651
|
/**
|
|
2370
2652
|
* `plan/diff.ts`'s `planCallsService`: the org's own calls service --
|
|
@@ -2426,7 +2708,7 @@ export class FoundryStepExecutor {
|
|
|
2426
2708
|
git_branch: 'main',
|
|
2427
2709
|
build_pack: 'dockerfile',
|
|
2428
2710
|
name,
|
|
2429
|
-
domains: callsHostnameFor(org),
|
|
2711
|
+
domains: coolifyDomains([callsHostnameFor(org)]),
|
|
2430
2712
|
dockerfile_location: CALLS_SERVICE_DOCKERFILE,
|
|
2431
2713
|
}));
|
|
2432
2714
|
// A fixed internal name, like Synapse and MAS above, so the org's Agora
|
|
@@ -2618,10 +2900,12 @@ export class FoundryStepExecutor {
|
|
|
2618
2900
|
* `AGORA_FILES_UPSTREAM` are pushed once the platform's own mail/files
|
|
2619
2901
|
* hosts are on and applied (#177's `platform-org.ts`'s
|
|
2620
2902
|
* `agoraHostValuesFromPlatform`, read by `plan-apply.ts` when no host
|
|
2621
|
-
* `agora` port overrides it) -- absent otherwise.
|
|
2622
|
-
*
|
|
2623
|
-
*
|
|
2624
|
-
*
|
|
2903
|
+
* `agora` port overrides it) -- absent otherwise. `AGORA_CHAT_UPSTREAM`,
|
|
2904
|
+
* `AGORA_MATRIX_UPSTREAM` and `AGORA_MAS_UPSTREAM` are pushed together once
|
|
2905
|
+
* chat is on and `coolifyChat` has recorded that its apps carry their
|
|
2906
|
+
* internal names (`CHAT_SERVICE_URL_SCOPE_KEY`); absent otherwise, and Agora
|
|
2907
|
+
* answers 502 on those routes rather than serving anything wrong.
|
|
2908
|
+
* `messagingProjectId` falls back to the project `coolifyChat` recorded. `AGORA_CALLS_UPSTREAM` is
|
|
2625
2909
|
* pushed once calls are on and this org's calls service app carries its
|
|
2626
2910
|
* internal name (`desiredAgoraEnv` reads that marker from the recorded ids,
|
|
2627
2911
|
* not the host values).
|
|
@@ -2652,7 +2936,7 @@ export class FoundryStepExecutor {
|
|
|
2652
2936
|
git_branch: 'main',
|
|
2653
2937
|
build_pack: 'dockerfile',
|
|
2654
2938
|
name,
|
|
2655
|
-
domains: agoraHostnameFor(org),
|
|
2939
|
+
domains: coolifyDomains([agoraHostnameFor(org)]),
|
|
2656
2940
|
dockerfile_location: AGORA_APP_DOCKERFILE,
|
|
2657
2941
|
}));
|
|
2658
2942
|
// Every app foundry deploys keeps its own logs (#75), reused as-is here.
|
|
@@ -3259,23 +3543,21 @@ export class FoundryStepExecutor {
|
|
|
3259
3543
|
* when the org has no owner yet, when no address is declared yet
|
|
3260
3544
|
* (`plan/diff.ts`'s `planMail` reports that case as blocked, not planned,
|
|
3261
3545
|
* so this step's own mode/domain work still runs), or once
|
|
3262
|
-
*
|
|
3546
|
+
* `mail_owner_mailbox` is already recorded -- checked last
|
|
3263
3547
|
* so a second apply never calls `MailPort.createMailbox` again, rather
|
|
3264
3548
|
* than relying on that port method's own duplicate refusal.
|
|
3265
3549
|
*/
|
|
3266
3550
|
async ensureOwnerMailbox(org, mailOrg, options, context) {
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
address
|
|
3276
|
-
|
|
3277
|
-
}, options);
|
|
3278
|
-
context.recordScopeId(MAIL_OWNER_MAILBOX_SCOPE_KEY, address);
|
|
3551
|
+
for (const box of ownerMailboxes(org)) {
|
|
3552
|
+
const { owner, address, scopeKey } = box;
|
|
3553
|
+
if (!address)
|
|
3554
|
+
continue;
|
|
3555
|
+
if (ownerMailboxRecorded(box, context.recordedIds()))
|
|
3556
|
+
continue;
|
|
3557
|
+
await this.ports.mail.createMailbox(mailOrg, { address, name: `${owner.givenName} ${owner.familyName}` }, options);
|
|
3558
|
+
// Recorded after each mailbox, so a crash between two owners repeats only the second.
|
|
3559
|
+
context.recordScopeId(scopeKey, address);
|
|
3560
|
+
}
|
|
3279
3561
|
}
|
|
3280
3562
|
/**
|
|
3281
3563
|
* #230: creates each of the org's declared `mail.roleMailboxes` on its
|
|
@@ -3329,27 +3611,57 @@ export class FoundryStepExecutor {
|
|
|
3329
3611
|
await this.ports.lettermint.deleteDomain(stale.id);
|
|
3330
3612
|
}
|
|
3331
3613
|
/**
|
|
3332
|
-
* Upserts each DNS record `applyOrgMail` says foundry must publish
|
|
3333
|
-
*
|
|
3334
|
-
*
|
|
3335
|
-
*
|
|
3336
|
-
* mints the key on a background task; the next apply publishes it.
|
|
3614
|
+
* Upserts each DNS record `applyOrgMail` says foundry must publish through
|
|
3615
|
+
* {@link upsertMailDnsRecord}. A `DKIM` record can be absent on the first
|
|
3616
|
+
* call, since Stalwart mints the key on a background task; the next apply
|
|
3617
|
+
* publishes it.
|
|
3337
3618
|
*/
|
|
3338
3619
|
async publishMailDnsRecords(org, result) {
|
|
3339
|
-
const
|
|
3340
|
-
const zone = await cf.findZoneByApex(result.domain);
|
|
3620
|
+
const zone = await this.ports.cloudflare.findZoneByApex(result.domain);
|
|
3341
3621
|
if (!zone) {
|
|
3342
3622
|
throw new Error(`coolify:mail for ${org.slug}: no Cloudflare zone found for ${result.domain}`);
|
|
3343
3623
|
}
|
|
3344
3624
|
for (const record of result.records) {
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3625
|
+
await this.upsertMailDnsRecord(zone.id, record);
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3628
|
+
/**
|
|
3629
|
+
* Creates or updates one mail record (Stalwart's or Lettermint's).
|
|
3630
|
+
*
|
|
3631
|
+
* Always DNS-only: Cloudflare proxies only `A`, `AAAA` and `CNAME`, and a
|
|
3632
|
+
* proxied `CNAME` answers with Cloudflare's own addresses instead of its
|
|
3633
|
+
* target, so Lettermint's return-path and DKIM names would stop resolving
|
|
3634
|
+
* to Lettermint. `@wtfalch/providers`' `createDnsRecord` proxies by
|
|
3635
|
+
* default, so `proxied: false` is sent on every write, update included, so
|
|
3636
|
+
* a record someone proxied by hand is put back. An `MX` record carries the
|
|
3637
|
+
* priority it was given, which Cloudflare requires.
|
|
3638
|
+
*
|
|
3639
|
+
* Found by an exact name+type match (`CloudflarePort.findDnsRecord`'s own
|
|
3640
|
+
* doc comment) so a stale record of a different type is never taken for a
|
|
3641
|
+
* match. A name can hold several `TXT` records (an apex often holds a site
|
|
3642
|
+
* verification beside SPF), so a `TXT` record only replaces one that
|
|
3643
|
+
* starts with the same tag (`v=spf1`, `v=DKIM1`); with none, it is added
|
|
3644
|
+
* beside them. Before this, the first `TXT` on the name was overwritten,
|
|
3645
|
+
* whatever it was.
|
|
3646
|
+
*/
|
|
3647
|
+
async upsertMailDnsRecord(zoneId, record) {
|
|
3648
|
+
const cf = this.ports.cloudflare;
|
|
3649
|
+
const found = await cf.findDnsRecord(zoneId, record.name, record.type);
|
|
3650
|
+
const existing = record.type === 'TXT'
|
|
3651
|
+
? found.find((r) => txtRecordTag(r.content) === txtRecordTag(record.value))
|
|
3652
|
+
: found[0];
|
|
3653
|
+
const rec = {
|
|
3654
|
+
type: record.type,
|
|
3655
|
+
name: record.name,
|
|
3656
|
+
content: record.value,
|
|
3657
|
+
proxied: false,
|
|
3658
|
+
...(record.priority === undefined ? {} : { priority: record.priority }),
|
|
3659
|
+
};
|
|
3660
|
+
if (existing) {
|
|
3661
|
+
await cf.updateDnsRecord(zoneId, existing.id, rec);
|
|
3662
|
+
}
|
|
3663
|
+
else {
|
|
3664
|
+
await cf.createDnsRecord(zoneId, rec);
|
|
3353
3665
|
}
|
|
3354
3666
|
}
|
|
3355
3667
|
/**
|
|
@@ -3398,20 +3710,12 @@ export class FoundryStepExecutor {
|
|
|
3398
3710
|
* handle here and is dropped rather than kept as dead code.
|
|
3399
3711
|
*/
|
|
3400
3712
|
async publishLettermintDnsRecords(zoneId, records) {
|
|
3401
|
-
const cf = this.ports.cloudflare;
|
|
3402
3713
|
for (const record of records) {
|
|
3403
3714
|
if (record.type === 'TXT' && record.value.toLowerCase().startsWith('v=dmarc1')) {
|
|
3404
|
-
await this.ensureLettermintDmarcRecord(
|
|
3715
|
+
await this.ensureLettermintDmarcRecord(this.ports.cloudflare, zoneId, record);
|
|
3405
3716
|
continue;
|
|
3406
3717
|
}
|
|
3407
|
-
|
|
3408
|
-
const rec = { type: record.type, name: record.name, content: record.value };
|
|
3409
|
-
if (existing) {
|
|
3410
|
-
await cf.updateDnsRecord(zoneId, existing.id, rec);
|
|
3411
|
-
}
|
|
3412
|
-
else {
|
|
3413
|
-
await cf.createDnsRecord(zoneId, rec);
|
|
3414
|
-
}
|
|
3718
|
+
await this.upsertMailDnsRecord(zoneId, record);
|
|
3415
3719
|
}
|
|
3416
3720
|
}
|
|
3417
3721
|
/**
|
|
@@ -3420,10 +3724,15 @@ export class FoundryStepExecutor {
|
|
|
3420
3724
|
* Lettermint's own `p=none` default.
|
|
3421
3725
|
*/
|
|
3422
3726
|
async ensureLettermintDmarcRecord(cf, zoneId, record) {
|
|
3423
|
-
const hasDmarc = (await cf.findDnsRecord(zoneId, record.name, 'TXT')).some((r) => r.content
|
|
3727
|
+
const hasDmarc = (await cf.findDnsRecord(zoneId, record.name, 'TXT')).some((r) => txtRecordTag(r.content) === 'v=dmarc1');
|
|
3424
3728
|
if (hasDmarc)
|
|
3425
3729
|
return;
|
|
3426
|
-
await cf.createDnsRecord(zoneId, {
|
|
3730
|
+
await cf.createDnsRecord(zoneId, {
|
|
3731
|
+
type: 'TXT',
|
|
3732
|
+
name: record.name,
|
|
3733
|
+
content: record.value,
|
|
3734
|
+
proxied: false,
|
|
3735
|
+
});
|
|
3427
3736
|
}
|
|
3428
3737
|
// ---- files / ai --------------------------------------------------------
|
|
3429
3738
|
/**
|
|
@@ -3433,15 +3742,62 @@ export class FoundryStepExecutor {
|
|
|
3433
3742
|
* this is called every time regardless of whether it already ran --
|
|
3434
3743
|
* idempotent by find-or-create, not by checking `recordedIds` first.
|
|
3435
3744
|
*/
|
|
3436
|
-
async filesOrganisation(action, org) {
|
|
3745
|
+
async filesOrganisation(action, org, context) {
|
|
3437
3746
|
const files = await this.filesPort();
|
|
3747
|
+
// F6b: an org whose entries use files needs an owner member in its files
|
|
3748
|
+
// organisation, which its own key is minted below. Only a new
|
|
3749
|
+
// organisation takes one, so it is passed when the owner is known now.
|
|
3750
|
+
const owner = org.hosting.entries.some((e) => e.uses?.files)
|
|
3751
|
+
? filesOwnerUserId(org, context.recordedIds())
|
|
3752
|
+
: undefined;
|
|
3438
3753
|
const provisioned = await files.provisionOrganisation({
|
|
3439
3754
|
organisationId: org.id,
|
|
3440
3755
|
ceilingBytes: action.desired.ceilingBytes ?? null,
|
|
3441
3756
|
ceilingMicros: action.desired.ceilingMicros ?? null,
|
|
3757
|
+
...(owner ? { name: org.displayName, owner } : {}),
|
|
3442
3758
|
});
|
|
3759
|
+
// Recorded now, so the org key is minted below the member files made.
|
|
3760
|
+
if (owner && provisioned.created)
|
|
3761
|
+
context.recordScopeId(FILES_ORG_OWNER_SCOPE_KEY, owner);
|
|
3762
|
+
if (provisioned.agoraKey) {
|
|
3763
|
+
await this.deliverFilesAgoraKey(files, org, provisioned.agoraKey, context);
|
|
3764
|
+
}
|
|
3443
3765
|
return { resourceId: provisioned.organisationId };
|
|
3444
3766
|
}
|
|
3767
|
+
/**
|
|
3768
|
+
* Files onboards Agora when it provisions an organisation with an owner and
|
|
3769
|
+
* returns Agora's service key once. Foundry holds no other files key for
|
|
3770
|
+
* Agora, so this is the only one: its secret goes straight to
|
|
3771
|
+
* `deliverSecret`, its id is recorded as undelivered first, and a delivery
|
|
3772
|
+
* that fails revokes the key again instead of leaving a live key nobody holds.
|
|
3773
|
+
*/
|
|
3774
|
+
async deliverFilesAgoraKey(files, org, key, context) {
|
|
3775
|
+
const at = `files:organisation for ${org.slug}`;
|
|
3776
|
+
context.recordScopeId(FILES_AGORA_UNDELIVERED_SCOPE_KEY, key.id);
|
|
3777
|
+
await context.persistScopeIds();
|
|
3778
|
+
let cause = 'no HTTP status';
|
|
3779
|
+
for (let attempt = 1; attempt <= AI_KEY_DELIVERY_ATTEMPTS; attempt++) {
|
|
3780
|
+
try {
|
|
3781
|
+
await this.ports.deliverSecret(`files:agora-key:${org.slug}`, key.secret);
|
|
3782
|
+
context.recordScopeId(FILES_AGORA_KEY_SCOPE_KEY, key.id);
|
|
3783
|
+
return;
|
|
3784
|
+
}
|
|
3785
|
+
catch (error) {
|
|
3786
|
+
cause = filesErrorCause(error);
|
|
3787
|
+
}
|
|
3788
|
+
}
|
|
3789
|
+
let revoked = false;
|
|
3790
|
+
try {
|
|
3791
|
+
await files.revokeOrganisationKey(org.id, key.id);
|
|
3792
|
+
revoked = true;
|
|
3793
|
+
}
|
|
3794
|
+
catch {
|
|
3795
|
+
// Left on the undelivered id, which the plan reports.
|
|
3796
|
+
}
|
|
3797
|
+
if (revoked)
|
|
3798
|
+
context.recordScopeId(FILES_AGORA_UNDELIVERED_SCOPE_KEY, '');
|
|
3799
|
+
throw new Error(`${at}: Agora's key ${key.id} could not be delivered after ${AI_KEY_DELIVERY_ATTEMPTS} attempts (${cause}); ${revoked ? 'it was revoked again' : 'revoke it in files'}`);
|
|
3800
|
+
}
|
|
3445
3801
|
async filesStorage(action, org) {
|
|
3446
3802
|
const files = await this.filesPort();
|
|
3447
3803
|
await files.setQuota(org.id, action.desired.limitBytes ?? null);
|
|
@@ -3514,6 +3870,418 @@ export class FoundryStepExecutor {
|
|
|
3514
3870
|
// echoes the secret.
|
|
3515
3871
|
throw new Error(`ai:key for ${org.slug}: key ${issued.id} was minted but its secret could not be delivered after ${AI_KEY_DELIVERY_ATTEMPTS} attempts (${lastError instanceof Error ? lastError.name : 'error'}); revoke it in ai before a new one is minted`);
|
|
3516
3872
|
}
|
|
3873
|
+
/**
|
|
3874
|
+
* F6a: mints, renews, finishes, re-writes or retires one entry's ai key.
|
|
3875
|
+
*
|
|
3876
|
+
* Order keeps the app on a valid key throughout: the new key is minted below
|
|
3877
|
+
* the org's key, its id is recorded and persisted as undelivered, the env is
|
|
3878
|
+
* written (up to three tries), that write is recorded, the app is restarted,
|
|
3879
|
+
* and only then is the old key revoked. A failed write keeps the old key in
|
|
3880
|
+
* use and the planner blocks instead of minting again. A failed restart
|
|
3881
|
+
* after the write plans `finish`, which restarts and records, minting
|
|
3882
|
+
* nothing. The webhook secret, which ai shows once, is written the same way
|
|
3883
|
+
* before the mint. A secret reaches `bulkUpsertEnvs` and nothing else:
|
|
3884
|
+
* errors name ids, never the cause, which can echo one.
|
|
3885
|
+
*/
|
|
3886
|
+
async aiEntryKey(action, org, context) {
|
|
3887
|
+
const entryKey = action.key.slice(org.id.length + 1);
|
|
3888
|
+
const at = `ai:key for ${org.slug} entry '${entryKey}'`;
|
|
3889
|
+
const desired = action.desired;
|
|
3890
|
+
const ai = await this.aiPort();
|
|
3891
|
+
if (desired.mode === 'retire') {
|
|
3892
|
+
const keyId = context.recordedIds()[`ai:key:${action.key}`];
|
|
3893
|
+
if (!keyId)
|
|
3894
|
+
return {};
|
|
3895
|
+
await this.revokeEntryKey(ai, org, entryKey, keyId, context);
|
|
3896
|
+
context.recordScopeId(entryAiRetiredScopeKey(entryKey), keyId);
|
|
3897
|
+
return {};
|
|
3898
|
+
}
|
|
3899
|
+
const entry = org.hosting.entries.find((e) => e.key === entryKey && e.uses?.ai);
|
|
3900
|
+
if (!entry)
|
|
3901
|
+
throw new Error(`${at}: the entry no longer declares uses.ai`);
|
|
3902
|
+
const d = action.desired;
|
|
3903
|
+
const recorded = context.recordedIds();
|
|
3904
|
+
const appId = recorded[`coolify:app:${action.key}`];
|
|
3905
|
+
if (!appId)
|
|
3906
|
+
throw new Error(`${at}: the entry's app is not recorded yet`);
|
|
3907
|
+
const writeEnvs = async (what, ids, envs) => {
|
|
3908
|
+
for (let attempt = 1; attempt <= AI_KEY_DELIVERY_ATTEMPTS; attempt++) {
|
|
3909
|
+
try {
|
|
3910
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, envs.map((e) => ({ ...e, literal: true })));
|
|
3911
|
+
return;
|
|
3912
|
+
}
|
|
3913
|
+
catch {
|
|
3914
|
+
// The error can echo a value; it is dropped.
|
|
3915
|
+
}
|
|
3916
|
+
}
|
|
3917
|
+
throw new Error(`${at}: ${what} ${ids} could not be written to the app's env after ${AI_KEY_DELIVERY_ATTEMPTS} attempts`);
|
|
3918
|
+
};
|
|
3919
|
+
const previousKeyId = recorded[`ai:key:${action.key}`];
|
|
3920
|
+
let resourceId = previousKeyId;
|
|
3921
|
+
let expiresAt;
|
|
3922
|
+
if (d.mode === 'finish') {
|
|
3923
|
+
// The env already holds the key recorded as undelivered (`planEntryAiKeys`).
|
|
3924
|
+
resourceId = recorded[entryAiUndeliveredScopeKey(entryKey)];
|
|
3925
|
+
expiresAt = recorded[entryAiPendingExpiresScopeKey(entryKey)];
|
|
3926
|
+
await this.ports.coolify.restart(appId);
|
|
3927
|
+
}
|
|
3928
|
+
else {
|
|
3929
|
+
const appliedBefore = recorded[entryAiAppliedScopeKey(entryKey)];
|
|
3930
|
+
let webhookBefore;
|
|
3931
|
+
try {
|
|
3932
|
+
webhookBefore = appliedBefore
|
|
3933
|
+
? JSON.parse(appliedBefore).webhook
|
|
3934
|
+
: undefined;
|
|
3935
|
+
}
|
|
3936
|
+
catch {
|
|
3937
|
+
webhookBefore = undefined;
|
|
3938
|
+
}
|
|
3939
|
+
if (d.webhook && d.webhook !== webhookBefore) {
|
|
3940
|
+
const pending = entryAiWebhookPendingScopeKey(entryKey);
|
|
3941
|
+
// Kept only while a secret could be lost: from here until the
|
|
3942
|
+
// registration answers without one or the secret is in the env.
|
|
3943
|
+
context.recordScopeId(pending, '1');
|
|
3944
|
+
await context.persistScopeIds();
|
|
3945
|
+
let registered;
|
|
3946
|
+
try {
|
|
3947
|
+
registered = await ai.setWebhookAddress(org.id, d.webhook);
|
|
3948
|
+
}
|
|
3949
|
+
catch (error) {
|
|
3950
|
+
// Refused: no secret exists to lose.
|
|
3951
|
+
context.recordScopeId(pending, '');
|
|
3952
|
+
await context.persistScopeIds();
|
|
3953
|
+
throw error;
|
|
3954
|
+
}
|
|
3955
|
+
if (registered.secret) {
|
|
3956
|
+
await writeEnvs('the webhook secret', 'of the registered webhook', [
|
|
3957
|
+
{ key: 'AI_WEBHOOK_SECRET', value: registered.secret },
|
|
3958
|
+
]);
|
|
3959
|
+
}
|
|
3960
|
+
context.recordScopeId(pending, '');
|
|
3961
|
+
await context.persistScopeIds();
|
|
3962
|
+
}
|
|
3963
|
+
const envs = [
|
|
3964
|
+
{ key: 'AI_URL', value: d.aiUrl },
|
|
3965
|
+
{ key: 'AI_ORGANISATION_ID', value: org.id },
|
|
3966
|
+
{ key: 'AI_OFFERINGS_JSON', value: JSON.stringify(d.offerings) },
|
|
3967
|
+
];
|
|
3968
|
+
if (d.mode !== 'settings') {
|
|
3969
|
+
const minterKeyId = recorded[`ai:key:${org.id}`];
|
|
3970
|
+
if (!minterKeyId)
|
|
3971
|
+
throw new Error(`${at}: the org's own ai key is not recorded yet`);
|
|
3972
|
+
const minted = await ai.mintNestedKey({
|
|
3973
|
+
organisationId: org.id,
|
|
3974
|
+
minterKeyId,
|
|
3975
|
+
name: d.name,
|
|
3976
|
+
expiresDays: d.expiresDays,
|
|
3977
|
+
permissions: d.permissions,
|
|
3978
|
+
});
|
|
3979
|
+
context.recordScopeId(entryAiUndeliveredScopeKey(entryKey), minted.id);
|
|
3980
|
+
context.recordScopeId(entryAiPendingExpiresScopeKey(entryKey), minted.expiresAt);
|
|
3981
|
+
await context.persistScopeIds();
|
|
3982
|
+
await writeEnvs('key', minted.id, [...envs, { key: 'AI_API_KEY', value: minted.secret }]);
|
|
3983
|
+
// The env holds the key now; a restart that fails from here plans `finish`.
|
|
3984
|
+
context.recordScopeId(entryAiWrittenScopeKey(entryKey), minted.id);
|
|
3985
|
+
await context.persistScopeIds();
|
|
3986
|
+
resourceId = minted.id;
|
|
3987
|
+
expiresAt = minted.expiresAt;
|
|
3988
|
+
}
|
|
3989
|
+
else {
|
|
3990
|
+
await writeEnvs('settings', 'of the entry', envs);
|
|
3991
|
+
}
|
|
3992
|
+
await this.ports.coolify.restart(appId);
|
|
3993
|
+
}
|
|
3994
|
+
if (expiresAt) {
|
|
3995
|
+
context.recordScopeId(entryAiExpiresScopeKey(entryKey), expiresAt);
|
|
3996
|
+
// ai clamps the expiry to the org key's; a key already inside the
|
|
3997
|
+
// renewal window must not be replaced by another as short.
|
|
3998
|
+
const minter = recorded[`ai:key:${org.id}`] ?? '';
|
|
3999
|
+
const clamped = !(Date.parse(expiresAt) - Date.now() >=
|
|
4000
|
+
AI_ENTRY_RENEW_WINDOW_DAYS * 24 * 60 * 60 * 1000);
|
|
4001
|
+
context.recordScopeId(entryAiClampedScopeKey(entryKey), clamped ? JSON.stringify({ expiresAt, minter }) : '');
|
|
4002
|
+
context.recordScopeId(entryAiUndeliveredScopeKey(entryKey), '');
|
|
4003
|
+
context.recordScopeId(entryAiWrittenScopeKey(entryKey), '');
|
|
4004
|
+
}
|
|
4005
|
+
// Follows the wanted state, so a removed `webhookPath` plans nothing again.
|
|
4006
|
+
context.recordScopeId(entryAiAppliedScopeKey(entryKey), JSON.stringify({
|
|
4007
|
+
permissions: d.permissions,
|
|
4008
|
+
offerings: d.offerings,
|
|
4009
|
+
webhook: d.webhook,
|
|
4010
|
+
aiUrl: d.aiUrl,
|
|
4011
|
+
}));
|
|
4012
|
+
if (expiresAt && previousKeyId && previousKeyId !== resourceId) {
|
|
4013
|
+
await this.revokeEntryKey(ai, org, entryKey, previousKeyId, context);
|
|
4014
|
+
}
|
|
4015
|
+
return { resourceId };
|
|
4016
|
+
}
|
|
4017
|
+
/**
|
|
4018
|
+
* REVOKE SEAM (ai#157): revokes through `revokeKey` when the port has one.
|
|
4019
|
+
* Without it, or when it fails, the id joins the entry's list of keys left
|
|
4020
|
+
* live, which the plan shows, and the step goes on: the app already has its
|
|
4021
|
+
* new key.
|
|
4022
|
+
*/
|
|
4023
|
+
async revokeEntryKey(ai, org, entryKey, keyId, context) {
|
|
4024
|
+
if (ai.revokeKey) {
|
|
4025
|
+
try {
|
|
4026
|
+
await ai.revokeKey(org.id, keyId);
|
|
4027
|
+
return;
|
|
4028
|
+
}
|
|
4029
|
+
catch {
|
|
4030
|
+
// Listed below instead; the cause is dropped.
|
|
4031
|
+
}
|
|
4032
|
+
}
|
|
4033
|
+
const listKey = entryAiUnrevokedScopeKey(entryKey);
|
|
4034
|
+
const ids = parseIdList(context.recordedIds()[listKey]);
|
|
4035
|
+
if (!ids.includes(keyId))
|
|
4036
|
+
ids.push(keyId);
|
|
4037
|
+
context.recordScopeId(listKey, JSON.stringify(ids));
|
|
4038
|
+
}
|
|
4039
|
+
/**
|
|
4040
|
+
* F6b: mints or renews the org's own files key, or revokes the ones a
|
|
4041
|
+
* renewal replaced. The key is minted below the files organisation's owner
|
|
4042
|
+
* (the member `files_org_owner` records) and its one-time secret goes
|
|
4043
|
+
* straight to {@link FoundryStepExecutorPorts.deliverSecret}. The id is
|
|
4044
|
+
* recorded as undelivered first, so a delivery that fails never leads to a
|
|
4045
|
+
* second mint. Entries are minted below it with `issueKeyBelow`, which needs
|
|
4046
|
+
* no secret.
|
|
4047
|
+
*
|
|
4048
|
+
* A renewal keeps the old key live: its id goes to `files_key_previous`, the
|
|
4049
|
+
* entries are re-minted below the new key by their own steps, and
|
|
4050
|
+
* `revoke-previous` revokes the old key only when every entry holds a key
|
|
4051
|
+
* below the new one. A failed revoke is listed, not retried.
|
|
4052
|
+
*/
|
|
4053
|
+
async filesRootKey(action, org, context) {
|
|
4054
|
+
const at = `files:key for ${org.slug}`;
|
|
4055
|
+
const files = await this.filesPort();
|
|
4056
|
+
const mode = action.desired.mode ?? 'mint';
|
|
4057
|
+
const recorded = context.recordedIds();
|
|
4058
|
+
if (mode === 'revoke-previous') {
|
|
4059
|
+
const previous = parseFilesIdList(recorded[FILES_ROOT_PREVIOUS_SCOPE_KEY]);
|
|
4060
|
+
const rootId = recorded[`files:key:${org.id}`];
|
|
4061
|
+
if (previous.length === 0 || !rootId)
|
|
4062
|
+
return {};
|
|
4063
|
+
// Every entry must already hold a key below the new org key; otherwise
|
|
4064
|
+
// an app would lose its key. The next plan asks again.
|
|
4065
|
+
const ready = org.hosting.entries
|
|
4066
|
+
.filter((e) => e.uses?.files)
|
|
4067
|
+
.every((e) => recorded[`files:key:${org.id}:${e.key}`] &&
|
|
4068
|
+
recorded[entryFilesMinterScopeKey(e.key)] === rootId &&
|
|
4069
|
+
!recorded[entryFilesUndeliveredScopeKey(e.key)]);
|
|
4070
|
+
if (!ready)
|
|
4071
|
+
return {};
|
|
4072
|
+
const unrevoked = parseFilesIdList(recorded[FILES_ROOT_UNREVOKED_SCOPE_KEY]);
|
|
4073
|
+
for (const id of previous) {
|
|
4074
|
+
try {
|
|
4075
|
+
await files.revokeOrganisationKey(org.id, id);
|
|
4076
|
+
}
|
|
4077
|
+
catch {
|
|
4078
|
+
if (!unrevoked.includes(id))
|
|
4079
|
+
unrevoked.push(id);
|
|
4080
|
+
}
|
|
4081
|
+
}
|
|
4082
|
+
context.recordScopeId(FILES_ROOT_UNREVOKED_SCOPE_KEY, unrevoked.length ? JSON.stringify(unrevoked) : '');
|
|
4083
|
+
context.recordScopeId(FILES_ROOT_PREVIOUS_SCOPE_KEY, '');
|
|
4084
|
+
return {};
|
|
4085
|
+
}
|
|
4086
|
+
const issuer = recorded[FILES_ORG_OWNER_SCOPE_KEY];
|
|
4087
|
+
if (!issuer) {
|
|
4088
|
+
throw new Error(`${at}: the files organisation has no recorded owner to mint the key below`);
|
|
4089
|
+
}
|
|
4090
|
+
let issued;
|
|
4091
|
+
try {
|
|
4092
|
+
issued = await files.issueOrganisationKey(org.id, {
|
|
4093
|
+
name: action.desired.name,
|
|
4094
|
+
kind: 'service',
|
|
4095
|
+
issuer,
|
|
4096
|
+
permissions: action.desired.permissions,
|
|
4097
|
+
expiresDays: action.desired.expiresDays,
|
|
4098
|
+
});
|
|
4099
|
+
}
|
|
4100
|
+
catch (error) {
|
|
4101
|
+
throw new Error(`${at}: files refused the key (${filesErrorCause(error)})`);
|
|
4102
|
+
}
|
|
4103
|
+
context.recordScopeId(FILES_ROOT_UNDELIVERED_SCOPE_KEY, issued.id);
|
|
4104
|
+
await context.persistScopeIds();
|
|
4105
|
+
let cause = 'no HTTP status';
|
|
4106
|
+
for (let attempt = 1; attempt <= AI_KEY_DELIVERY_ATTEMPTS; attempt++) {
|
|
4107
|
+
try {
|
|
4108
|
+
await this.ports.deliverSecret(`files:key:${org.slug}`, issued.secret);
|
|
4109
|
+
// The expiry and the replaced id follow the delivery: before it, the
|
|
4110
|
+
// recorded key is still the one in use.
|
|
4111
|
+
context.recordScopeId(FILES_ROOT_EXPIRES_SCOPE_KEY, issued.expiresAt);
|
|
4112
|
+
const replaced = recorded[`files:key:${org.id}`];
|
|
4113
|
+
if (mode === 'renew' && replaced) {
|
|
4114
|
+
const previous = parseFilesIdList(recorded[FILES_ROOT_PREVIOUS_SCOPE_KEY]);
|
|
4115
|
+
if (!previous.includes(replaced))
|
|
4116
|
+
previous.push(replaced);
|
|
4117
|
+
context.recordScopeId(FILES_ROOT_PREVIOUS_SCOPE_KEY, JSON.stringify(previous));
|
|
4118
|
+
}
|
|
4119
|
+
return { resourceId: issued.id };
|
|
4120
|
+
}
|
|
4121
|
+
catch (error) {
|
|
4122
|
+
cause = filesErrorCause(error);
|
|
4123
|
+
}
|
|
4124
|
+
}
|
|
4125
|
+
throw new Error(`${at}: key ${issued.id} was minted but its secret could not be delivered after ${AI_KEY_DELIVERY_ATTEMPTS} attempts (${cause}); revoke it in files before a new one is minted`);
|
|
4126
|
+
}
|
|
4127
|
+
/**
|
|
4128
|
+
* F6b: mints, renews, finishes, re-writes or retires one entry's files key.
|
|
4129
|
+
*
|
|
4130
|
+
* Same order as the ai entry key: the collections are ensured first (they
|
|
4131
|
+
* hold no secret and can be repeated), the new key is minted below the
|
|
4132
|
+
* org's own key, its id is recorded and persisted as undelivered, the env
|
|
4133
|
+
* is written (up to three tries), that write is recorded, the app is
|
|
4134
|
+
* restarted, and only then is the old key revoked. A failed write keeps the
|
|
4135
|
+
* old key in use and the planner blocks instead of minting again. A failed
|
|
4136
|
+
* restart after the write plans `finish`, which restarts and records,
|
|
4137
|
+
* minting nothing. A secret reaches `bulkUpsertEnvs` and nothing else.
|
|
4138
|
+
*/
|
|
4139
|
+
async filesEntryKey(action, org, context) {
|
|
4140
|
+
const entryKey = action.key.slice(org.id.length + 1);
|
|
4141
|
+
const at = `files:key for ${org.slug} entry '${entryKey}'`;
|
|
4142
|
+
const desired = action.desired;
|
|
4143
|
+
const files = await this.filesPort();
|
|
4144
|
+
if (desired.mode === 'retire') {
|
|
4145
|
+
const recordedNow = context.recordedIds();
|
|
4146
|
+
const keyId = recordedNow[`files:key:${action.key}`];
|
|
4147
|
+
// A key minted but not delivered, or written but not yet restarted, is live too.
|
|
4148
|
+
const looseId = recordedNow[entryFilesUndeliveredScopeKey(entryKey)];
|
|
4149
|
+
for (const id of new Set([keyId, looseId].filter((v) => Boolean(v)))) {
|
|
4150
|
+
await this.revokeFilesEntryKey(files, org, entryKey, id, context);
|
|
4151
|
+
}
|
|
4152
|
+
if (keyId)
|
|
4153
|
+
context.recordScopeId(entryFilesRetiredScopeKey(entryKey), keyId);
|
|
4154
|
+
context.recordScopeId(entryFilesUndeliveredScopeKey(entryKey), '');
|
|
4155
|
+
context.recordScopeId(entryFilesWrittenScopeKey(entryKey), '');
|
|
4156
|
+
return {};
|
|
4157
|
+
}
|
|
4158
|
+
const entry = org.hosting.entries.find((e) => e.key === entryKey && e.uses?.files);
|
|
4159
|
+
if (!entry)
|
|
4160
|
+
throw new Error(`${at}: the entry no longer declares uses.files`);
|
|
4161
|
+
const d = action.desired;
|
|
4162
|
+
const recorded = context.recordedIds();
|
|
4163
|
+
const appId = recorded[`coolify:app:${action.key}`];
|
|
4164
|
+
if (!appId)
|
|
4165
|
+
throw new Error(`${at}: the entry's app is not recorded yet`);
|
|
4166
|
+
const writeEnvs = async (what, ids, envs) => {
|
|
4167
|
+
let cause = 'no HTTP status';
|
|
4168
|
+
for (let attempt = 1; attempt <= AI_KEY_DELIVERY_ATTEMPTS; attempt++) {
|
|
4169
|
+
try {
|
|
4170
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, envs.map((e) => ({ ...e, literal: true })));
|
|
4171
|
+
return;
|
|
4172
|
+
}
|
|
4173
|
+
catch (error) {
|
|
4174
|
+
// Only the status is kept: the message can echo a value.
|
|
4175
|
+
cause = filesErrorCause(error);
|
|
4176
|
+
}
|
|
4177
|
+
}
|
|
4178
|
+
throw new Error(`${at}: ${what} ${ids} could not be written to the app's env after ${AI_KEY_DELIVERY_ATTEMPTS} attempts (${cause})`);
|
|
4179
|
+
};
|
|
4180
|
+
const previousKeyId = recorded[`files:key:${action.key}`];
|
|
4181
|
+
let resourceId = previousKeyId;
|
|
4182
|
+
let expiresAt;
|
|
4183
|
+
if (d.mode === 'finish') {
|
|
4184
|
+
// The env already holds the key recorded as undelivered (`planFilesKeys`).
|
|
4185
|
+
resourceId = recorded[entryFilesUndeliveredScopeKey(entryKey)];
|
|
4186
|
+
expiresAt = recorded[entryFilesPendingExpiresScopeKey(entryKey)];
|
|
4187
|
+
await this.ports.coolify.restart(appId);
|
|
4188
|
+
}
|
|
4189
|
+
else {
|
|
4190
|
+
// Repeatable by name, so each apply asks again and a collection is never created twice.
|
|
4191
|
+
const envs = [
|
|
4192
|
+
{ key: 'FILES_URL', value: d.filesUrl },
|
|
4193
|
+
{ key: 'FILES_ORGANISATION_ID', value: org.id },
|
|
4194
|
+
];
|
|
4195
|
+
for (const name of d.collections) {
|
|
4196
|
+
let collection;
|
|
4197
|
+
try {
|
|
4198
|
+
collection = await files.ensureCollection(org.id, name);
|
|
4199
|
+
}
|
|
4200
|
+
catch (error) {
|
|
4201
|
+
throw new Error(`${at}: collection ${name} could not be ensured (${filesErrorCause(error)})`);
|
|
4202
|
+
}
|
|
4203
|
+
envs.push({ key: filesCollectionEnvName(name), value: collection.id });
|
|
4204
|
+
}
|
|
4205
|
+
if (d.mode !== 'settings') {
|
|
4206
|
+
const minterId = recorded[`files:key:${org.id}`];
|
|
4207
|
+
if (!minterId)
|
|
4208
|
+
throw new Error(`${at}: the org's own files key is not recorded yet`);
|
|
4209
|
+
// files refuses a key that outlives its minter. A minute is held back
|
|
4210
|
+
// so the call, which files dates later, still lands inside it.
|
|
4211
|
+
const rootExpires = Date.parse(recorded[FILES_ROOT_EXPIRES_SCOPE_KEY] ?? '');
|
|
4212
|
+
if (Number.isNaN(rootExpires)) {
|
|
4213
|
+
throw new Error(`${at}: the org's own files key expiry is not recorded`);
|
|
4214
|
+
}
|
|
4215
|
+
const expiresDays = Math.min(FILES_KEY_DAYS, Math.floor((rootExpires - (context.now?.() ?? Date.now()) - 60_000) / DAY_MS));
|
|
4216
|
+
if (expiresDays < FILES_KEY_RENEW_WINDOW_DAYS) {
|
|
4217
|
+
throw new Error(`${at}: the org's own files key leaves under ${FILES_KEY_RENEW_WINDOW_DAYS} days; mint a new org key first`);
|
|
4218
|
+
}
|
|
4219
|
+
let minted;
|
|
4220
|
+
try {
|
|
4221
|
+
minted = await files.issueKeyBelow(org.id, minterId, {
|
|
4222
|
+
name: d.name,
|
|
4223
|
+
kind: 'service',
|
|
4224
|
+
permissions: d.permissions,
|
|
4225
|
+
expiresDays,
|
|
4226
|
+
});
|
|
4227
|
+
}
|
|
4228
|
+
catch (error) {
|
|
4229
|
+
throw new Error(`${at}: files refused the key (${filesErrorCause(error)})`);
|
|
4230
|
+
}
|
|
4231
|
+
context.recordScopeId(entryFilesUndeliveredScopeKey(entryKey), minted.id);
|
|
4232
|
+
context.recordScopeId(entryFilesPendingExpiresScopeKey(entryKey), minted.expiresAt);
|
|
4233
|
+
await context.persistScopeIds();
|
|
4234
|
+
await writeEnvs('key', minted.id, [
|
|
4235
|
+
...envs,
|
|
4236
|
+
{ key: 'FILES_API_KEY', value: minted.secret },
|
|
4237
|
+
]);
|
|
4238
|
+
// The env holds the key now; a restart that fails from here plans `finish`.
|
|
4239
|
+
context.recordScopeId(entryFilesWrittenScopeKey(entryKey), minted.id);
|
|
4240
|
+
context.recordScopeId(entryFilesMinterScopeKey(entryKey), minterId);
|
|
4241
|
+
await context.persistScopeIds();
|
|
4242
|
+
resourceId = minted.id;
|
|
4243
|
+
expiresAt = minted.expiresAt;
|
|
4244
|
+
}
|
|
4245
|
+
else {
|
|
4246
|
+
await writeEnvs('settings', 'of the entry', envs);
|
|
4247
|
+
}
|
|
4248
|
+
await this.ports.coolify.restart(appId);
|
|
4249
|
+
}
|
|
4250
|
+
if (expiresAt) {
|
|
4251
|
+
context.recordScopeId(entryFilesExpiresScopeKey(entryKey), expiresAt);
|
|
4252
|
+
context.recordScopeId(entryFilesUndeliveredScopeKey(entryKey), '');
|
|
4253
|
+
context.recordScopeId(entryFilesWrittenScopeKey(entryKey), '');
|
|
4254
|
+
}
|
|
4255
|
+
// Follows the wanted state, so an unchanged declaration plans nothing again.
|
|
4256
|
+
context.recordScopeId(entryFilesAppliedScopeKey(entryKey), JSON.stringify({
|
|
4257
|
+
permissions: d.permissions,
|
|
4258
|
+
collections: d.collections,
|
|
4259
|
+
filesUrl: d.filesUrl,
|
|
4260
|
+
}));
|
|
4261
|
+
if (expiresAt && previousKeyId && previousKeyId !== resourceId) {
|
|
4262
|
+
await this.revokeFilesEntryKey(files, org, entryKey, previousKeyId, context);
|
|
4263
|
+
}
|
|
4264
|
+
return { resourceId };
|
|
4265
|
+
}
|
|
4266
|
+
/**
|
|
4267
|
+
* Revokes one entry key. When the call fails the id joins the entry's list
|
|
4268
|
+
* of keys left live, which the plan shows, and the step goes on: the app
|
|
4269
|
+
* already has its new key.
|
|
4270
|
+
*/
|
|
4271
|
+
async revokeFilesEntryKey(files, org, entryKey, keyId, context) {
|
|
4272
|
+
try {
|
|
4273
|
+
await files.revokeOrganisationKey(org.id, keyId);
|
|
4274
|
+
return;
|
|
4275
|
+
}
|
|
4276
|
+
catch {
|
|
4277
|
+
// Listed below instead; the cause is dropped.
|
|
4278
|
+
}
|
|
4279
|
+
const listKey = entryFilesUnrevokedScopeKey(entryKey);
|
|
4280
|
+
const ids = parseFilesIdList(context.recordedIds()[listKey]);
|
|
4281
|
+
if (!ids.includes(keyId))
|
|
4282
|
+
ids.push(keyId);
|
|
4283
|
+
context.recordScopeId(listKey, JSON.stringify(ids));
|
|
4284
|
+
}
|
|
3517
4285
|
// ---- destroy -------------------------------------------------------------
|
|
3518
4286
|
/**
|
|
3519
4287
|
* {@link StepDestroyer.destroy}: deletes the one resource
|
|
@@ -3652,6 +4420,20 @@ export class FoundryStepExecutor {
|
|
|
3652
4420
|
case 'cloudflare:access':
|
|
3653
4421
|
await this.ports.cloudflare.deleteAccessApp(target.id);
|
|
3654
4422
|
return;
|
|
4423
|
+
case 'ai:key': {
|
|
4424
|
+
// F6a: an entry's key (`<orgId>:<entry>`). REVOKE SEAM (ai#157): a
|
|
4425
|
+
// port with no `revokeKey` leaves it live until it expires.
|
|
4426
|
+
const ai = await this.aiPort();
|
|
4427
|
+
if (ai.revokeKey)
|
|
4428
|
+
await ai.revokeKey(org.id, target.id);
|
|
4429
|
+
return;
|
|
4430
|
+
}
|
|
4431
|
+
case 'files:key': {
|
|
4432
|
+
// F6b: an entry's key (`<orgId>:<entry>`); the org's own key is left alone.
|
|
4433
|
+
const files = await this.filesPort();
|
|
4434
|
+
await files.revokeOrganisationKey(org.id, target.id);
|
|
4435
|
+
return;
|
|
4436
|
+
}
|
|
3655
4437
|
case 'coolify:app':
|
|
3656
4438
|
await this.ports.coolify.deleteApplication(target.id);
|
|
3657
4439
|
return;
|