@wtfalch/foundry 0.16.15 → 0.17.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/apply.d.ts +22 -1
- package/dist/bootstrap/bootstrap.d.ts +6 -1
- package/dist/bootstrap/bootstrap.js +26 -3
- package/dist/bootstrap/declare.d.ts +11 -1
- package/dist/bootstrap/declare.js +12 -2
- package/dist/index.d.ts +9 -4
- package/dist/index.js +7 -0
- package/dist/migrations/0038_calls_version_pin.sql +8 -0
- package/dist/migrations/0039_destroy_record.sql +7 -0
- package/dist/migrations/0040_company_included_uuid.sql +72 -0
- package/dist/migrations/0041_company_included_uuid_keys.sql +132 -0
- package/dist/migrations/0042_mutation_leases.sql +12 -0
- package/dist/org.d.ts +82 -5
- package/dist/org.js +62 -1
- package/dist/plan/adopt.d.ts +76 -0
- package/dist/plan/adopt.js +184 -0
- package/dist/plan/agora.d.ts +21 -0
- package/dist/plan/agora.js +37 -0
- package/dist/plan/broker-entries.d.ts +9 -1
- package/dist/plan/broker-entries.js +13 -5
- package/dist/plan/chat-matrix.d.ts +1 -1
- package/dist/plan/chat-matrix.js +11 -3
- package/dist/plan/core-files.d.ts +25 -0
- package/dist/plan/core-files.js +25 -0
- package/dist/plan/deploy-health.d.ts +136 -0
- package/dist/plan/deploy-health.js +153 -0
- package/dist/plan/diff.d.ts +11 -3
- package/dist/plan/diff.js +77 -71
- package/dist/plan/entry-ai-key.d.ts +3 -3
- package/dist/plan/entry-files-key.d.ts +6 -4
- package/dist/plan/entry-files-key.js +7 -10
- package/dist/plan/executor.d.ts +109 -28
- package/dist/plan/executor.js +213 -56
- package/dist/plan/hostnames.d.ts +6 -0
- package/dist/plan/hostnames.js +13 -0
- package/dist/plan/index.d.ts +5 -1
- package/dist/plan/index.js +3 -1
- package/dist/plan/live.d.ts +5 -0
- package/dist/plan/login-host.d.ts +1 -1
- package/dist/plan/login-host.js +6 -5
- package/dist/plan/plan-apply.d.ts +153 -3
- package/dist/plan/plan-apply.js +584 -69
- package/dist/plan/platform-diff.d.ts +103 -9
- package/dist/plan/platform-diff.js +192 -14
- package/dist/plan/platform-org.d.ts +25 -0
- package/dist/plan/platform-org.js +17 -1
- package/dist/plan/platform-providers.d.ts +38 -5
- package/dist/plan/platform-providers.js +592 -109
- package/dist/plan/providers-readers.d.ts +2 -0
- package/dist/plan/providers-readers.js +4 -0
- package/dist/plan/rollout.d.ts +52 -0
- package/dist/plan/rollout.js +37 -0
- package/dist/plan/step-system.d.ts +22 -0
- package/dist/plan/step-system.js +37 -0
- package/dist/plan.d.ts +3 -0
- package/dist/state.d.ts +13 -0
- package/dist/store/audit.js +15 -1
- package/dist/store/postgres.d.ts +1 -1
- package/dist/store/postgres.js +127 -14
- package/dist/store/schema.d.ts +134 -5
- package/dist/store/schema.js +12 -1
- package/dist/store.d.ts +47 -4
- package/dist/store.js +9 -1
- package/package.json +2 -2
package/dist/plan/executor.js
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
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, 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';
|
|
4
|
+
import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, sharedSecretRef, } from '../org.js';
|
|
5
|
+
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, 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, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
|
|
6
6
|
import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
7
7
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
8
8
|
import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
9
9
|
import { CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, chatInternalNames, chatMasConfigYaml, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
|
|
10
10
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
11
|
+
import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
|
|
11
12
|
import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScopeKey, entryAiExpiresScopeKey, entryAiPendingExpiresScopeKey, entryAiRetiredScopeKey, entryAiUndeliveredScopeKey, entryAiUnrevokedScopeKey, entryAiWebhookPendingScopeKey, entryAiWrittenScopeKey, parseIdList, } from './entry-ai-key.js';
|
|
12
13
|
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';
|
|
13
14
|
import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
|
|
14
15
|
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
15
16
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
16
17
|
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';
|
|
18
|
+
import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
|
|
17
19
|
import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
|
|
18
20
|
/**
|
|
19
21
|
* #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
|
|
@@ -209,14 +211,16 @@ const RATE_LIMIT_REQUESTS_PER_PERIOD = 10;
|
|
|
209
211
|
const DEFAULT_APP_REPO = 'wtfalch/foundry';
|
|
210
212
|
const DEFAULT_APP_DOCKERFILE = '/apps/hestia/Dockerfile';
|
|
211
213
|
/**
|
|
212
|
-
* The
|
|
213
|
-
* (https://github.com/wtfalch/cms) -- the
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* `dockerfile_location`. `CMS_APP_DOCKERFILE`
|
|
218
|
-
* `Dockerfile` (`apps/host`, single-org mode);
|
|
219
|
-
* is `apps/site`'s own Dockerfile (the reference
|
|
214
|
+
* The shared repo `cmsSite` builds its cms admin app from, always, and its
|
|
215
|
+
* site app from by default (https://github.com/wtfalch/cms) -- the
|
|
216
|
+
* 2026-09-25 decision this campaign implements
|
|
217
|
+
* (`.plans/2026-09-25-org-sites.md`, that repo): foundry never calls a cms
|
|
218
|
+
* admin API for the org's site, it provisions two ordinary Coolify
|
|
219
|
+
* applications, told apart by `dockerfile_location`. `CMS_APP_DOCKERFILE`
|
|
220
|
+
* is the repo's own root `Dockerfile` (`apps/host`, single-org mode);
|
|
221
|
+
* `CMS_SITE_APP_DOCKERFILE` is `apps/site`'s own Dockerfile (the reference
|
|
222
|
+
* renderer). An org with `site.repo` set (#245) builds its site app from
|
|
223
|
+
* that repo instead, the sample skin otherwise ships to every org.
|
|
220
224
|
*/
|
|
221
225
|
const CMS_REPO = 'wtfalch/cms';
|
|
222
226
|
const CMS_APP_DOCKERFILE = '/Dockerfile';
|
|
@@ -276,6 +280,8 @@ const LOG_DIR = '/app/logs';
|
|
|
276
280
|
*/
|
|
277
281
|
const CMS_APP_SCOPE_KEY = 'coolify_cms_app';
|
|
278
282
|
const SITE_APP_SCOPE_KEY = 'coolify_site_app';
|
|
283
|
+
/** #445: the one Coolify server every resource of this org lands on, chosen once and recorded. */
|
|
284
|
+
const COOLIFY_SERVER_SCOPE_KEY = 'coolify_server';
|
|
279
285
|
/**
|
|
280
286
|
* #117: the cms app's own Postgres database (`<org.slug>-cms-db`), found or
|
|
281
287
|
* created inside `cmsSite` the same way `coolifyDatabase` does for the org's
|
|
@@ -658,8 +664,6 @@ export class FoundryStepExecutor {
|
|
|
658
664
|
return action.key.includes(':')
|
|
659
665
|
? this.filesEntryKey(action, org, context)
|
|
660
666
|
: this.filesRootKey(action, org, context);
|
|
661
|
-
case 'files:storage':
|
|
662
|
-
return this.filesStorage(action, org);
|
|
663
667
|
case 'ai:organisation':
|
|
664
668
|
return this.aiOrganisation(action, org);
|
|
665
669
|
case 'ai:budget':
|
|
@@ -861,11 +865,16 @@ export class FoundryStepExecutor {
|
|
|
861
865
|
if (!zitadelOrgId) {
|
|
862
866
|
throw new Error(`zitadel:owner for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
|
|
863
867
|
}
|
|
868
|
+
// ZITADEL's default invite link (`/ui/v2/login/verify`) 404s; the platform's login app, which
|
|
869
|
+
// every org's users sign in through on this instance, serves `/verify` at the root.
|
|
870
|
+
const authIssuer = action.desired.authIssuer;
|
|
864
871
|
const { userId } = await this.ports.zitadel.inviteOrgOwner(zitadelOrgId, {
|
|
865
872
|
givenName: action.desired.givenName,
|
|
866
873
|
familyName: action.desired.familyName,
|
|
867
874
|
email: action.desired.email,
|
|
868
|
-
}
|
|
875
|
+
}, authIssuer
|
|
876
|
+
? `${authIssuer}/verify?userId={{.UserID}}&code={{.Code}}&organization={{.OrgID}}&invite=true`
|
|
877
|
+
: undefined);
|
|
869
878
|
// An owner invited after chat was built never reaches `coolifyChat`
|
|
870
879
|
// again, so the role is granted here once the messaging project exists.
|
|
871
880
|
const messagingProjectId = context.recordedIds()[ZITADEL_MESSAGING_PROJECT_SCOPE_KEY];
|
|
@@ -977,6 +986,7 @@ export class FoundryStepExecutor {
|
|
|
977
986
|
.map((key) => key.slice(ENTRY_CLIENT_ID_SCOPE_PREFIX.length)),
|
|
978
987
|
...(recorded[CMS_CLIENT_ID_SCOPE_KEY] ? ['cms'] : []),
|
|
979
988
|
...(recorded[AGORA_CLIENT_ID_SCOPE_KEY] ? ['agora'] : []),
|
|
989
|
+
...(recorded[BOULE_CLIENT_ID_SCOPE_KEY] ? ['boule'] : []),
|
|
980
990
|
].map((name) => recorded[`zitadel:app:${name}`]));
|
|
981
991
|
const projectId = recorded.zitadel_project;
|
|
982
992
|
// No project yet means no apps yet: vacuously safe to decide from (fix
|
|
@@ -1250,11 +1260,37 @@ export class FoundryStepExecutor {
|
|
|
1250
1260
|
context.recordScopeId('coolify_project', projectUuid);
|
|
1251
1261
|
return projectUuid;
|
|
1252
1262
|
}
|
|
1253
|
-
|
|
1263
|
+
/**
|
|
1264
|
+
* #445: one company lands on one Coolify server. The first call for an
|
|
1265
|
+
* org picks a usable, reachable server and records it
|
|
1266
|
+
* ({@link COOLIFY_SERVER_SCOPE_KEY}); every later call -- this apply, or a
|
|
1267
|
+
* later one -- reads that recording back and returns the same server,
|
|
1268
|
+
* regardless of what the pool's order or health looks like by then. A
|
|
1269
|
+
* recorded server that has gone unusable or unreachable is reported
|
|
1270
|
+
* blocked (never silently replaced with a second host, which would split
|
|
1271
|
+
* the org's resources across servers and break their internal container
|
|
1272
|
+
* URLs); a recorded server no longer registered at all is the same.
|
|
1273
|
+
* Deliberately no fallback to `servers[0]` when nothing is usable: an
|
|
1274
|
+
* unusable server picked blind is not a server anyone's resources could
|
|
1275
|
+
* actually be created on.
|
|
1276
|
+
*/
|
|
1277
|
+
async resolveServer(org, context) {
|
|
1254
1278
|
const servers = await this.ports.coolify.listServers();
|
|
1255
|
-
const
|
|
1279
|
+
const recorded = context.recordedIds()[COOLIFY_SERVER_SCOPE_KEY];
|
|
1280
|
+
if (recorded) {
|
|
1281
|
+
const server = servers.find((s) => s.uuid === recorded);
|
|
1282
|
+
if (!server) {
|
|
1283
|
+
throw new Error(`${org.slug}'s recorded Coolify server ${recorded} is no longer registered on this instance`);
|
|
1284
|
+
}
|
|
1285
|
+
if (!(server.is_usable && server.is_reachable)) {
|
|
1286
|
+
throw new Error(`${org.slug}'s recorded Coolify server ${recorded} is not usable/reachable right now; it is not moved to a second server`);
|
|
1287
|
+
}
|
|
1288
|
+
return server;
|
|
1289
|
+
}
|
|
1290
|
+
const server = servers.find((s) => s.is_usable && s.is_reachable);
|
|
1256
1291
|
if (!server)
|
|
1257
1292
|
throw new Error('no usable Coolify server is registered on this instance');
|
|
1293
|
+
context.recordScopeId(COOLIFY_SERVER_SCOPE_KEY, server.uuid);
|
|
1258
1294
|
return server;
|
|
1259
1295
|
}
|
|
1260
1296
|
async coolifyApp(action, org, context) {
|
|
@@ -1320,7 +1356,7 @@ export class FoundryStepExecutor {
|
|
|
1320
1356
|
haveNames = appliedAppDomains(org, context.recordedIds(), entry.key);
|
|
1321
1357
|
}
|
|
1322
1358
|
else {
|
|
1323
|
-
const server = await this.resolveServer();
|
|
1359
|
+
const server = await this.resolveServer(org, context);
|
|
1324
1360
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
1325
1361
|
const created = await this.ports.coolify.createGithubAppApplication({
|
|
1326
1362
|
project_uuid: projectUuid,
|
|
@@ -1424,7 +1460,13 @@ export class FoundryStepExecutor {
|
|
|
1424
1460
|
// The app receives whatever it needs to sign in (gap P2). Unconditional,
|
|
1425
1461
|
// unlike the database push above: every app has a hostname, and an
|
|
1426
1462
|
// upsert of the same value on a later apply is a no-op, not a change.
|
|
1427
|
-
const appEnv = await this.pushAppEnv(appId, org, entry, context, action.desired.authIssuer ?? null
|
|
1463
|
+
const appEnv = await this.pushAppEnv(appId, org, entry, context, action.desired.authIssuer ?? null,
|
|
1464
|
+
// What the plan decided is available (diff.ts `planApps`), so the marker
|
|
1465
|
+
// recorded below is the one the plan compares against. Absent: none.
|
|
1466
|
+
action.desired.secretSources ?? {
|
|
1467
|
+
platformMailKey: false,
|
|
1468
|
+
sharedSecret: false,
|
|
1469
|
+
});
|
|
1428
1470
|
// Rule 4 (#239, #243): a container reads its env only at start, so an app
|
|
1429
1471
|
// that was already there is restarted, once, when its database URLs or its
|
|
1430
1472
|
// declared appEnv changed.
|
|
@@ -1517,7 +1559,7 @@ export class FoundryStepExecutor {
|
|
|
1517
1559
|
dbId = found.uuid;
|
|
1518
1560
|
}
|
|
1519
1561
|
else {
|
|
1520
|
-
const server = await this.resolveServer();
|
|
1562
|
+
const server = await this.resolveServer(org, context);
|
|
1521
1563
|
const dbName = org.slug.replace(/-/g, '_');
|
|
1522
1564
|
const created = await this.ports.coolify.createPostgresDatabase({
|
|
1523
1565
|
project_uuid: projectUuid,
|
|
@@ -1633,7 +1675,7 @@ export class FoundryStepExecutor {
|
|
|
1633
1675
|
* that call runs only once the `app` entry's `database` is declared and
|
|
1634
1676
|
* provisioned, where this one is unconditional (#239).
|
|
1635
1677
|
*/
|
|
1636
|
-
async pushAppEnv(appId, org, entry, context, authIssuer) {
|
|
1678
|
+
async pushAppEnv(appId, org, entry, context, authIssuer, secretSources) {
|
|
1637
1679
|
const recorded = context.recordedIds();
|
|
1638
1680
|
const envs = [
|
|
1639
1681
|
// Only the `app` entry has a public name; the others are internal.
|
|
@@ -1691,14 +1733,44 @@ export class FoundryStepExecutor {
|
|
|
1691
1733
|
// minted here; a source with no value yet (its own step has not run,
|
|
1692
1734
|
// or has not run for this apply) is simply omitted, the same way every
|
|
1693
1735
|
// other optional entry above is.
|
|
1694
|
-
|
|
1736
|
+
// The secret sources (`sharedSecret`, `platformMailKey`) are filled here
|
|
1737
|
+
// only, straight into `envs`: never recorded, returned or logged.
|
|
1738
|
+
const heldKeys = this.ports.heldKeys;
|
|
1739
|
+
// Availability is the plan's (`secretSources`). If the plan said a key is
|
|
1740
|
+
// held and this port cannot read it, the host wired the two differently:
|
|
1741
|
+
// fail by ref, never push a marker for an env that was not written.
|
|
1742
|
+
let mailKey = null;
|
|
1743
|
+
if (secretSources.platformMailKey) {
|
|
1744
|
+
try {
|
|
1745
|
+
mailKey = (await heldKeys?.resolve(PLATFORM_AUTH_BROKER_MAIL_KEY_REF)) || null;
|
|
1746
|
+
}
|
|
1747
|
+
catch {
|
|
1748
|
+
// reported below, by ref
|
|
1749
|
+
}
|
|
1750
|
+
if (mailKey === null) {
|
|
1751
|
+
throw new Error(`coolify:app for ${org.slug}: the plan found ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF} held but the executor's heldKeys cannot read it`);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
if (secretSources.sharedSecret && typeof heldKeys?.put !== 'function') {
|
|
1755
|
+
throw new Error(`coolify:app for ${org.slug}: the plan found held keys that can put but the executor's heldKeys cannot`);
|
|
1756
|
+
}
|
|
1757
|
+
const resolved = resolveAppEnv(org, entry, recorded, secretSources);
|
|
1758
|
+
const appEnv = resolved.values;
|
|
1695
1759
|
for (const [key, value] of Object.entries(appEnv))
|
|
1696
1760
|
envs.push({ key, value });
|
|
1761
|
+
for (const { name, source } of resolved.secrets) {
|
|
1762
|
+
envs.push({
|
|
1763
|
+
key: name,
|
|
1764
|
+
value: source === 'platformMailKey'
|
|
1765
|
+
? mailKey
|
|
1766
|
+
: await this.ensureSharedSecret(org, source.name, recorded),
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1697
1769
|
await this.ports.coolify.bulkUpsertEnvs(appId, envs);
|
|
1698
1770
|
// `coolifyApp` records `applied` once the restart succeeded, so `planApp`
|
|
1699
1771
|
// replans this step when the declared map changes (rule 3). Ids and a
|
|
1700
1772
|
// domain, never a secret.
|
|
1701
|
-
const applied = JSON.stringify(
|
|
1773
|
+
const applied = JSON.stringify(resolved.marker);
|
|
1702
1774
|
// The sign-in values, recorded by `coolifyApp` the same way; the key is
|
|
1703
1775
|
// never part of them.
|
|
1704
1776
|
const authApplied = JSON.stringify(authEnv);
|
|
@@ -1710,6 +1782,28 @@ export class FoundryStepExecutor {
|
|
|
1710
1782
|
authApplied,
|
|
1711
1783
|
};
|
|
1712
1784
|
}
|
|
1785
|
+
/**
|
|
1786
|
+
* The org's shared secret `name`: read from the held keys, else generated
|
|
1787
|
+
* and held first, so a crash after the `put` and before the env write
|
|
1788
|
+
* hands the same value out on the next apply. Only called when `put` exists.
|
|
1789
|
+
* An error here names the ref, never a value.
|
|
1790
|
+
*/
|
|
1791
|
+
async ensureSharedSecret(org, name, recorded) {
|
|
1792
|
+
const heldKeys = this.ports.heldKeys;
|
|
1793
|
+
const ref = sharedSecretRef(org.id, name);
|
|
1794
|
+
const held = await heldKeys?.resolve(ref);
|
|
1795
|
+
if (held)
|
|
1796
|
+
return held;
|
|
1797
|
+
// Another entry (or this one) already got a value. Generating again would
|
|
1798
|
+
// split them, so a lost value needs a person, not a new mint.
|
|
1799
|
+
const label = `"secret:sharedSecret:${name}"`;
|
|
1800
|
+
if (Object.entries(recorded).some(([key, value]) => key.startsWith('app_env_applied.') && value.includes(label))) {
|
|
1801
|
+
throw new Error(`coolify:app for ${org.slug}: shared secret ${name} was delivered but is no longer held (ref ${ref}); restore the held value, or clear it from every entry's env and applied marker to mint a new one`);
|
|
1802
|
+
}
|
|
1803
|
+
const value = randomBytes(32).toString('base64url');
|
|
1804
|
+
await heldKeys?.put?.(ref, value);
|
|
1805
|
+
return value;
|
|
1806
|
+
}
|
|
1713
1807
|
/**
|
|
1714
1808
|
* F5: the app's `AUTH_APP_KEY`, minted once and only when the app has none
|
|
1715
1809
|
* of at least 32 characters (the broker refuses a shorter one). `null` once
|
|
@@ -2097,16 +2191,22 @@ export class FoundryStepExecutor {
|
|
|
2097
2191
|
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
2098
2192
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
2099
2193
|
const site = await this.findOrCreateCmsApp({
|
|
2194
|
+
org,
|
|
2195
|
+
context,
|
|
2100
2196
|
projectUuid,
|
|
2101
2197
|
environment,
|
|
2102
2198
|
name: `${org.slug}-site`,
|
|
2199
|
+
repo: org.site.repo ?? CMS_REPO,
|
|
2103
2200
|
dockerfileLocation: CMS_SITE_APP_DOCKERFILE,
|
|
2104
2201
|
domain,
|
|
2105
2202
|
});
|
|
2106
2203
|
const cms = await this.findOrCreateCmsApp({
|
|
2204
|
+
org,
|
|
2205
|
+
context,
|
|
2107
2206
|
projectUuid,
|
|
2108
2207
|
environment,
|
|
2109
2208
|
name: `${org.slug}-cms`,
|
|
2209
|
+
repo: CMS_REPO,
|
|
2110
2210
|
dockerfileLocation: CMS_APP_DOCKERFILE,
|
|
2111
2211
|
domain: cmsHostnameFor(org),
|
|
2112
2212
|
});
|
|
@@ -2217,7 +2317,7 @@ export class FoundryStepExecutor {
|
|
|
2217
2317
|
const found = (await this.ports.coolify.listDatabases()).find((d) => d.name === name && d.environment_id === environment.id);
|
|
2218
2318
|
if (found)
|
|
2219
2319
|
return found.uuid;
|
|
2220
|
-
const server = await this.resolveServer();
|
|
2320
|
+
const server = await this.resolveServer(org, context);
|
|
2221
2321
|
const dbName = name.replace(/-/g, '_');
|
|
2222
2322
|
const created = await this.ports.coolify.createPostgresDatabase({
|
|
2223
2323
|
project_uuid: projectUuid,
|
|
@@ -2304,6 +2404,10 @@ export class FoundryStepExecutor {
|
|
|
2304
2404
|
* `coolifyApp`'s own analogous gap (it never restarted a found-but-stopped
|
|
2305
2405
|
* app either) has since closed the same way, inline in that method (#130);
|
|
2306
2406
|
* this method is `cmsSite`'s only caller.
|
|
2407
|
+
*
|
|
2408
|
+
* `repo` (#245) lets the site app build from an org's own skin repo
|
|
2409
|
+
* (`org.site.repo`) instead of the shared `CMS_REPO`; `cmsSite` always
|
|
2410
|
+
* passes `CMS_REPO` for the cms admin app, which is never org-specific.
|
|
2307
2411
|
*/
|
|
2308
2412
|
async findOrCreateCmsApp(input) {
|
|
2309
2413
|
const existing = await this.ports.coolify.findApplicationByName(input.name, {
|
|
@@ -2316,7 +2420,7 @@ export class FoundryStepExecutor {
|
|
|
2316
2420
|
}
|
|
2317
2421
|
return { appId: existing.uuid, existed: true };
|
|
2318
2422
|
}
|
|
2319
|
-
const server = await this.resolveServer();
|
|
2423
|
+
const server = await this.resolveServer(input.org, input.context);
|
|
2320
2424
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
2321
2425
|
const created = await this.ports.coolify.createGithubAppApplication({
|
|
2322
2426
|
project_uuid: input.projectUuid,
|
|
@@ -2324,7 +2428,7 @@ export class FoundryStepExecutor {
|
|
|
2324
2428
|
environment_name: input.environment.name,
|
|
2325
2429
|
environment_uuid: input.environment.uuid,
|
|
2326
2430
|
github_app_uuid: githubApp.uuid,
|
|
2327
|
-
git_repository:
|
|
2431
|
+
git_repository: input.repo,
|
|
2328
2432
|
git_branch: 'main',
|
|
2329
2433
|
build_pack: 'dockerfile',
|
|
2330
2434
|
name: input.name,
|
|
@@ -2362,16 +2466,16 @@ export class FoundryStepExecutor {
|
|
|
2362
2466
|
}
|
|
2363
2467
|
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
2364
2468
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
2365
|
-
const server = await this.resolveServer();
|
|
2469
|
+
const server = await this.resolveServer(org, context);
|
|
2366
2470
|
const names = {
|
|
2367
2471
|
synapse: `${org.slug}-calls-synapse`,
|
|
2368
2472
|
mas: `${org.slug}-calls-mas`,
|
|
2369
2473
|
synapseDb: `${org.slug}-calls-synapse-db`,
|
|
2370
2474
|
masDb: `${org.slug}-calls-mas-db`,
|
|
2371
2475
|
};
|
|
2372
|
-
const synapseDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.synapseDb, 'synapse', CALLS_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
|
|
2476
|
+
const synapseDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.synapseDb, 'synapse', CALLS_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
|
|
2373
2477
|
context.recordScopeId(CALLS_SYNAPSE_DB_SCOPE_KEY, synapseDbId);
|
|
2374
|
-
const masDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.masDb, 'mas', CALLS_MAS_DB_SCOPE_KEY);
|
|
2478
|
+
const masDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.masDb, 'mas', CALLS_MAS_DB_SCOPE_KEY);
|
|
2375
2479
|
context.recordScopeId(CALLS_MAS_DB_SCOPE_KEY, masDbId);
|
|
2376
2480
|
const [synapseDbRow, masDbRow] = await Promise.all([
|
|
2377
2481
|
getDatabaseStarted(this.ports.coolify, synapseDbId),
|
|
@@ -2525,14 +2629,14 @@ export class FoundryStepExecutor {
|
|
|
2525
2629
|
* database, whose {@link CALLS_SERVICE_DB_SCOPE_KEY} has no
|
|
2526
2630
|
* `system:kind:key` slot of its own for the same reason.
|
|
2527
2631
|
*/
|
|
2528
|
-
async ensureCallsDatabase(context, projectUuid, environment, name, postgresDb, scopeKey, postgresInitdbArgs) {
|
|
2632
|
+
async ensureCallsDatabase(org, context, projectUuid, environment, name, postgresDb, scopeKey, postgresInitdbArgs) {
|
|
2529
2633
|
const existing = context.recordedIds()[scopeKey];
|
|
2530
2634
|
if (existing)
|
|
2531
2635
|
return existing;
|
|
2532
2636
|
const found = (await this.ports.coolify.listDatabases()).find((d) => d.name === name && d.environment_id === environment.id);
|
|
2533
2637
|
if (found)
|
|
2534
2638
|
return found.uuid;
|
|
2535
|
-
const server = await this.resolveServer();
|
|
2639
|
+
const server = await this.resolveServer(org, context);
|
|
2536
2640
|
const created = await this.ports.coolify.createPostgresDatabase({
|
|
2537
2641
|
project_uuid: projectUuid,
|
|
2538
2642
|
server_uuid: server.uuid,
|
|
@@ -2658,13 +2762,13 @@ export class FoundryStepExecutor {
|
|
|
2658
2762
|
await this.ensureChatAdmins(org, zitadelOrgId, project.id, recorded);
|
|
2659
2763
|
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
2660
2764
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
2661
|
-
const server = await this.resolveServer();
|
|
2765
|
+
const server = await this.resolveServer(org, context);
|
|
2662
2766
|
const names = chatInternalNames(org.slug);
|
|
2663
|
-
const synapseDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.synapseDb, 'synapse', CHAT_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
|
|
2767
|
+
const synapseDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.synapseDb, 'synapse', CHAT_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
|
|
2664
2768
|
context.recordScopeId(CHAT_SYNAPSE_DB_SCOPE_KEY, synapseDbId);
|
|
2665
|
-
const masDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.masDb, 'mas', CHAT_MAS_DB_SCOPE_KEY);
|
|
2769
|
+
const masDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.masDb, 'mas', CHAT_MAS_DB_SCOPE_KEY);
|
|
2666
2770
|
context.recordScopeId(CHAT_MAS_DB_SCOPE_KEY, masDbId);
|
|
2667
|
-
const serviceDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.serviceDb, 'chat', CHAT_SERVICE_DB_SCOPE_KEY);
|
|
2771
|
+
const serviceDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.serviceDb, 'chat', CHAT_SERVICE_DB_SCOPE_KEY);
|
|
2668
2772
|
context.recordScopeId(CHAT_SERVICE_DB_SCOPE_KEY, serviceDbId);
|
|
2669
2773
|
const [synapseDbRow, masDbRow, serviceDbRow] = await Promise.all([
|
|
2670
2774
|
getDatabaseStarted(this.ports.coolify, synapseDbId),
|
|
@@ -2840,13 +2944,13 @@ export class FoundryStepExecutor {
|
|
|
2840
2944
|
const synapseHost = `${org.slug}-calls-synapse`;
|
|
2841
2945
|
const masHost = `${org.slug}-calls-mas`;
|
|
2842
2946
|
const dbName = `${name}-db`;
|
|
2843
|
-
const dbId = await this.ensureCallsDatabase(context, projectUuid, environment, dbName, dbName.replace(/-/g, '_'), CALLS_SERVICE_DB_SCOPE_KEY);
|
|
2947
|
+
const dbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, dbName, dbName.replace(/-/g, '_'), CALLS_SERVICE_DB_SCOPE_KEY);
|
|
2844
2948
|
context.recordScopeId(CALLS_SERVICE_DB_SCOPE_KEY, dbId);
|
|
2845
2949
|
const db = await getDatabaseStarted(this.ports.coolify, dbId);
|
|
2846
2950
|
if (!db.internal_db_url) {
|
|
2847
2951
|
throw new Error(`coolify:calls-service for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
2848
2952
|
}
|
|
2849
|
-
const server = await this.resolveServer();
|
|
2953
|
+
const server = await this.resolveServer(org, context);
|
|
2850
2954
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
2851
2955
|
const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
2852
2956
|
project_uuid: projectUuid,
|
|
@@ -3059,6 +3163,19 @@ export class FoundryStepExecutor {
|
|
|
3059
3163
|
* pushed once calls are on and this org's calls service app carries its
|
|
3060
3164
|
* internal name (`desiredAgoraEnv` reads that marker from the recorded ids,
|
|
3061
3165
|
* not the host values).
|
|
3166
|
+
*
|
|
3167
|
+
* #442: a found-but-already-running app's container binds env only at
|
|
3168
|
+
* start, so writing it alone leaves that process on its old config --
|
|
3169
|
+
* restarted (never redeployed: no new image, so `restart` is the same
|
|
3170
|
+
* primitive `ensureLogVolume`'s own restart uses) whenever the live env
|
|
3171
|
+
* actually differs, or whenever the last restart this step confirmed
|
|
3172
|
+
* ({@link AGORA_ACTIVATED_ENV_SCOPE_KEY}) is not this exact env (an org
|
|
3173
|
+
* applied before this existed, whose live env already matched desired
|
|
3174
|
+
* with nothing ever confirmed running it). A brand new app has nothing
|
|
3175
|
+
* running yet to restart. The activation marker is recorded last, after
|
|
3176
|
+
* the restart (if any) returns without throwing, so a failed restart
|
|
3177
|
+
* leaves it stale and the next plan replans this step even though
|
|
3178
|
+
* Coolify's own env store already matches desired.
|
|
3062
3179
|
*/
|
|
3063
3180
|
async coolifyAgora(action, org, context) {
|
|
3064
3181
|
// The host values the plan read, carried in the step: never read again
|
|
@@ -3074,7 +3191,7 @@ export class FoundryStepExecutor {
|
|
|
3074
3191
|
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
3075
3192
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
3076
3193
|
const name = `${org.slug}-agora`;
|
|
3077
|
-
const server = await this.resolveServer();
|
|
3194
|
+
const server = await this.resolveServer(org, context);
|
|
3078
3195
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
3079
3196
|
const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
3080
3197
|
project_uuid: projectUuid,
|
|
@@ -3091,7 +3208,20 @@ export class FoundryStepExecutor {
|
|
|
3091
3208
|
}));
|
|
3092
3209
|
// Every app foundry deploys keeps its own logs (#75), reused as-is here.
|
|
3093
3210
|
await this.ensureLogVolume(app.appId, org, app.existed);
|
|
3211
|
+
// #442: read before overwriting -- `bulkUpsertEnvs` below always leaves
|
|
3212
|
+
// Coolify's own env store matching `env`, which is why that store alone
|
|
3213
|
+
// can no longer tell a changed value from one already live.
|
|
3214
|
+
const liveEnv = new Map((await this.ports.coolify.listEnvs(app.appId))
|
|
3215
|
+
.filter((e) => !e.is_preview)
|
|
3216
|
+
.map((e) => [e.key, e.value]));
|
|
3217
|
+
const envChanged = agoraEnvDrift(env, Object.fromEntries(liveEnv)).length > 0;
|
|
3094
3218
|
await this.ports.coolify.bulkUpsertEnvs(app.appId, Object.entries(env).map(([key, value]) => ({ key, value })));
|
|
3219
|
+
const revision = agoraEnvRevision(env);
|
|
3220
|
+
const alreadyActivated = context.recordedIds()[AGORA_ACTIVATED_ENV_SCOPE_KEY] === revision;
|
|
3221
|
+
if (app.existed && (envChanged || !alreadyActivated)) {
|
|
3222
|
+
await this.ports.coolify.restart(app.appId);
|
|
3223
|
+
}
|
|
3224
|
+
context.recordScopeId(AGORA_ACTIVATED_ENV_SCOPE_KEY, revision);
|
|
3095
3225
|
return { resourceId: app.appId };
|
|
3096
3226
|
}
|
|
3097
3227
|
// ---- boule (#161) --------------------------------------------------------
|
|
@@ -3131,9 +3261,9 @@ export class FoundryStepExecutor {
|
|
|
3131
3261
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
3132
3262
|
const name = `${org.slug}-boule`;
|
|
3133
3263
|
const dbName = `${org.slug}-boule-db`.replace(/-/g, '_');
|
|
3134
|
-
const dbId = await this.ensureCallsDatabase(context, projectUuid, environment, `${org.slug}-boule-db`, dbName, BOULE_DATABASE_SCOPE_KEY);
|
|
3264
|
+
const dbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, `${org.slug}-boule-db`, dbName, BOULE_DATABASE_SCOPE_KEY);
|
|
3135
3265
|
context.recordScopeId(BOULE_DATABASE_SCOPE_KEY, dbId);
|
|
3136
|
-
const server = await this.resolveServer();
|
|
3266
|
+
const server = await this.resolveServer(org, context);
|
|
3137
3267
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
3138
3268
|
const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
3139
3269
|
project_uuid: projectUuid,
|
|
@@ -3345,7 +3475,7 @@ export class FoundryStepExecutor {
|
|
|
3345
3475
|
appName: `${org.slug}-cloudflared`,
|
|
3346
3476
|
projectUuid,
|
|
3347
3477
|
environment,
|
|
3348
|
-
resolveServer: () => this.resolveServer(),
|
|
3478
|
+
resolveServer: () => this.resolveServer(org, context),
|
|
3349
3479
|
recordedConnectorAppId: context.recordedIds()[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY] ?? null,
|
|
3350
3480
|
recordAppId: async (appId) => {
|
|
3351
3481
|
context.recordScopeId(CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY, appId);
|
|
@@ -3965,30 +4095,45 @@ export class FoundryStepExecutor {
|
|
|
3965
4095
|
}
|
|
3966
4096
|
// ---- files / ai --------------------------------------------------------
|
|
3967
4097
|
/**
|
|
3968
|
-
* #80: provisions the org's own organisation on Files
|
|
3969
|
-
*
|
|
3970
|
-
*
|
|
3971
|
-
*
|
|
3972
|
-
*
|
|
4098
|
+
* #80: provisions the org's own organisation on Files.
|
|
4099
|
+
* `provisionOrganisation` is safely repeatable on its own
|
|
4100
|
+
* (`FilesOrganisationPort`'s doc comment), so this is called every time
|
|
4101
|
+
* regardless of whether it already ran -- idempotent by find-or-create,
|
|
4102
|
+
* not by checking `recordedIds` first.
|
|
4103
|
+
*
|
|
4104
|
+
* F6b: `action.desired.shareHost` (`diff.ts`'s `planFilesOrganisation`) is
|
|
4105
|
+
* passed through every time, even though `entry-files-key.ts` reports the
|
|
4106
|
+
* route itself as blocked until the platform's files host can serve
|
|
4107
|
+
* `share.<domain>` -- this is the one place the name reaches files at all,
|
|
4108
|
+
* so files can act on it the moment its own route exists.
|
|
3973
4109
|
*
|
|
3974
4110
|
* #273: also ensures the org's Agora origin is on the files host's CORS
|
|
3975
4111
|
* allowlist (`FilesOrganisationPort.ensureAllowedOrigin`), the same
|
|
3976
4112
|
* repeat-is-a-no-op shape as `provisionOrganisation` itself -- every org
|
|
3977
4113
|
* gets an Agora (#164), so `diff.ts`'s `planFilesOrganisation` always sets
|
|
3978
4114
|
* `desired.agoraOrigin`.
|
|
4115
|
+
*
|
|
4116
|
+
* #437: the owner member/name, the core ZITADEL files project and the core
|
|
4117
|
+
* collection (`core-files.ts`) are no longer conditioned on a hosting entry
|
|
4118
|
+
* declaring `uses.files` -- the core bundle (Agora's own uploads) needs
|
|
4119
|
+
* all three just as much as a bespoke entry does, and withholding them
|
|
4120
|
+
* until one existed is why a plain Agora/Boule org never got a usable
|
|
4121
|
+
* Files setup at all. Both the project and the collection are idempotent
|
|
4122
|
+
* by name, so a re-run that already created either finds it instead.
|
|
3979
4123
|
*/
|
|
3980
4124
|
async filesOrganisation(action, org, context) {
|
|
3981
4125
|
const files = await this.filesPort();
|
|
3982
|
-
|
|
3983
|
-
// organisation
|
|
3984
|
-
//
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
4126
|
+
const recorded = context.recordedIds();
|
|
4127
|
+
// F6b: an organisation takes an owner member and name only while it is
|
|
4128
|
+
// being created, so it is passed whenever the owner is known now --
|
|
4129
|
+
// every org, not only one with a hosting entry declaring `uses.files`
|
|
4130
|
+
// (#437).
|
|
4131
|
+
const owner = filesOwnerUserId(org, recorded);
|
|
3988
4132
|
const provisioned = await files.provisionOrganisation({
|
|
3989
4133
|
organisationId: org.id,
|
|
3990
4134
|
ceilingBytes: action.desired.ceilingBytes ?? null,
|
|
3991
4135
|
ceilingMicros: action.desired.ceilingMicros ?? null,
|
|
4136
|
+
shareHost: action.desired.shareHost ?? null,
|
|
3992
4137
|
...(owner ? { name: org.displayName, owner } : {}),
|
|
3993
4138
|
});
|
|
3994
4139
|
const agoraOrigin = action.desired.agoraOrigin;
|
|
@@ -4000,6 +4145,22 @@ export class FoundryStepExecutor {
|
|
|
4000
4145
|
if (provisioned.agoraKey) {
|
|
4001
4146
|
await this.deliverFilesAgoraKey(files, org, provisioned.agoraKey, context);
|
|
4002
4147
|
}
|
|
4148
|
+
// #437: the audience Agora requests to read/browse Files
|
|
4149
|
+
// (`agoras.ts`'s `filesProjectId`), the same `urn:zitadel:iam:org:
|
|
4150
|
+
// project:id:<id>:aud` shape `messagingProjectId` already uses. Retried
|
|
4151
|
+
// on a later apply, same as the owner above, while the org's ZITADEL
|
|
4152
|
+
// org id is not recorded yet (`zitadel:issuer` has not run).
|
|
4153
|
+
const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
4154
|
+
if (zitadelOrgId) {
|
|
4155
|
+
const { project } = await this.ports.zitadel.findOrCreateProject(zitadelOrgId, `${org.slug}-files`);
|
|
4156
|
+
context.recordScopeId(ZITADEL_FILES_PROJECT_SCOPE_KEY, project.id);
|
|
4157
|
+
}
|
|
4158
|
+
// #437: the one collection every file Agora itself uploads goes into
|
|
4159
|
+
// (`agoras.ts`'s `filesCollectionId`) -- Files has no route to list an
|
|
4160
|
+
// organisation's collections and an ordinary member cannot create one,
|
|
4161
|
+
// so it has to exist before Agora ever tries.
|
|
4162
|
+
const collection = await files.ensureCollection(org.id, CORE_FILES_COLLECTION_NAME);
|
|
4163
|
+
context.recordScopeId(FILES_CORE_COLLECTION_SCOPE_KEY, collection.id);
|
|
4003
4164
|
return { resourceId: provisioned.organisationId };
|
|
4004
4165
|
}
|
|
4005
4166
|
/**
|
|
@@ -4036,12 +4197,6 @@ export class FoundryStepExecutor {
|
|
|
4036
4197
|
context.recordScopeId(FILES_AGORA_UNDELIVERED_SCOPE_KEY, '');
|
|
4037
4198
|
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'}`);
|
|
4038
4199
|
}
|
|
4039
|
-
async filesStorage(action, org) {
|
|
4040
|
-
const files = await this.filesPort();
|
|
4041
|
-
await files.setQuota(org.id, action.desired.limitBytes ?? null);
|
|
4042
|
-
await files.setBudget(org.id, action.desired.limitMicros ?? null);
|
|
4043
|
-
return { resourceId: org.id };
|
|
4044
|
-
}
|
|
4045
4200
|
/**
|
|
4046
4201
|
* #80: provisions the org's own organisation on AI and switches its AI use
|
|
4047
4202
|
* on, before {@link aiBudget} can set its own spend limit. Both
|
|
@@ -4429,6 +4584,7 @@ export class FoundryStepExecutor {
|
|
|
4429
4584
|
const envs = [
|
|
4430
4585
|
{ key: 'FILES_URL', value: d.filesUrl },
|
|
4431
4586
|
{ key: 'FILES_ORGANISATION_ID', value: org.id },
|
|
4587
|
+
...(d.shareOrigin ? [{ key: 'FILES_SHARE_ORIGIN', value: d.shareOrigin }] : []),
|
|
4432
4588
|
];
|
|
4433
4589
|
for (const name of d.collections) {
|
|
4434
4590
|
let collection;
|
|
@@ -4495,6 +4651,7 @@ export class FoundryStepExecutor {
|
|
|
4495
4651
|
permissions: d.permissions,
|
|
4496
4652
|
collections: d.collections,
|
|
4497
4653
|
filesUrl: d.filesUrl,
|
|
4654
|
+
...(d.shareOrigin ? { shareOrigin: d.shareOrigin } : {}),
|
|
4498
4655
|
}));
|
|
4499
4656
|
if (expiresAt && previousKeyId && previousKeyId !== resourceId) {
|
|
4500
4657
|
await this.revokeFilesEntryKey(files, org, entryKey, previousKeyId, context);
|
package/dist/plan/hostnames.d.ts
CHANGED
|
@@ -26,6 +26,12 @@ export declare function appHostname(org: DeclaredOrg): string;
|
|
|
26
26
|
export declare function coolifyDomains(names: readonly string[]): string;
|
|
27
27
|
/** What `domains` held on an `app` entry's Coolify app before entries had hostnames. */
|
|
28
28
|
export declare function legacyAppHostname(org: DeclaredOrg): string;
|
|
29
|
+
/**
|
|
30
|
+
* `share.<domain>` when hosting is on and an entry declares
|
|
31
|
+
* `uses.files.shareHost`, else `null`. The files service answers public
|
|
32
|
+
* shares there; foundry never puts it behind Access.
|
|
33
|
+
*/
|
|
34
|
+
export declare function filesShareHostname(org: DeclaredOrg): string | null;
|
|
29
35
|
/**
|
|
30
36
|
* The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
|
|
31
37
|
* #164): a site's two names, every entry's names, an own-domain calls name,
|
package/dist/plan/hostnames.js
CHANGED
|
@@ -47,6 +47,16 @@ export function coolifyDomains(names) {
|
|
|
47
47
|
export function legacyAppHostname(org) {
|
|
48
48
|
return org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* `share.<domain>` when hosting is on and an entry declares
|
|
52
|
+
* `uses.files.shareHost`, else `null`. The files service answers public
|
|
53
|
+
* shares there; foundry never puts it behind Access.
|
|
54
|
+
*/
|
|
55
|
+
export function filesShareHostname(org) {
|
|
56
|
+
if (!org.hosting.enabled || !org.domain)
|
|
57
|
+
return null;
|
|
58
|
+
return org.hosting.entries.some((e) => e.uses?.files?.shareHost) ? `share.${org.domain}` : null;
|
|
59
|
+
}
|
|
50
60
|
/**
|
|
51
61
|
* The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
|
|
52
62
|
* #164): a site's two names, every entry's names, an own-domain calls name,
|
|
@@ -71,6 +81,9 @@ export function tunnelIngressHostnames(org) {
|
|
|
71
81
|
// #161: and Boule moves onto `boule.<domain>` the same way.
|
|
72
82
|
hostnames.push(bouleHostnameFor(org));
|
|
73
83
|
}
|
|
84
|
+
const share = filesShareHostname(org);
|
|
85
|
+
if (share)
|
|
86
|
+
hostnames.push(share);
|
|
74
87
|
// The wildcard goes last: Cloudflare takes the first ingress rule that
|
|
75
88
|
// matches, so every exact name above must come before `*.<domain>`.
|
|
76
89
|
const wildcard = wildcardHostname(org);
|
package/dist/plan/index.d.ts
CHANGED
|
@@ -2,15 +2,19 @@ export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, File
|
|
|
2
2
|
export { readLiveResources, readLivePlatformResources } from './live.js';
|
|
3
3
|
export type { StepAction, PlannedStep, PlanDetail } from './diff.js';
|
|
4
4
|
export { computePlanDetail, toPlan } from './diff.js';
|
|
5
|
+
export type { StepSystem } from './step-system.js';
|
|
6
|
+
export { stepSystemOf } from './step-system.js';
|
|
5
7
|
export type { PlatformPlanDetail } from './platform-diff.js';
|
|
6
8
|
export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, } from './platform-diff.js';
|
|
7
9
|
export type { HeldKeyResolver, OrgPlatformReadiness } from './platform-org.js';
|
|
8
10
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
|
|
9
11
|
export type { PlatformOrgClientFactories } from './platform-org-ports.js';
|
|
10
12
|
export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
|
|
13
|
+
export type { AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot } from './adopt.js';
|
|
14
|
+
export { AdoptRefusedError } from './adopt.js';
|
|
11
15
|
export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan-apply.js';
|
|
12
16
|
export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
|
|
13
|
-
export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './platform-providers.js';
|
|
17
|
+
export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, } from './platform-providers.js';
|
|
14
18
|
export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, } from './platform-providers.js';
|
|
15
19
|
export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './platform-providers.js';
|
|
16
20
|
export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './platform-providers.js';
|
package/dist/plan/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
export { readLiveResources, readLivePlatformResources } from './live.js';
|
|
2
2
|
export { computePlanDetail, toPlan } from './diff.js';
|
|
3
|
+
export { stepSystemOf } from './step-system.js';
|
|
3
4
|
export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, } from './platform-diff.js';
|
|
4
5
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
|
|
5
6
|
export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
|
|
7
|
+
export { AdoptRefusedError } from './adopt.js';
|
|
6
8
|
export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
|
|
7
|
-
export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './platform-providers.js';
|
|
9
|
+
export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, } from './platform-providers.js';
|
|
8
10
|
// #171: how a host signs files management requests with the delivered key.
|
|
9
11
|
export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './platform-providers.js';
|
|
10
12
|
export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './platform-providers.js';
|