@wtfalch/foundry 0.6.0 → 0.8.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/bootstrap/bootstrap.d.ts +12 -8
- package/dist/bootstrap/bootstrap.js +4 -12
- package/dist/bootstrap/declare.d.ts +3 -1
- package/dist/bootstrap/declare.js +6 -2
- package/dist/bootstrap/versions.d.ts +25 -0
- package/dist/bootstrap/versions.js +43 -0
- package/dist/calls-sfu-deploy-support.d.ts +1 -1
- package/dist/calls-sfu-deploy-support.js +1 -1
- package/dist/calls-sfu-ssh-adapter.d.ts +1 -1
- package/dist/calls-sfu-ssh-adapter.js +1 -1
- package/dist/calls-sfu.d.ts +8 -6
- package/dist/calls-sfu.js +9 -6
- package/dist/index.d.ts +3 -4
- package/dist/index.js +5 -2
- package/dist/migrations/0022_chat_enabled.sql +6 -0
- package/dist/migrations/0023_hosting_app_env.sql +8 -0
- package/dist/migrations/0024_audit_events.sql +211 -0
- package/dist/migrations/0025_audit_display.sql +147 -0
- package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
- package/dist/migrations/0027_audit_chain.sql +170 -0
- package/dist/migrations/0028_audit_rls.sql +111 -0
- package/dist/migrations-dir.d.ts +9 -0
- package/dist/migrations-dir.js +10 -0
- package/dist/org.d.ts +24 -0
- package/dist/org.js +13 -0
- package/dist/plan/agora.d.ts +21 -0
- package/dist/plan/agora.js +23 -0
- package/dist/plan/calls-matrix.d.ts +4 -2
- package/dist/plan/calls-matrix.js +5 -2
- package/dist/plan/cloudflared-connector.d.ts +3 -2
- package/dist/plan/cloudflared-connector.js +5 -3
- package/dist/plan/diff.d.ts +18 -3
- package/dist/plan/diff.js +369 -42
- package/dist/plan/executor.d.ts +378 -44
- package/dist/plan/executor.js +816 -57
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/login-host.d.ts +88 -0
- package/dist/plan/login-host.js +73 -0
- package/dist/plan/plan-apply.d.ts +23 -8
- package/dist/plan/plan-apply.js +30 -10
- package/dist/plan/platform-diff.d.ts +102 -2
- package/dist/plan/platform-diff.js +232 -1
- package/dist/plan/platform-providers.d.ts +37 -2
- package/dist/plan/platform-providers.js +223 -36
- package/dist/plan/providers-readers.js +44 -1
- package/dist/plan.d.ts +43 -0
- package/dist/plan.js +16 -1
- package/dist/store/audit.d.ts +36 -4
- package/dist/store/audit.js +110 -29
- package/dist/store/postgres.d.ts +14 -0
- package/dist/store/postgres.js +17 -9
- package/dist/store/schema.d.ts +37 -1
- package/dist/store/schema.js +7 -0
- package/package.json +10 -5
- package/versions.json +8 -0
- package/dist/migrate.d.ts +0 -38
- package/dist/migrate.js +0 -47
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, randomUUID, sign, } from 'node:crypto';
|
|
2
2
|
import { isIPv4 } from 'node:net';
|
|
3
|
+
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
3
4
|
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
4
5
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
5
6
|
import { ACCESS_POLICY_NAME, isNotFoundError, isRunningOrStarting } from './executor.js';
|
|
6
|
-
import { JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
|
|
7
|
+
import { JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
|
|
7
8
|
import { applyCallsSfuPlatformStep } from './platform-sfu.js';
|
|
8
9
|
/** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
|
|
9
10
|
function apexZoneKey(apex) {
|
|
@@ -92,6 +93,10 @@ const JOBS_SCHEDULER_REPO = 'wtfalch/jobs';
|
|
|
92
93
|
* #175 adds `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}, Archon itself
|
|
93
94
|
* ({@link provisionPlatformArchon}), once a host passes
|
|
94
95
|
* {@link PlatformArchonDeps}.
|
|
96
|
+
*
|
|
97
|
+
* #257 adds `coolify:backup:<service>`, a nightly R2 backup of each
|
|
98
|
+
* platform database ({@link provisionPlatformBackup}), once a host passes
|
|
99
|
+
* {@link PlatformBackupDeps}.
|
|
95
100
|
*/
|
|
96
101
|
export class FoundryPlatformStepExecutor {
|
|
97
102
|
cloudflare;
|
|
@@ -105,6 +110,7 @@ export class FoundryPlatformStepExecutor {
|
|
|
105
110
|
files;
|
|
106
111
|
ai;
|
|
107
112
|
archon;
|
|
113
|
+
backup;
|
|
108
114
|
constructor(cloudflare, declaredOrgStore, options = {}) {
|
|
109
115
|
this.cloudflare = cloudflare;
|
|
110
116
|
this.declaredOrgStore = declaredOrgStore;
|
|
@@ -117,6 +123,7 @@ export class FoundryPlatformStepExecutor {
|
|
|
117
123
|
this.files = options.files;
|
|
118
124
|
this.ai = options.ai;
|
|
119
125
|
this.archon = options.archon;
|
|
126
|
+
this.backup = options.backup;
|
|
120
127
|
}
|
|
121
128
|
async blockedSteps(platform, recordedIds = {}) {
|
|
122
129
|
const blocked = {};
|
|
@@ -130,8 +137,19 @@ export class FoundryPlatformStepExecutor {
|
|
|
130
137
|
}
|
|
131
138
|
else if (platform.auth.enabled && this.auth) {
|
|
132
139
|
const { reason: authBlocked, otherInputsMissing } = await authBrokerBlockedReason(this.auth, recordedIds);
|
|
133
|
-
if (authBlocked)
|
|
140
|
+
if (authBlocked) {
|
|
134
141
|
blocked.auth = authBlocked;
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
// #248: the same "block, not throw" rule mail's own tag check
|
|
145
|
+
// follows. `VERSIONS.zitadel` is a real pin today, so this never
|
|
146
|
+
// fires in practice -- it is here so a future bump that lands a
|
|
147
|
+
// floating tag is caught the same way mail's would be.
|
|
148
|
+
const zitadelTagBlocked = floatingTagBlocked('ZITADEL', VERSIONS.zitadel, 'v4.17.3');
|
|
149
|
+
if (zitadelTagBlocked) {
|
|
150
|
+
blocked.auth = `the platform's sign-in is blocked, not planned: ${zitadelTagBlocked}`;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
135
153
|
// #216 round 5: see `PlanPlatformOptions.blocked.authOtherInputsMissing`.
|
|
136
154
|
if (otherInputsMissing.length > 0)
|
|
137
155
|
blocked.authOtherInputsMissing = otherInputsMissing;
|
|
@@ -197,6 +215,14 @@ export class FoundryPlatformStepExecutor {
|
|
|
197
215
|
}
|
|
198
216
|
}
|
|
199
217
|
}
|
|
218
|
+
// Only once a database actually exists to back up -- the same
|
|
219
|
+
// "nothing to report yet" rule `planBackups` (`platform-diff.ts`)
|
|
220
|
+
// itself follows for "no database", so an otherwise-unrelated platform
|
|
221
|
+
// with no backup deps wired carries no standing blocked note.
|
|
222
|
+
if (!this.backup &&
|
|
223
|
+
PLATFORM_BACKUP_SERVICE_KEYS.some((service) => Boolean(recordedIds[PLATFORM_BACKUP_DATABASE_SCOPE_KEYS[service]]))) {
|
|
224
|
+
blocked.backupExecutorMissing = true;
|
|
225
|
+
}
|
|
200
226
|
return blocked;
|
|
201
227
|
}
|
|
202
228
|
/**
|
|
@@ -270,6 +296,12 @@ export class FoundryPlatformStepExecutor {
|
|
|
270
296
|
if (action.system === 'coolify' && action.kind === 'jobs') {
|
|
271
297
|
return this.coolifyJobs(action, context);
|
|
272
298
|
}
|
|
299
|
+
if (action.system === 'coolify' && action.kind === 'backup') {
|
|
300
|
+
if (!this.backup) {
|
|
301
|
+
throw new Error(`platform:backup:${action.key} needs FoundryPlatformStepExecutor to be constructed with its backup dependencies (#257)`);
|
|
302
|
+
}
|
|
303
|
+
return provisionPlatformBackup(action, context, this.backup);
|
|
304
|
+
}
|
|
273
305
|
if (action.system === 'sfu' && action.kind === 'livekit') {
|
|
274
306
|
return this.executeSfu();
|
|
275
307
|
}
|
|
@@ -439,9 +471,9 @@ const PLATFORM_AUTH_BROKER_INTERNAL_NAME = 'platform-auth-broker';
|
|
|
439
471
|
const PLATFORM_AUTH_TUNNEL_NAME = 'platform-auth-tunnel';
|
|
440
472
|
/** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_AUTH_TUNNEL_NAME}. */
|
|
441
473
|
const PLATFORM_AUTH_CLOUDFLARED_APP_NAME = 'platform-auth-cloudflared';
|
|
442
|
-
/** `wtfalch/auth`'s `docker-compose.yml`: `image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION:-v4.17.3}`. */
|
|
474
|
+
/** `wtfalch/auth`'s `docker-compose.yml`: `image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION:-v4.17.3}`. Pinned in `versions.json` (#247). */
|
|
443
475
|
const AUTH_ZITADEL_IMAGE = 'ghcr.io/zitadel/zitadel';
|
|
444
|
-
const AUTH_ZITADEL_IMAGE_TAG =
|
|
476
|
+
const AUTH_ZITADEL_IMAGE_TAG = VERSIONS.zitadel;
|
|
445
477
|
/** `wtfalch/auth`'s own `login/Dockerfile` -- `docs/deploy.md`'s "One compose service, four containers... Two images are built from this repository: the proxy... and the login". */
|
|
446
478
|
const AUTH_REPO = 'wtfalch/auth';
|
|
447
479
|
const AUTH_LOGIN_DOCKERFILE = 'login/Dockerfile';
|
|
@@ -808,6 +840,15 @@ async function provisionPlatformAuth(context, deps) {
|
|
|
808
840
|
await deps.coolify.updateApplication(zitadelAppId, {
|
|
809
841
|
custom_internal_name: PLATFORM_AUTH_ZITADEL_INTERNAL_NAME,
|
|
810
842
|
});
|
|
843
|
+
// #251: only on create, never unconditionally -- nothing here patches
|
|
844
|
+
// `docker_registry_image_tag` on an app `findOrCreatePlatformApp` found
|
|
845
|
+
// already running, so that is the only moment this tag is actually live.
|
|
846
|
+
// Recording it every run, the way the app id above is, would make
|
|
847
|
+
// `versionNotAppliedLine` compare a bumped `VERSIONS.zitadel` against a
|
|
848
|
+
// value that was never applied to the running app.
|
|
849
|
+
if (zitadelApp.justCreated) {
|
|
850
|
+
await record(PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, AUTH_ZITADEL_IMAGE_TAG);
|
|
851
|
+
}
|
|
811
852
|
// ---- the zitadel-login app -----------------------------------------------
|
|
812
853
|
const loginApp = await findOrCreatePlatformApp(deps.coolify, PLATFORM_AUTH_LOGIN_APP_NAME, environment, async () => {
|
|
813
854
|
const server = await resolvePlatformServer(deps.coolify);
|
|
@@ -1519,12 +1560,17 @@ async function provisionPlatformMail(context, deps) {
|
|
|
1519
1560
|
await record(PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, imageTag);
|
|
1520
1561
|
return { resourceId: appId };
|
|
1521
1562
|
}
|
|
1522
|
-
/**
|
|
1563
|
+
/**
|
|
1564
|
+
* Why the Stalwart image tag cannot be used, or `null`. #248: a thin wrapper
|
|
1565
|
+
* over the estate-wide {@link floatingTagBlocked} -- the mail step's own
|
|
1566
|
+
* message, naming `gitRef` since that is the declared field this tag
|
|
1567
|
+
* actually comes from.
|
|
1568
|
+
*/
|
|
1523
1569
|
function mailImageTagBlocked(tag) {
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1570
|
+
const reason = floatingTagBlocked('Stalwart', tag, 'v0.16.24');
|
|
1571
|
+
return reason
|
|
1572
|
+
? `the platform's mail server is blocked, not planned: ${reason} as the mail gitRef`
|
|
1573
|
+
: null;
|
|
1528
1574
|
}
|
|
1529
1575
|
/** Why the server's address cannot be the MX A record's content, or `null`. */
|
|
1530
1576
|
function mailServerIpBlocked(ip) {
|
|
@@ -1848,19 +1894,32 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
1848
1894
|
await record(PLATFORM_ARCHON_PROJECT_SCOPE_KEY, projectUuid);
|
|
1849
1895
|
}
|
|
1850
1896
|
const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
1897
|
+
// #265: a database already named `platform-archon-db` (the hand-deployed
|
|
1898
|
+
// one) is adopted, never built a second time. One of that name in another
|
|
1899
|
+
// environment is refused: adopting or duplicating it would split the data.
|
|
1851
1900
|
let dbId = recorded[PLATFORM_ARCHON_DB_SCOPE_KEY];
|
|
1852
1901
|
if (!dbId) {
|
|
1853
|
-
const
|
|
1854
|
-
const
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1902
|
+
const named = (await deps.coolify.listDatabases()).filter((d) => d.name === PLATFORM_ARCHON_DB_NAME);
|
|
1903
|
+
const found = named.find((d) => d.environment_id === environment.id);
|
|
1904
|
+
if (!found && named.length > 0) {
|
|
1905
|
+
throw new Error(`platform:archon: a database named ${PLATFORM_ARCHON_DB_NAME} exists outside the ${PLATFORM_ARCHON_PROJECT} project's production environment; refusing to adopt or duplicate it. Move or rename it in Coolify and apply again`);
|
|
1906
|
+
}
|
|
1907
|
+
if (found) {
|
|
1908
|
+
dbId = found.uuid;
|
|
1909
|
+
}
|
|
1910
|
+
else {
|
|
1911
|
+
const server = await resolvePlatformServer(deps.coolify);
|
|
1912
|
+
const created = await deps.coolify.createPostgresDatabase({
|
|
1913
|
+
project_uuid: projectUuid,
|
|
1914
|
+
server_uuid: server.uuid,
|
|
1915
|
+
environment_name: environment.name,
|
|
1916
|
+
environment_uuid: environment.uuid,
|
|
1917
|
+
name: PLATFORM_ARCHON_DB_NAME,
|
|
1918
|
+
postgres_db: ARCHON_POSTGRES_DB,
|
|
1919
|
+
postgres_user: ARCHON_POSTGRES_DB,
|
|
1920
|
+
});
|
|
1921
|
+
dbId = created.uuid;
|
|
1922
|
+
}
|
|
1864
1923
|
await record(PLATFORM_ARCHON_DB_SCOPE_KEY, dbId);
|
|
1865
1924
|
}
|
|
1866
1925
|
const db = await deps.coolify.getDatabase(dbId);
|
|
@@ -1869,6 +1928,12 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
1869
1928
|
}
|
|
1870
1929
|
// ---- the app ----------------------------------------------------------------
|
|
1871
1930
|
const app = await findOrCreatePlatformApp(deps.coolify, PLATFORM_ARCHON_APP_NAME, environment, async () => {
|
|
1931
|
+
// #265: the lookup above is scoped to this environment, so a same-named
|
|
1932
|
+
// app elsewhere is invisible to it. Making another one would give two
|
|
1933
|
+
// `platform-archon` apps; refuse instead.
|
|
1934
|
+
if (await deps.coolify.findApplicationByName(PLATFORM_ARCHON_APP_NAME)) {
|
|
1935
|
+
throw new Error(`platform:archon: an application named ${PLATFORM_ARCHON_APP_NAME} exists outside the ${PLATFORM_ARCHON_PROJECT} project's production environment; refusing to create a second one. Move or rename it in Coolify and apply again`);
|
|
1936
|
+
}
|
|
1872
1937
|
const server = await resolvePlatformServer(deps.coolify);
|
|
1873
1938
|
const githubApp = await deps.coolify.requireGithubApp();
|
|
1874
1939
|
return deps.coolify.createGithubAppApplication({
|
|
@@ -1913,7 +1978,7 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
1913
1978
|
// response was lost, or one made for another hostname: its client id
|
|
1914
1979
|
// cannot be read back (`listApps` does not carry it), so it is deleted and
|
|
1915
1980
|
// made again, as `zitadelApp` does.
|
|
1916
|
-
const orphan = (await zitadel.listApps(org.id, project.id)).find((a) => a.name === PLATFORM_ARCHON_ZITADEL_APP &&
|
|
1981
|
+
const orphan = (await zitadel.listApps(org.id, project.id)).apps.find((a) => a.name === PLATFORM_ARCHON_ZITADEL_APP &&
|
|
1917
1982
|
a.appType === 'web' &&
|
|
1918
1983
|
a.state !== 'APP_STATE_REMOVED');
|
|
1919
1984
|
if (orphan) {
|
|
@@ -1944,9 +2009,20 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
1944
2009
|
{ key: 'AUTH_ISSUER', value: issuer },
|
|
1945
2010
|
{ key: 'AUTH_ORGANIZATION_ID', value: org.id },
|
|
1946
2011
|
{ key: 'AUTH_CLIENT_ID', value: clientId },
|
|
1947
|
-
{ key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
|
|
1948
|
-
{ key: 'REPORTING_SITE', value: 'archon' },
|
|
1949
2012
|
];
|
|
2013
|
+
// #265: an owner URL or reporting site already on the app is kept as it is.
|
|
2014
|
+
// The owner URL must name the adopted database's host, or the app's two
|
|
2015
|
+
// database URLs would point at different databases.
|
|
2016
|
+
const liveOwnerUrl = await currentEnvValue(deps.coolify, appId, 'DATABASE_URL_OWNER');
|
|
2017
|
+
if (liveOwnerUrl) {
|
|
2018
|
+
assertSameDatabaseHost('DATABASE_URL_OWNER', liveOwnerUrl, db.internal_db_url);
|
|
2019
|
+
}
|
|
2020
|
+
else {
|
|
2021
|
+
envs.push({ key: 'DATABASE_URL_OWNER', value: db.internal_db_url });
|
|
2022
|
+
}
|
|
2023
|
+
if (!(await currentEnvValue(deps.coolify, appId, 'REPORTING_SITE'))) {
|
|
2024
|
+
envs.push({ key: 'REPORTING_SITE', value: 'archon' });
|
|
2025
|
+
}
|
|
1950
2026
|
const cookieSecret = await ensureGeneratedEnv(deps.coolify, appId, 'AUTH_COOKIE_SECRET', () => randomBytes(32).toString('base64'));
|
|
1951
2027
|
if (cookieSecret)
|
|
1952
2028
|
envs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
|
|
@@ -2591,10 +2667,34 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
|
|
|
2591
2667
|
* `base64url` fits `db-runtime-role.sh`'s password alphabet
|
|
2592
2668
|
* (`A-Za-z0-9_.~-`).
|
|
2593
2669
|
*/
|
|
2670
|
+
/**
|
|
2671
|
+
* #265: throws when `liveUrl` points at another host than the adopted
|
|
2672
|
+
* database. Names both hosts and nothing else: a URL carries a credential, and
|
|
2673
|
+
* Node's parse error echoes its whole input.
|
|
2674
|
+
*/
|
|
2675
|
+
function assertSameDatabaseHost(key, liveUrl, dbInternalUrl) {
|
|
2676
|
+
const hostOf = (url) => {
|
|
2677
|
+
try {
|
|
2678
|
+
return new URL(url).host;
|
|
2679
|
+
}
|
|
2680
|
+
catch {
|
|
2681
|
+
return null;
|
|
2682
|
+
}
|
|
2683
|
+
};
|
|
2684
|
+
const liveHost = hostOf(liveUrl);
|
|
2685
|
+
const dbHost = hostOf(dbInternalUrl);
|
|
2686
|
+
if (liveHost !== dbHost) {
|
|
2687
|
+
throw new Error(`platform:archon: ${key} on the app points at host ${liveHost ?? '(unparseable)'}, but the adopted database ${PLATFORM_ARCHON_DB_NAME} is at host ${dbHost ?? '(unparseable)'}; refusing to leave the app's database URLs on different databases`);
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2594
2690
|
async function ensureArchonRuntimeUrl(coolify, appId, dbInternalUrl) {
|
|
2595
2691
|
const envs = await coolify.listEnvs(appId);
|
|
2596
|
-
|
|
2692
|
+
const live = envs.find((e) => e.key === 'DATABASE_URL' && !e.is_preview);
|
|
2693
|
+
if (live) {
|
|
2694
|
+
// #265: kept as it is, but it must name the adopted database's host.
|
|
2695
|
+
assertSameDatabaseHost('DATABASE_URL', live.value, dbInternalUrl);
|
|
2597
2696
|
return null;
|
|
2697
|
+
}
|
|
2598
2698
|
let host;
|
|
2599
2699
|
let pathname;
|
|
2600
2700
|
let search;
|
|
@@ -3781,7 +3881,7 @@ async function provisionPlatformFiles(context, deps) {
|
|
|
3781
3881
|
// An app by this name here has no recorded secret (no undelivered id, or
|
|
3782
3882
|
// the throw above would have fired): a crash between the create and the
|
|
3783
3883
|
// record. Nothing was delivered, so it is deleted and made again.
|
|
3784
|
-
const stale = (await zitadel.listApps(org.id, project.id)).filter((app) => app.name === FILES_INTROSPECTION_APP_NAME && app.state !== 'APP_STATE_REMOVED');
|
|
3884
|
+
const stale = (await zitadel.listApps(org.id, project.id)).apps.filter((app) => app.name === FILES_INTROSPECTION_APP_NAME && app.state !== 'APP_STATE_REMOVED');
|
|
3785
3885
|
for (const app of stale) {
|
|
3786
3886
|
try {
|
|
3787
3887
|
await zitadel.deleteApp(org.id, project.id, app.id);
|
|
@@ -4284,6 +4384,61 @@ async function waitForIntegrationsMigration(coolify, appId, runtime, sleep) {
|
|
|
4284
4384
|
await sleep(interval);
|
|
4285
4385
|
}
|
|
4286
4386
|
}
|
|
4387
|
+
/** The name every platform database's backup is registered against -- one shared R2 bucket, one storage entry. */
|
|
4388
|
+
const PLATFORM_BACKUP_S3_STORAGE_NAME = 'platform-backups';
|
|
4389
|
+
/** The uuid `provisionPlatformBackup` records once {@link PLATFORM_BACKUP_S3_STORAGE_NAME} is registered on Coolify, so a later apply never re-registers it. */
|
|
4390
|
+
const PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY = 'coolify_platform_backup_s3_storage';
|
|
4391
|
+
/** Coolify's own name for "once a day" -- `createDatabaseBackup`'s `frequency`. */
|
|
4392
|
+
const PLATFORM_BACKUP_DEFAULT_FREQUENCY = 'daily';
|
|
4393
|
+
/**
|
|
4394
|
+
* #257: schedules a nightly R2 backup of one platform database --
|
|
4395
|
+
* `planBackups` (`platform-diff.ts`) plans this step once per service, each
|
|
4396
|
+
* carrying that service's own database id as `action.desired.databaseId`
|
|
4397
|
+
* (recorded by that service's own `coolify:app` step; never minted here).
|
|
4398
|
+
* The R2 bucket is registered as a Coolify S3 storage once, by name, and
|
|
4399
|
+
* its uuid recorded under {@link PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY} so
|
|
4400
|
+
* every service's own backup step -- and a rerun of this one -- reuses it
|
|
4401
|
+
* rather than registering a second storage entry under the same name.
|
|
4402
|
+
* `createDatabaseBackup` itself is the idempotency boundary for the
|
|
4403
|
+
* schedule: an existing enabled, `save_s3` schedule is left alone rather
|
|
4404
|
+
* than replaced, since Coolify has no "upsert" for this call and a second
|
|
4405
|
+
* schedule would dump the same database twice a night.
|
|
4406
|
+
*/
|
|
4407
|
+
async function provisionPlatformBackup(action, context, deps) {
|
|
4408
|
+
const { coolify, r2 } = deps;
|
|
4409
|
+
const databaseId = action.desired.databaseId;
|
|
4410
|
+
const recorded = context.recordedIds();
|
|
4411
|
+
let storageUuid = recorded[PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY];
|
|
4412
|
+
if (!storageUuid) {
|
|
4413
|
+
const found = (await coolify.listS3Storages()).find((storage) => storage.name === PLATFORM_BACKUP_S3_STORAGE_NAME);
|
|
4414
|
+
if (found) {
|
|
4415
|
+
storageUuid = found.uuid;
|
|
4416
|
+
}
|
|
4417
|
+
else {
|
|
4418
|
+
const created = await coolify.createS3Storage({
|
|
4419
|
+
name: PLATFORM_BACKUP_S3_STORAGE_NAME,
|
|
4420
|
+
endpoint: r2.endpoint,
|
|
4421
|
+
bucket: r2.bucket,
|
|
4422
|
+
region: r2.region ?? 'auto',
|
|
4423
|
+
key: r2.accessKeyId,
|
|
4424
|
+
secret: r2.secretAccessKey,
|
|
4425
|
+
is_usable: true,
|
|
4426
|
+
});
|
|
4427
|
+
storageUuid = created.uuid;
|
|
4428
|
+
}
|
|
4429
|
+
context.recordScopeId(PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY, storageUuid);
|
|
4430
|
+
}
|
|
4431
|
+
const existing = (await coolify.listDatabaseBackups(databaseId)).find((backup) => backup.enabled && backup.save_s3);
|
|
4432
|
+
if (!existing) {
|
|
4433
|
+
await coolify.createDatabaseBackup(databaseId, {
|
|
4434
|
+
frequency: deps.frequency ?? PLATFORM_BACKUP_DEFAULT_FREQUENCY,
|
|
4435
|
+
enabled: true,
|
|
4436
|
+
save_s3: true,
|
|
4437
|
+
s3_storage_uuid: storageUuid,
|
|
4438
|
+
});
|
|
4439
|
+
}
|
|
4440
|
+
return undefined;
|
|
4441
|
+
}
|
|
4287
4442
|
/**
|
|
4288
4443
|
* The real {@link PlatformCloudflareReader}: the platform's apex zone as
|
|
4289
4444
|
* `planApexZone` expects to see it, and nothing else. The zone's DNS records
|
|
@@ -4338,23 +4493,55 @@ export class ProvidersPlatformCoolifyReader {
|
|
|
4338
4493
|
this.platformStore = platformStore;
|
|
4339
4494
|
}
|
|
4340
4495
|
async listResources() {
|
|
4341
|
-
const
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
{
|
|
4496
|
+
const recorded = await this.platformStore.getServiceIds();
|
|
4497
|
+
const resources = [];
|
|
4498
|
+
const recordedAppId = recorded[JOBS_APP_KEY];
|
|
4499
|
+
if (recordedAppId) {
|
|
4500
|
+
try {
|
|
4501
|
+
const app = await this.client.getApplication(recordedAppId);
|
|
4502
|
+
resources.push({
|
|
4348
4503
|
system: 'coolify',
|
|
4349
4504
|
kind: 'jobs',
|
|
4350
4505
|
key: JOBS_SCHEDULER_KEY,
|
|
4351
4506
|
config: { uuid: app.uuid, status: app.status },
|
|
4352
|
-
}
|
|
4353
|
-
|
|
4507
|
+
});
|
|
4508
|
+
}
|
|
4509
|
+
catch {
|
|
4510
|
+
// A recorded id Coolify no longer has reads as not-live (see this
|
|
4511
|
+
// class's own doc comment); the scheduler, not every other resource
|
|
4512
|
+
// this reader checks.
|
|
4513
|
+
}
|
|
4354
4514
|
}
|
|
4355
|
-
|
|
4356
|
-
|
|
4515
|
+
// #257: one backup-schedule resource per service whose database is
|
|
4516
|
+
// recorded, found the only way a schedule can be: listed off that
|
|
4517
|
+
// database itself, since Coolify names no schedule of its own.
|
|
4518
|
+
for (const service of PLATFORM_BACKUP_SERVICE_KEYS) {
|
|
4519
|
+
const databaseId = recorded[PLATFORM_BACKUP_DATABASE_SCOPE_KEYS[service]];
|
|
4520
|
+
if (!databaseId)
|
|
4521
|
+
continue;
|
|
4522
|
+
try {
|
|
4523
|
+
const backups = await this.client.listDatabaseBackups(databaseId);
|
|
4524
|
+
const scheduled = backups.find((backup) => backup.enabled && backup.save_s3);
|
|
4525
|
+
if (scheduled) {
|
|
4526
|
+
resources.push({
|
|
4527
|
+
system: 'coolify',
|
|
4528
|
+
kind: 'backup',
|
|
4529
|
+
key: service,
|
|
4530
|
+
config: { uuid: scheduled.uuid, frequency: scheduled.frequency },
|
|
4531
|
+
});
|
|
4532
|
+
}
|
|
4533
|
+
}
|
|
4534
|
+
catch {
|
|
4535
|
+
// A Coolify error (or a recorded database id Coolify no longer has)
|
|
4536
|
+
// reads as not-live, the same as the jobs app above -- `databaseId`
|
|
4537
|
+
// is still recorded, so `planBackups` plans the step again rather
|
|
4538
|
+
// than reporting it blocked; `provisionPlatformBackup`'s own
|
|
4539
|
+
// `listDatabaseBackups` check (run again there) is what keeps a
|
|
4540
|
+
// transient failure here from scheduling a second backup once the
|
|
4541
|
+
// first one did succeed.
|
|
4542
|
+
}
|
|
4357
4543
|
}
|
|
4544
|
+
return resources;
|
|
4358
4545
|
}
|
|
4359
4546
|
}
|
|
4360
4547
|
/**
|
|
@@ -130,6 +130,47 @@ async function withLogVolumeConfig(client, resource, recorded) {
|
|
|
130
130
|
return resource;
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* #239: whether a recorded database has an internal connection URL, as
|
|
135
|
+
* `config.hasInternalUrl` (a boolean, never the URL), so `planApp` can report
|
|
136
|
+
* a database it cannot push from as blocked. A failed read leaves it absent.
|
|
137
|
+
*/
|
|
138
|
+
async function withDatabaseUrlConfig(client, resource, recorded) {
|
|
139
|
+
if (resource.kind !== 'database' || !recorded[resource.key]?.startsWith('database:')) {
|
|
140
|
+
return resource;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const db = await client.getDatabase(resource.key);
|
|
144
|
+
return {
|
|
145
|
+
...resource,
|
|
146
|
+
config: { ...resource.config, hasInternalUrl: Boolean(db.internal_db_url) },
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return resource;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* #239: the live env var NAMES (never values) of the org's own recorded app,
|
|
155
|
+
* as `config.envNames`, so `diff.ts`'s `planApp` can notice an app that
|
|
156
|
+
* lacks `DATABASE_URL_OWNER`. Failure is swallowed like
|
|
157
|
+
* {@link withLogVolumeConfig}: `envNames` stays absent and the planner reads
|
|
158
|
+
* that as nothing checked.
|
|
159
|
+
*/
|
|
160
|
+
async function withEnvNamesConfig(client, resource, recorded) {
|
|
161
|
+
if (resource.kind !== 'application' || !recorded[resource.key]?.startsWith('app:')) {
|
|
162
|
+
return resource;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const envNames = (await client.listEnvs(resource.key))
|
|
166
|
+
.filter((e) => !e.is_preview)
|
|
167
|
+
.map((e) => e.key);
|
|
168
|
+
return { ...resource, config: { ...resource.config, envNames } };
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return resource;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
133
174
|
/**
|
|
134
175
|
* #164: the org's own recorded Agora app's live `AGORA_*` env vars, as
|
|
135
176
|
* `config.env`, so `diff.ts`'s `planAgora` can compare them with what
|
|
@@ -177,7 +218,9 @@ export class ProvidersCoolifyReader {
|
|
|
177
218
|
this.resolveRecordedIds(orgId),
|
|
178
219
|
]);
|
|
179
220
|
const withLogConfig = await Promise.all(toFoundryResources('coolify', resources).map((r) => withLogVolumeConfig(this.client, r, recorded)));
|
|
180
|
-
const
|
|
221
|
+
const withEnvNames = await Promise.all(withLogConfig.map((r) => withEnvNamesConfig(this.client, r, recorded)));
|
|
222
|
+
const withDbUrl = await Promise.all(withEnvNames.map((r) => withDatabaseUrlConfig(this.client, r, recorded)));
|
|
223
|
+
const withAgoraConfig = await Promise.all(withDbUrl.map((r) => withAgoraEnvConfig(this.client, r, recorded)));
|
|
181
224
|
return applyRecordedIds(withAgoraConfig, recorded);
|
|
182
225
|
}
|
|
183
226
|
}
|
package/dist/plan.d.ts
CHANGED
|
@@ -53,4 +53,47 @@ export interface Plan {
|
|
|
53
53
|
orgId: OrgId;
|
|
54
54
|
steps: PlanStep[];
|
|
55
55
|
unmanaged: UnmanagedItem[];
|
|
56
|
+
/**
|
|
57
|
+
* #251: see {@link VersionChangeItem}. Optional so a `Plan` built before
|
|
58
|
+
* this field existed, or a test fixture that predates it, still satisfies
|
|
59
|
+
* this type with none.
|
|
60
|
+
*/
|
|
61
|
+
versionChanges?: VersionChangeItem[];
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The action kind a {@link VersionChangeItem} carries -- a distinct, typed
|
|
65
|
+
* value so Archon can key off it to render and later gate these lines
|
|
66
|
+
* (wtfalch/archon#133), and so it is never mistaken for a `StepAction.kind`
|
|
67
|
+
* (`plan/diff.ts`) any `StepExecutor` dispatches on.
|
|
68
|
+
*
|
|
69
|
+
* #251 round 3: an earlier attempt planned these as ordinary `PlanStep`s in
|
|
70
|
+
* `Plan.steps`. That made a plan holding only version-change lines for
|
|
71
|
+
* zitadel/synapse/mas (whose own step never re-applies a running app's
|
|
72
|
+
* image tag -- see {@link VersionChangeItem}) never read as empty, which is
|
|
73
|
+
* the condition `bootstrap/drive.ts` loops on, and `apply` tried to run a
|
|
74
|
+
* step for those services that does nothing. Version-change lines are
|
|
75
|
+
* carried on their own `Plan.versionChanges` field instead, never on
|
|
76
|
+
* `steps`, so they are display-only everywhere a `Plan` is read.
|
|
77
|
+
*/
|
|
78
|
+
export declare const VERSION_CHANGE_KIND = "version-change";
|
|
79
|
+
/**
|
|
80
|
+
* One informational line reporting a service whose version has drifted --
|
|
81
|
+
* either from what the next apply would actually put live (mail, archon,
|
|
82
|
+
* ai, files, integrations: each already redeploys when its own recorded
|
|
83
|
+
* applied ref differs from the declared one), or, for a service whose own
|
|
84
|
+
* step never patches a running app's image tag once created (zitadel,
|
|
85
|
+
* synapse, mas), from what is actually live. #251: the data to show this
|
|
86
|
+
* already existed per-service with no line anywhere saying a foundry
|
|
87
|
+
* release bumping a pinned version, or an operator changing a declared
|
|
88
|
+
* `gitRef`, had changed what the next apply would do -- or, for the second
|
|
89
|
+
* group, that it would do nothing at all.
|
|
90
|
+
*
|
|
91
|
+
* Never a {@link PlanStep}: it creates or changes nothing live, and is never
|
|
92
|
+
* passed to a `StepExecutor`.
|
|
93
|
+
*/
|
|
94
|
+
export interface VersionChangeItem {
|
|
95
|
+
kind: typeof VERSION_CHANGE_KIND;
|
|
96
|
+
/** The service this line is about, e.g. 'mail', 'archon', 'zitadel'. */
|
|
97
|
+
service: string;
|
|
98
|
+
description: string;
|
|
56
99
|
}
|
package/dist/plan.js
CHANGED
|
@@ -1 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The action kind a {@link VersionChangeItem} carries -- a distinct, typed
|
|
3
|
+
* value so Archon can key off it to render and later gate these lines
|
|
4
|
+
* (wtfalch/archon#133), and so it is never mistaken for a `StepAction.kind`
|
|
5
|
+
* (`plan/diff.ts`) any `StepExecutor` dispatches on.
|
|
6
|
+
*
|
|
7
|
+
* #251 round 3: an earlier attempt planned these as ordinary `PlanStep`s in
|
|
8
|
+
* `Plan.steps`. That made a plan holding only version-change lines for
|
|
9
|
+
* zitadel/synapse/mas (whose own step never re-applies a running app's
|
|
10
|
+
* image tag -- see {@link VersionChangeItem}) never read as empty, which is
|
|
11
|
+
* the condition `bootstrap/drive.ts` loops on, and `apply` tried to run a
|
|
12
|
+
* step for those services that does nothing. Version-change lines are
|
|
13
|
+
* carried on their own `Plan.versionChanges` field instead, never on
|
|
14
|
+
* `steps`, so they are display-only everywhere a `Plan` is read.
|
|
15
|
+
*/
|
|
16
|
+
export const VERSION_CHANGE_KIND = 'version-change';
|
package/dist/store/audit.d.ts
CHANGED
|
@@ -1,8 +1,40 @@
|
|
|
1
1
|
import type { AuditLog } from '../plan/plan-apply.js';
|
|
2
2
|
import type { FoundryStoreDb } from './postgres.js';
|
|
3
3
|
/**
|
|
4
|
-
* foundry's own {@link AuditLog},
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* foundry's own {@link AuditLog}, backed by `@wtfalch/audit`'s ledger
|
|
5
|
+
* (#232) instead of the hand-rolled `foundry_audit_events` table
|
|
6
|
+
* (`migrations/0012_audit.sql`) this replaces. Every row: `tenantId: null`
|
|
7
|
+
* (foundry has no tenant of its own -- the org a plan is *for* is the
|
|
8
|
+
* `target`, not a tenant), `hashChain` on so a changed or deleted row is
|
|
9
|
+
* detectable (`verifyChain`), through migrations 0024-0028
|
|
10
|
+
* (`src/migrations/`, copied from `@wtfalch/audit` 0.6.0's own
|
|
11
|
+
* `migrations/0001-0005`, the same files its own `audit-migrations` CLI
|
|
12
|
+
* would copy into a host that has a `drizzle/` journal -- foundry does not,
|
|
13
|
+
* so they are hand-placed at the next free numbers instead).
|
|
14
|
+
*
|
|
15
|
+
* The `AuditLog` interface this satisfies is unchanged (`plan-apply.ts`),
|
|
16
|
+
* so `bootstrap.ts` and `redactingAudit` need no change either.
|
|
17
|
+
*
|
|
18
|
+
* Decide (#232, open): whether `foundry_audit_events`' existing rows are
|
|
19
|
+
* copied into the ledger or left as a closed, separate trail. Left as-is
|
|
20
|
+
* here -- the simpler default, and the one that forecloses nothing: a
|
|
21
|
+
* backfill can still be written later, where discarding the old rows
|
|
22
|
+
* outright could not be undone.
|
|
23
|
+
*
|
|
24
|
+
* `checkRuntimeRole` defaults to `true`, `createLedger`'s own default. The
|
|
25
|
+
* connection must be a restricted role: not a superuser, no BYPASSRLS, and no
|
|
26
|
+
* UPDATE, DELETE or TRUNCATE on `audit_events`. A call through the owner
|
|
27
|
+
* connection throws `UnsafeRuntimeRoleError`. The check reads attributes of
|
|
28
|
+
* the connected role, not its name.
|
|
29
|
+
*
|
|
30
|
+
* The host provides that role. After it migrates with the owner URL, it calls
|
|
31
|
+
* `ensureRuntimeRole` from `@wtfalch/db/runtime-role` with
|
|
32
|
+
* `appendOnly: ['audit_events']` and the four audit functions in `grants`
|
|
33
|
+
* (README, "Foundry's own Postgres"), then connects the server as the runtime
|
|
34
|
+
* role. A test fixture without that split (PGlite's one user is a superuser)
|
|
35
|
+
* passes `checkRuntimeRole: false`. Options, not a second positional argument
|
|
36
|
+
* (`CLAUDE.md` rule 7).
|
|
7
37
|
*/
|
|
8
|
-
export declare function createPostgresAuditLog(db: FoundryStoreDb
|
|
38
|
+
export declare function createPostgresAuditLog(db: FoundryStoreDb, options?: {
|
|
39
|
+
checkRuntimeRole?: boolean;
|
|
40
|
+
}): AuditLog;
|