@wtfalch/foundry 0.33.1 → 0.35.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/bootstrap.d.ts +1 -1
- package/dist/bootstrap/bootstrap.js +49 -11
- package/dist/bootstrap/declare.d.ts +12 -5
- package/dist/bootstrap/declare.js +33 -18
- package/dist/bootstrap/versions.d.ts +24 -0
- package/dist/bootstrap/versions.js +46 -0
- package/dist/calls-sfu.d.ts +10 -0
- package/dist/calls-sfu.js +44 -3
- package/dist/index.d.ts +21 -8
- package/dist/index.js +11 -4
- package/dist/migrations/0051_org_services.sql +13 -0
- package/dist/migrations/0052_platform_mail_relay.sql +8 -0
- package/dist/migrations/0053_heartbeats_provider.sql +9 -0
- package/dist/migrations/0053_org_version_pin.sql +6 -0
- package/dist/org.d.ts +76 -12
- package/dist/org.js +136 -20
- package/dist/plan/backup-gate.d.ts +93 -0
- package/dist/plan/backup-gate.js +261 -0
- package/dist/plan/broker-entries.d.ts +9 -1
- package/dist/plan/broker-entries.js +20 -3
- package/dist/plan/build-record.d.ts +56 -0
- package/dist/plan/build-record.js +109 -0
- package/dist/plan/calls-matrix.d.ts +9 -0
- package/dist/plan/calls-matrix.js +18 -1
- package/dist/plan/cloudflared-connector.d.ts +7 -1
- package/dist/plan/cloudflared-connector.js +15 -2
- package/dist/plan/company-backups.d.ts +31 -0
- package/dist/plan/company-backups.js +101 -0
- package/dist/plan/deploy-health.d.ts +12 -2
- package/dist/plan/deploy-health.js +3 -1
- package/dist/plan/destinations.d.ts +73 -0
- package/dist/plan/destinations.js +217 -0
- package/dist/plan/diff.d.ts +46 -8
- package/dist/plan/diff.js +367 -126
- package/dist/plan/edge-access.d.ts +15 -0
- package/dist/plan/edge-access.js +21 -2
- package/dist/plan/executor.d.ts +276 -5
- package/dist/plan/executor.js +1111 -109
- package/dist/plan/health-check-settings.d.ts +28 -0
- package/dist/plan/health-check-settings.js +36 -0
- package/dist/plan/health-gaps.d.ts +33 -0
- package/dist/plan/health-gaps.js +89 -0
- package/dist/plan/health-targets.d.ts +1 -3
- package/dist/plan/health-targets.js +36 -36
- package/dist/plan/held-key-refs.d.ts +15 -0
- package/dist/plan/held-key-refs.js +15 -0
- package/dist/plan/hostnames.d.ts +10 -0
- package/dist/plan/hostnames.js +14 -0
- package/dist/plan/index.d.ts +19 -6
- package/dist/plan/index.js +10 -3
- package/dist/plan/live.d.ts +11 -2
- package/dist/plan/live.js +16 -2
- package/dist/plan/locked-hostnames.d.ts +7 -3
- package/dist/plan/locked-hostnames.js +25 -4
- package/dist/plan/login-host.d.ts +61 -12
- package/dist/plan/login-host.js +99 -11
- package/dist/plan/mail-relay.d.ts +139 -0
- package/dist/plan/mail-relay.js +400 -0
- package/dist/plan/network-move.d.ts +20 -0
- package/dist/plan/network-move.js +41 -0
- package/dist/plan/oikos-ingress.d.ts +111 -0
- package/dist/plan/oikos-ingress.js +231 -0
- package/dist/plan/org-names.d.ts +30 -0
- package/dist/plan/org-names.js +108 -0
- package/dist/plan/own-diff.d.ts +170 -0
- package/dist/plan/own-diff.js +440 -0
- package/dist/plan/own-dns.d.ts +6 -0
- package/dist/plan/own-dns.js +15 -0
- package/dist/plan/own-hostnames.d.ts +27 -0
- package/dist/plan/own-hostnames.js +24 -0
- package/dist/plan/own-limits.d.ts +213 -0
- package/dist/plan/own-limits.js +232 -0
- package/dist/plan/own-services.d.ts +193 -0
- package/dist/plan/own-services.js +169 -0
- package/dist/plan/own-sfu.d.ts +69 -0
- package/dist/plan/own-sfu.js +256 -0
- package/dist/plan/plan-apply.d.ts +121 -10
- package/dist/plan/plan-apply.js +357 -18
- package/dist/plan/platform-diff.d.ts +155 -11
- package/dist/plan/platform-diff.js +271 -66
- package/dist/plan/platform-org.d.ts +107 -4
- package/dist/plan/platform-org.js +175 -3
- package/dist/plan/platform-providers.d.ts +474 -81
- package/dist/plan/platform-providers.js +1407 -560
- package/dist/plan/proxy-labels.d.ts +57 -11
- package/dist/plan/proxy-labels.js +94 -14
- package/dist/plan/run-options.d.ts +19 -0
- package/dist/plan/run-options.js +46 -0
- package/dist/plan/sign-in-org.d.ts +1 -1
- package/dist/plan/sign-in-org.js +20 -2
- package/dist/plan/zitadel-login-policy.d.ts +1 -1
- package/dist/plan/zitadel-login-policy.js +77 -9
- package/dist/plan/zitadel-system-api.d.ts +22 -1
- package/dist/plan/zitadel-system-api.js +49 -8
- package/dist/platform.d.ts +40 -0
- package/dist/platform.js +16 -0
- package/dist/provision.d.ts +2 -0
- package/dist/provision.js +3 -1
- package/dist/slug.d.ts +3 -1
- package/dist/slug.js +26 -3
- package/dist/store/postgres.js +93 -2
- package/dist/store/schema.d.ts +87 -0
- package/dist/store/schema.js +9 -0
- package/package.json +3 -3
- package/versions.digests.json +11 -0
- package/versions.json +3 -1
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { backupScopeKey, companyDatabaseKeys, databasesForService } from './company-backups.js';
|
|
2
|
+
import { LOG_DIR } from './executor.js';
|
|
3
|
+
/** A confirmed stateful upgrade needs a dump that finished within this many hours. */
|
|
4
|
+
export const BACKUP_MAX_AGE_HOURS = 26;
|
|
5
|
+
/** A move deletes the databases, so the dump it relies on is at most this old. */
|
|
6
|
+
export const MOVE_BACKUP_MAX_AGE_MINUTES = 60;
|
|
7
|
+
/** How long one Coolify call of the gate may take. */
|
|
8
|
+
const GATE_CALL_TIMEOUT_MS = 30_000;
|
|
9
|
+
/**
|
|
10
|
+
* Thrown before any step runs when a confirmed upgrade of a stateful service
|
|
11
|
+
* has no fresh finished backup. The message names the service and nothing
|
|
12
|
+
* else: no database name, uuid or URL. There is no override.
|
|
13
|
+
*/
|
|
14
|
+
export class UpgradeBackupMissingError extends Error {
|
|
15
|
+
service;
|
|
16
|
+
scheduleOnly;
|
|
17
|
+
constructor(service,
|
|
18
|
+
/** True when this Coolify has no executions route, so only the schedule was checked. */
|
|
19
|
+
scheduleOnly = false) {
|
|
20
|
+
const note = scheduleOnly
|
|
21
|
+
? ' (this Coolify has no executions route, so only the schedule was checked, and it is missing, disabled or not saved to S3)'
|
|
22
|
+
: '';
|
|
23
|
+
super(`upgrade of ${service} refused: no finished backup of its database in the last ${BACKUP_MAX_AGE_HOURS} hours${note}`);
|
|
24
|
+
this.service = service;
|
|
25
|
+
this.scheduleOnly = scheduleOnly;
|
|
26
|
+
this.name = 'UpgradeBackupMissingError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A network move is refused when the host has no backup gate, when a database of the project has no
|
|
31
|
+
* verified dump under {@link MOVE_BACKUP_MAX_AGE_MINUTES} minutes old, or when the project holds
|
|
32
|
+
* something no dump covers (the company's mail server, an app with a volume) or cannot be read. The
|
|
33
|
+
* message names no database, app, uuid or URL. There is no override: such a company is moved by hand.
|
|
34
|
+
*/
|
|
35
|
+
export class NetworkMoveBackupMissingError extends Error {
|
|
36
|
+
reason;
|
|
37
|
+
constructor(reason) {
|
|
38
|
+
super(reason === 'no-gate'
|
|
39
|
+
? 'network move refused: this host has no backup gate, so a fresh backup cannot be checked'
|
|
40
|
+
: reason === 'no-backup'
|
|
41
|
+
? `network move refused: not every database in the company's project has a finished dump from the last ${MOVE_BACKUP_MAX_AGE_MINUTES} minutes, checked in Coolify; take one now, or move this company by hand with docs/runbooks/network-move.md`
|
|
42
|
+
: "network move refused: the company's project holds something no backup covers (its mail server or an app with a volume), or Coolify could not be asked; move this company by hand with docs/runbooks/network-move.md");
|
|
43
|
+
this.reason = reason;
|
|
44
|
+
this.name = 'NetworkMoveBackupMissingError';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function withinTimeout(call) {
|
|
48
|
+
let timer;
|
|
49
|
+
try {
|
|
50
|
+
return await Promise.race([
|
|
51
|
+
call(),
|
|
52
|
+
new Promise((_, reject) => {
|
|
53
|
+
timer = setTimeout(() => reject(new Error('timeout')), GATE_CALL_TIMEOUT_MS);
|
|
54
|
+
}),
|
|
55
|
+
]);
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Coolify 4.x answers `GET /databases/{uuid}/backups/{backup}/executions` with
|
|
63
|
+
* `{ executions: [{ uuid, filename, size, created_at, message, status }] }`,
|
|
64
|
+
* newest first; `status` is `success`, `failed` or `running`. Read from
|
|
65
|
+
* `routes/api.php` and `DatabasesController::list_backup_executions` on the
|
|
66
|
+
* v4.x branch, 2026-10-10; not run against a live instance. Anything else is
|
|
67
|
+
* a shape this gate cannot trust, so it throws.
|
|
68
|
+
*/
|
|
69
|
+
function parseExecutions(body) {
|
|
70
|
+
const list = Array.isArray(body) ? body : body?.executions;
|
|
71
|
+
if (!Array.isArray(list))
|
|
72
|
+
throw new Error('executions list has an unexpected shape');
|
|
73
|
+
return list.map((row) => {
|
|
74
|
+
const r = row;
|
|
75
|
+
if (!r ||
|
|
76
|
+
typeof r.uuid !== 'string' ||
|
|
77
|
+
typeof r.status !== 'string' ||
|
|
78
|
+
typeof r.created_at !== 'string') {
|
|
79
|
+
throw new Error('executions list has an unexpected shape');
|
|
80
|
+
}
|
|
81
|
+
return { uuid: r.uuid, status: r.status, created_at: r.created_at };
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/** The executions of one schedule, or `null` when this Coolify has no such route. */
|
|
85
|
+
async function readExecutions(coolify, databaseUuid, backupUuid) {
|
|
86
|
+
try {
|
|
87
|
+
const list = coolify.listDatabaseBackupExecutions?.bind(coolify);
|
|
88
|
+
if (list)
|
|
89
|
+
return parseExecutions(await withinTimeout(() => list(databaseUuid, backupUuid)));
|
|
90
|
+
return parseExecutions(await withinTimeout(() => coolify.raw('GET', `/databases/${databaseUuid}/backups/${backupUuid}/executions`)));
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
if (isRouteMissing(e))
|
|
94
|
+
return null;
|
|
95
|
+
throw e;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function isRouteMissing(error) {
|
|
99
|
+
const status = error?.status;
|
|
100
|
+
if (typeof status === 'number')
|
|
101
|
+
return status === 404;
|
|
102
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
103
|
+
return /^\S+ \S+ \S+ → 404:/.test(message);
|
|
104
|
+
}
|
|
105
|
+
/** The newest `success` execution that is not older than the window, or null. */
|
|
106
|
+
function freshSuccess(executions, now, maxAgeMs = BACKUP_MAX_AGE_HOURS * 3_600_000, skewMs = 3_600_000) {
|
|
107
|
+
const oldest = now - maxAgeMs;
|
|
108
|
+
let best = null;
|
|
109
|
+
for (const execution of executions) {
|
|
110
|
+
if (execution.status !== 'success')
|
|
111
|
+
continue;
|
|
112
|
+
const at = Date.parse(execution.created_at);
|
|
113
|
+
if (Number.isNaN(at) || at < oldest || at > now + skewMs)
|
|
114
|
+
continue;
|
|
115
|
+
if (!best || at > best.at)
|
|
116
|
+
best = { execution, at };
|
|
117
|
+
}
|
|
118
|
+
return best?.execution ?? null;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Refuses a confirmed upgrade of a stateful service whose database has no
|
|
122
|
+
* finished backup in the last {@link BACKUP_MAX_AGE_HOURS} hours. Runs after
|
|
123
|
+
* `unconfirmedUpgrades` and before any step. A service whose database the
|
|
124
|
+
* company does not have passes (there is nothing to migrate); a database that
|
|
125
|
+
* exists with no recorded schedule, a disabled or non-S3 schedule, a failed or
|
|
126
|
+
* old dump, or an unreadable list all refuse. `mail` has no Postgres and is
|
|
127
|
+
* not covered here (plan note `backup-not-covered:mail`).
|
|
128
|
+
*
|
|
129
|
+
* Where this Coolify has no executions route, the fallback is that the
|
|
130
|
+
* schedule exists, is enabled and saves to S3; that proves a dump is
|
|
131
|
+
* scheduled, not that one ran, so it passes with no backup id.
|
|
132
|
+
*
|
|
133
|
+
* Returns the uuid of the newest fresh execution across the checked
|
|
134
|
+
* databases, for the caller to record as `backupId`, or `null`.
|
|
135
|
+
*/
|
|
136
|
+
export async function requireFreshBackupsForUpgrades(plan, confirmed, ports) {
|
|
137
|
+
const now = (ports.now ?? Date.now)();
|
|
138
|
+
const keys = ports.scope?.keys ?? companyDatabaseKeys({ id: ports.orgId });
|
|
139
|
+
const names = Object.keys(keys);
|
|
140
|
+
const confirmedSet = new Set(confirmed ?? []);
|
|
141
|
+
let backupId = null;
|
|
142
|
+
let newest = 0;
|
|
143
|
+
for (const change of plan.versionChanges ?? []) {
|
|
144
|
+
if (!change.needsConfirmation || !confirmedSet.has(change.service))
|
|
145
|
+
continue;
|
|
146
|
+
const wanted = (ports.scope?.forService ?? databasesForService)(change.service);
|
|
147
|
+
const dbs = wanted === 'all' ? names : wanted;
|
|
148
|
+
for (const db of dbs) {
|
|
149
|
+
const databaseUuid = ports.recordedIds[keys[db]];
|
|
150
|
+
if (!databaseUuid)
|
|
151
|
+
continue;
|
|
152
|
+
let backupUuid = ports.recordedIds[backupScopeKey(db)];
|
|
153
|
+
if (!backupUuid && ports.scope) {
|
|
154
|
+
const schedules = await withinTimeout(() => ports.coolify.listDatabaseBackups(databaseUuid)).catch(() => []);
|
|
155
|
+
backupUuid = schedules.find((x) => x.enabled && x.save_s3)?.uuid;
|
|
156
|
+
}
|
|
157
|
+
if (!backupUuid)
|
|
158
|
+
throw new UpgradeBackupMissingError(change.service);
|
|
159
|
+
let executions;
|
|
160
|
+
try {
|
|
161
|
+
executions = await readExecutions(ports.coolify, databaseUuid, backupUuid);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// An unreadable list is not a fresh backup. The cause stays out of the
|
|
165
|
+
// message: it can carry a response body.
|
|
166
|
+
throw new UpgradeBackupMissingError(change.service);
|
|
167
|
+
}
|
|
168
|
+
if (executions === null) {
|
|
169
|
+
const schedules = await withinTimeout(() => ports.coolify.listDatabaseBackups(databaseUuid)).catch(() => []);
|
|
170
|
+
const ok = schedules.some((s) => s.uuid === backupUuid && s.enabled && s.save_s3);
|
|
171
|
+
if (!ok)
|
|
172
|
+
throw new UpgradeBackupMissingError(change.service, true);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const fresh = freshSuccess(executions, now);
|
|
176
|
+
if (!fresh)
|
|
177
|
+
throw new UpgradeBackupMissingError(change.service);
|
|
178
|
+
const at = Date.parse(fresh.created_at);
|
|
179
|
+
if (at >= newest) {
|
|
180
|
+
newest = at;
|
|
181
|
+
backupId = fresh.uuid;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { backupId };
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The gate for a move to the company's own network, which deletes every database of the project. It
|
|
189
|
+
* fails closed and is stricter than the upgrade gate: it reads the project itself, not foundry's records.
|
|
190
|
+
* It passes only when every database in the project has a successful dump, seen in Coolify's
|
|
191
|
+
* executions, from the last {@link MOVE_BACKUP_MAX_AGE_MINUTES} minutes, and the project holds nothing
|
|
192
|
+
* a dump does not cover. It refuses (`by-hand`) the company's own mail server and any app with a
|
|
193
|
+
* volume other than its log volume, and (`no-backup`) a database with no schedule or no execution, an
|
|
194
|
+
* unreadable list, and a Coolify with no executions route. A company with neither database nor volume passes.
|
|
195
|
+
*/
|
|
196
|
+
export async function requireFreshBackupsForMove(ports) {
|
|
197
|
+
const { coolify } = ports;
|
|
198
|
+
const listResources = coolify.listResources?.bind(coolify);
|
|
199
|
+
const listStorages = coolify.listStorages?.bind(coolify);
|
|
200
|
+
if (!ports.projectUuid || !listResources || !listStorages || ports.ownsMail) {
|
|
201
|
+
throw new NetworkMoveBackupMissingError('by-hand');
|
|
202
|
+
}
|
|
203
|
+
const projectUuid = ports.projectUuid;
|
|
204
|
+
const now = (ports.now ?? Date.now)();
|
|
205
|
+
let resources;
|
|
206
|
+
try {
|
|
207
|
+
resources = await withinTimeout(() => listResources(projectUuid));
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
throw new NetworkMoveBackupMissingError('by-hand');
|
|
211
|
+
}
|
|
212
|
+
for (const app of resources.filter((r) => r.kind === 'application')) {
|
|
213
|
+
if (typeof app.key !== 'string')
|
|
214
|
+
throw new NetworkMoveBackupMissingError('by-hand');
|
|
215
|
+
const appUuid = app.key;
|
|
216
|
+
let storages;
|
|
217
|
+
try {
|
|
218
|
+
storages = await withinTimeout(() => listStorages(appUuid));
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
throw new NetworkMoveBackupMissingError('by-hand');
|
|
222
|
+
}
|
|
223
|
+
if ((storages.persistent_storages ?? []).some((v) => v.mount_path !== LOG_DIR)) {
|
|
224
|
+
throw new NetworkMoveBackupMissingError('by-hand');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
for (const database of resources.filter((r) => r.kind === 'database')) {
|
|
228
|
+
if (typeof database.key !== 'string')
|
|
229
|
+
throw new NetworkMoveBackupMissingError('no-backup');
|
|
230
|
+
const databaseUuid = database.key;
|
|
231
|
+
let fresh = false;
|
|
232
|
+
try {
|
|
233
|
+
const schedules = await withinTimeout(() => coolify.listDatabaseBackups(databaseUuid));
|
|
234
|
+
for (const schedule of schedules) {
|
|
235
|
+
// A missing executions route is a refusal: a schedule alone proves no dump ran.
|
|
236
|
+
const executions = await readExecutions(coolify, databaseUuid, schedule.uuid);
|
|
237
|
+
if (executions &&
|
|
238
|
+
freshSuccess(executions, now, MOVE_BACKUP_MAX_AGE_MINUTES * 60_000, 5 * 60_000)) {
|
|
239
|
+
fresh = true;
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
// The cause can carry a response body.
|
|
246
|
+
throw new NetworkMoveBackupMissingError('no-backup');
|
|
247
|
+
}
|
|
248
|
+
if (!fresh)
|
|
249
|
+
throw new NetworkMoveBackupMissingError('no-backup');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* The platform databases an upgrade of `service` can migrate. Postgres is all three. archon, ai and
|
|
254
|
+
* integrations each migrate their own database when the new container starts. The owner company runs its own
|
|
255
|
+
* sign-in, files and jobs, so the platform has no database for them; mail, edge and hc have no Postgres.
|
|
256
|
+
*/
|
|
257
|
+
export function platformDatabasesForService(service) {
|
|
258
|
+
if (service === 'postgres')
|
|
259
|
+
return 'all';
|
|
260
|
+
return service === 'archon' || service === 'ai' || service === 'integrations' ? [service] : [];
|
|
261
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type DeclaredOrg, type HostingEntry } from '../org.js';
|
|
2
2
|
import type { DeclaredOrgStore, PlatformStore } from '../store.js';
|
|
3
3
|
import type { CoolifyPort } from './executor.js';
|
|
4
4
|
/**
|
|
@@ -64,6 +64,13 @@ export interface BrokerPort {
|
|
|
64
64
|
/** The platform's `PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY` value, if any. */
|
|
65
65
|
recordedDeploy(): Promise<string | undefined>;
|
|
66
66
|
recordDeploy(value: string): Promise<void>;
|
|
67
|
+
/**
|
|
68
|
+
* The id of Archon's own sign-in organisation, when the platform has made it.
|
|
69
|
+
* No other entry may sign in that organisation, so an entry that does is
|
|
70
|
+
* refused before anything is written. Optional: a port that cannot read it
|
|
71
|
+
* (an older host) skips the check.
|
|
72
|
+
*/
|
|
73
|
+
archonOrganisationId?(): Promise<string | undefined>;
|
|
67
74
|
}
|
|
68
75
|
/** {@link BrokerPort} over the platform's recorded ids and a Coolify client. */
|
|
69
76
|
export declare class PlatformBrokerPort implements BrokerPort {
|
|
@@ -89,6 +96,7 @@ export declare class PlatformBrokerPort implements BrokerPort {
|
|
|
89
96
|
setAuthApps(raw: string): Promise<void>;
|
|
90
97
|
deploy(): Promise<void>;
|
|
91
98
|
recordedDeploy(): Promise<string | undefined>;
|
|
99
|
+
archonOrganisationId(): Promise<string | undefined>;
|
|
92
100
|
recordDeploy(value: string): Promise<void>;
|
|
93
101
|
}
|
|
94
102
|
/**
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { isFoundryOwnedBrokerEntryName, } from '../org.js';
|
|
2
3
|
import { entryClientIdScopeKey } from './agora.js';
|
|
3
4
|
import { entryHostnames } from './hostnames.js';
|
|
4
|
-
import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, parseBrokerDeployRecord, } from './platform-diff.js';
|
|
5
|
-
import { brokerDeployNeeded, brokerDeployRecordValue, missingBrokerInputs, validateBrokerAppEntry, } from './platform-providers.js';
|
|
5
|
+
import { PLATFORM_ARCHON_ORG_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, parseBrokerDeployRecord, } from './platform-diff.js';
|
|
6
|
+
import { archonOrgConflicts, brokerDeployNeeded, brokerDeployRecordValue, missingBrokerInputs, validateBrokerAppEntry, } from './platform-providers.js';
|
|
6
7
|
/**
|
|
7
8
|
* F5 (https://github.com/wtfalch/foundry/issues/314): an entry with
|
|
8
9
|
* `uses.auth` gets one entry in the broker's `AUTH_APPS`. The org executor has
|
|
@@ -99,6 +100,9 @@ export class PlatformBrokerPort {
|
|
|
99
100
|
async recordedDeploy() {
|
|
100
101
|
return (await this.options.store.getServiceIds())[PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY];
|
|
101
102
|
}
|
|
103
|
+
async archonOrganisationId() {
|
|
104
|
+
return (await this.options.store.getServiceIds())[PLATFORM_ARCHON_ORG_SCOPE_KEY] || undefined;
|
|
105
|
+
}
|
|
102
106
|
async recordDeploy(value) {
|
|
103
107
|
const { leases, store } = this.options;
|
|
104
108
|
const lease = await leases?.acquireMutationLease('platform', randomUUID(), 60_000);
|
|
@@ -130,6 +134,11 @@ export async function writeBrokerEntry(broker, input) {
|
|
|
130
134
|
const { want, appKey, previous } = input;
|
|
131
135
|
const label = `broker entry '${want.name}'`;
|
|
132
136
|
const brokerAppId = await broker.appId();
|
|
137
|
+
// Archon's organisation holds Archon's people only: its key must be the one entry that signs in there.
|
|
138
|
+
const archonOrgId = await broker.archonOrganisationId?.();
|
|
139
|
+
if (archonOrgConflicts([want], archonOrgId).length > 0) {
|
|
140
|
+
throw new Error(`${label} would sign in Archon's own organisation, which no other entry may`);
|
|
141
|
+
}
|
|
133
142
|
const env = (await broker.listEnvs()).find((e) => e.key === 'AUTH_APPS' && !e.is_preview);
|
|
134
143
|
// A value that reads back empty is not an empty list: writing over it
|
|
135
144
|
// would drop every hand-written entry.
|
|
@@ -154,7 +163,15 @@ export async function writeBrokerEntry(broker, input) {
|
|
|
154
163
|
entry.name === want.name &&
|
|
155
164
|
(entry.organizationId === want.organizationId ||
|
|
156
165
|
(previous !== null && entry.organizationId === previous.organizationId));
|
|
157
|
-
|
|
166
|
+
if (want.replaces !== null && isFoundryOwnedBrokerEntryName(want.replaces)) {
|
|
167
|
+
throw new Error(`${label}: it may not replace '${want.replaces}', a broker entry foundry writes itself`);
|
|
168
|
+
}
|
|
169
|
+
// Only an entry in the same organisation: another company's entry of that name stays.
|
|
170
|
+
const isReplaced = (entry) => want.replaces !== null &&
|
|
171
|
+
isObject(entry) &&
|
|
172
|
+
entry.name === want.replaces &&
|
|
173
|
+
entry.organizationId === want.organizationId &&
|
|
174
|
+
!isOwn(entry);
|
|
158
175
|
const entry = {
|
|
159
176
|
...(current.find(isOwn) ?? {}),
|
|
160
177
|
organizationId: want.organizationId,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { PlanNote } from '../plan.js';
|
|
2
|
+
import type { DeployAndWaitPort } from './deploy-health.js';
|
|
3
|
+
/**
|
|
4
|
+
* What commit Coolify built for an app foundry deployed, and a plan note when
|
|
5
|
+
* the commit now running differs. Coolify returns no image digest for an app
|
|
6
|
+
* it built from git, so the built commit is the only thing recorded. A deploy
|
|
7
|
+
* made outside foundry (a push, a manual deploy in Coolify) shows up as a
|
|
8
|
+
* different commit.
|
|
9
|
+
*/
|
|
10
|
+
/** One row of Coolify's deployment history, as `CoolifyClient.listApplicationDeployments` returns it. */
|
|
11
|
+
export interface BuildDeploymentRow {
|
|
12
|
+
deployment_uuid: string;
|
|
13
|
+
/** Absent on a port that builds from no git commit (the local stand-in). */
|
|
14
|
+
commit?: string;
|
|
15
|
+
status: string;
|
|
16
|
+
}
|
|
17
|
+
export interface BuildRecordPort {
|
|
18
|
+
listApplicationDeployments?(uuid: string, take?: number): Promise<BuildDeploymentRow[]>;
|
|
19
|
+
}
|
|
20
|
+
/** The scope key holding the 40 character commit of the deployment foundry started for `name`. */
|
|
21
|
+
export declare function builtCommitScopeKey(name: string): string;
|
|
22
|
+
/** The scope key holding the Coolify application uuid `builtCommitScopeKey(name)` was read from, so a later plan can read the live commit. */
|
|
23
|
+
export declare function builtAppScopeKey(name: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* Records the commit of the deployment with uuid `deploymentUuid` (the one
|
|
26
|
+
* the deploy returned, not the newest row) under `built_commit_<name>`, and
|
|
27
|
+
* the application uuid under `built_app_<name>`.
|
|
28
|
+
*
|
|
29
|
+
* Best effort: a port without the list call, a failed or timed-out read, a
|
|
30
|
+
* missing row or a commit that is not 40 hex characters records nothing and
|
|
31
|
+
* returns the reason. The deploy has already succeeded, so a failure here
|
|
32
|
+
* leaves no key and the plan shows no note for this app; it never fails the
|
|
33
|
+
* step. The reason names no response body.
|
|
34
|
+
*/
|
|
35
|
+
export declare function recordBuiltCommit(coolify: DeployAndWaitPort, input: {
|
|
36
|
+
appId: string;
|
|
37
|
+
deploymentUuid: string;
|
|
38
|
+
name: string;
|
|
39
|
+
}, record: (key: string, value: string) => void | Promise<void>): Promise<{
|
|
40
|
+
recorded: true;
|
|
41
|
+
} | {
|
|
42
|
+
recorded: false;
|
|
43
|
+
reason: string;
|
|
44
|
+
}>;
|
|
45
|
+
/**
|
|
46
|
+
* The commit of each recorded app's latest finished deployment, keyed by
|
|
47
|
+
* app name. An app whose read fails or has no finished deployment is left
|
|
48
|
+
* out, so it gives no note.
|
|
49
|
+
*/
|
|
50
|
+
export declare function readLiveBuilds(coolify: BuildRecordPort | undefined, recorded: Readonly<Record<string, string>>): Promise<Record<string, string>>;
|
|
51
|
+
/**
|
|
52
|
+
* One `build-drift:<name>` note per app whose live commit differs from the
|
|
53
|
+
* recorded one. An app with no recorded commit (never deployed by foundry) or
|
|
54
|
+
* no live commit gives none. The description carries 12 character shas only.
|
|
55
|
+
*/
|
|
56
|
+
export declare function buildDriftNotes(recorded: Readonly<Record<string, string>>, live: Readonly<Record<string, string | undefined>>): PlanNote[];
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
const BUILT_COMMIT_PREFIX = 'built_commit_';
|
|
2
|
+
const BUILT_APP_PREFIX = 'built_app_';
|
|
3
|
+
const FULL_SHA = /^[0-9a-f]{40}$/;
|
|
4
|
+
/** Coolify's own client has a request timeout; this bounds the whole read so a stuck call cannot hold a deploy step. */
|
|
5
|
+
const LIST_TIMEOUT_MS = 15_000;
|
|
6
|
+
const SHORT_SHA_LENGTH = 12;
|
|
7
|
+
/** The scope key holding the 40 character commit of the deployment foundry started for `name`. */
|
|
8
|
+
export function builtCommitScopeKey(name) {
|
|
9
|
+
return `${BUILT_COMMIT_PREFIX}${name}`;
|
|
10
|
+
}
|
|
11
|
+
/** The scope key holding the Coolify application uuid `builtCommitScopeKey(name)` was read from, so a later plan can read the live commit. */
|
|
12
|
+
export function builtAppScopeKey(name) {
|
|
13
|
+
return `${BUILT_APP_PREFIX}${name}`;
|
|
14
|
+
}
|
|
15
|
+
async function withTimeout(work, label) {
|
|
16
|
+
let timer;
|
|
17
|
+
const timeout = new Promise((_, reject) => {
|
|
18
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out`)), LIST_TIMEOUT_MS);
|
|
19
|
+
});
|
|
20
|
+
try {
|
|
21
|
+
return await Promise.race([work, timeout]);
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Records the commit of the deployment with uuid `deploymentUuid` (the one
|
|
29
|
+
* the deploy returned, not the newest row) under `built_commit_<name>`, and
|
|
30
|
+
* the application uuid under `built_app_<name>`.
|
|
31
|
+
*
|
|
32
|
+
* Best effort: a port without the list call, a failed or timed-out read, a
|
|
33
|
+
* missing row or a commit that is not 40 hex characters records nothing and
|
|
34
|
+
* returns the reason. The deploy has already succeeded, so a failure here
|
|
35
|
+
* leaves no key and the plan shows no note for this app; it never fails the
|
|
36
|
+
* step. The reason names no response body.
|
|
37
|
+
*/
|
|
38
|
+
export async function recordBuiltCommit(coolify, input, record) {
|
|
39
|
+
if (!coolify.listApplicationDeployments) {
|
|
40
|
+
return { recorded: false, reason: 'the Coolify port cannot list deployments' };
|
|
41
|
+
}
|
|
42
|
+
let rows;
|
|
43
|
+
try {
|
|
44
|
+
rows = await withTimeout(coolify.listApplicationDeployments(input.appId), 'deployment list');
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
const reason = error instanceof Error && error.message.endsWith('timed out')
|
|
48
|
+
? error.message
|
|
49
|
+
: 'the deployment list failed';
|
|
50
|
+
return { recorded: false, reason };
|
|
51
|
+
}
|
|
52
|
+
const row = rows.find((candidate) => candidate.deployment_uuid === input.deploymentUuid);
|
|
53
|
+
if (!row)
|
|
54
|
+
return { recorded: false, reason: 'the deployment is not in the list' };
|
|
55
|
+
if (!row.commit || !FULL_SHA.test(row.commit))
|
|
56
|
+
return { recorded: false, reason: 'the deployment has no git commit' };
|
|
57
|
+
await record(builtCommitScopeKey(input.name), row.commit);
|
|
58
|
+
await record(builtAppScopeKey(input.name), input.appId);
|
|
59
|
+
return { recorded: true };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The commit of each recorded app's latest finished deployment, keyed by
|
|
63
|
+
* app name. An app whose read fails or has no finished deployment is left
|
|
64
|
+
* out, so it gives no note.
|
|
65
|
+
*/
|
|
66
|
+
export async function readLiveBuilds(coolify, recorded) {
|
|
67
|
+
const live = {};
|
|
68
|
+
const list = coolify?.listApplicationDeployments?.bind(coolify);
|
|
69
|
+
if (!list)
|
|
70
|
+
return live;
|
|
71
|
+
for (const [key, appId] of Object.entries(recorded)) {
|
|
72
|
+
if (!key.startsWith(BUILT_APP_PREFIX) || !appId)
|
|
73
|
+
continue;
|
|
74
|
+
try {
|
|
75
|
+
const rows = await withTimeout(list(appId), 'deployment list');
|
|
76
|
+
const finished = rows.find((row) => row.status === 'finished');
|
|
77
|
+
if (finished?.commit && FULL_SHA.test(finished.commit)) {
|
|
78
|
+
live[key.slice(BUILT_APP_PREFIX.length)] = finished.commit;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
// Unreadable now: no live value, so no note for this app. The next plan reads again.
|
|
83
|
+
void error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return live;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* One `build-drift:<name>` note per app whose live commit differs from the
|
|
90
|
+
* recorded one. An app with no recorded commit (never deployed by foundry) or
|
|
91
|
+
* no live commit gives none. The description carries 12 character shas only.
|
|
92
|
+
*/
|
|
93
|
+
export function buildDriftNotes(recorded, live) {
|
|
94
|
+
const notes = [];
|
|
95
|
+
for (const [key, recordedSha] of Object.entries(recorded)) {
|
|
96
|
+
if (!key.startsWith(BUILT_COMMIT_PREFIX))
|
|
97
|
+
continue;
|
|
98
|
+
const name = key.slice(BUILT_COMMIT_PREFIX.length);
|
|
99
|
+
const liveSha = live[name];
|
|
100
|
+
if (!recordedSha || !liveSha || liveSha === recordedSha)
|
|
101
|
+
continue;
|
|
102
|
+
notes.push({
|
|
103
|
+
key: `build-drift:${name}`,
|
|
104
|
+
service: name,
|
|
105
|
+
description: `${name}: running build ${liveSha.slice(0, SHORT_SHA_LENGTH)} differs from the recorded ${recordedSha.slice(0, SHORT_SHA_LENGTH)}`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return notes.sort((a, b) => a.key.localeCompare(b.key));
|
|
109
|
+
}
|
|
@@ -47,6 +47,15 @@
|
|
|
47
47
|
export declare const SYNAPSE_IMAGE = "ghcr.io/element-hq/synapse";
|
|
48
48
|
export declare const SYNAPSE_TAG: string;
|
|
49
49
|
export declare const MAS_IMAGE: string;
|
|
50
|
+
/** MAS listens here in both the calls and the chat config (`http.listeners[0].binds`). */
|
|
51
|
+
export declare const MAS_PORT = 8080;
|
|
52
|
+
/**
|
|
53
|
+
* The inline Dockerfile of a MAS app: the published image plus its entry command. That image is distroless: no
|
|
54
|
+
* shell and no HTTP client, so Coolify's own check (always `sh -c`) cannot run in it. A static busybox is copied in
|
|
55
|
+
* and an exec-form HEALTHCHECK asks MAS's `/health` (a `SELECT 1` on its database). Coolify keeps a Dockerfile's
|
|
56
|
+
* HEALTHCHECK when its own check is off (`health_check_enabled: false`).
|
|
57
|
+
*/
|
|
58
|
+
export declare function masDockerfile(): string;
|
|
50
59
|
/** #251: the version baked into {@link MAS_IMAGE}'s `FROM` line, named on its own so a version-change line can read it without re-parsing the image string. */
|
|
51
60
|
export declare const MAS_TAG: string;
|
|
52
61
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { generateKeyPairSync, randomBytes } from 'node:crypto';
|
|
2
|
-
import { VERSIONS } from '../bootstrap/versions.js';
|
|
2
|
+
import { VERSIONS, pinnedRef } from '../bootstrap/versions.js';
|
|
3
3
|
/**
|
|
4
4
|
* The calls Matrix stack (#128, part 1 of 2 -- see that issue's "If this
|
|
5
5
|
* passes about 800 changed lines, split it" note): pure config generation
|
|
@@ -49,6 +49,23 @@ import { VERSIONS } from '../bootstrap/versions.js';
|
|
|
49
49
|
export const SYNAPSE_IMAGE = 'ghcr.io/element-hq/synapse';
|
|
50
50
|
export const SYNAPSE_TAG = VERSIONS.synapse;
|
|
51
51
|
export const MAS_IMAGE = `ghcr.io/element-hq/matrix-authentication-service:${VERSIONS.mas}`;
|
|
52
|
+
/** MAS listens here in both the calls and the chat config (`http.listeners[0].binds`). */
|
|
53
|
+
export const MAS_PORT = 8080;
|
|
54
|
+
/**
|
|
55
|
+
* The inline Dockerfile of a MAS app: the published image plus its entry command. That image is distroless: no
|
|
56
|
+
* shell and no HTTP client, so Coolify's own check (always `sh -c`) cannot run in it. A static busybox is copied in
|
|
57
|
+
* and an exec-form HEALTHCHECK asks MAS's `/health` (a `SELECT 1` on its database). Coolify keeps a Dockerfile's
|
|
58
|
+
* HEALTHCHECK when its own check is off (`health_check_enabled: false`).
|
|
59
|
+
*/
|
|
60
|
+
export function masDockerfile() {
|
|
61
|
+
return [
|
|
62
|
+
`FROM busybox:${pinnedRef('busybox')} AS busybox`,
|
|
63
|
+
`FROM ghcr.io/element-hq/matrix-authentication-service:${pinnedRef('mas')}`,
|
|
64
|
+
'COPY --from=busybox /bin/busybox /busybox',
|
|
65
|
+
'CMD ["server", "--config", "/config/config.yaml"]',
|
|
66
|
+
`HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 CMD ["/busybox", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:${MAS_PORT}/health"]`,
|
|
67
|
+
].join('\n');
|
|
68
|
+
}
|
|
52
69
|
/** #251: the version baked into {@link MAS_IMAGE}'s `FROM` line, named on its own so a version-change line can read it without re-parsing the image string. */
|
|
53
70
|
export const MAS_TAG = VERSIONS.mas;
|
|
54
71
|
/**
|
|
@@ -7,6 +7,12 @@ import type { CoolifyPort } from './executor.js';
|
|
|
7
7
|
* `versions.json` (#247).
|
|
8
8
|
*/
|
|
9
9
|
export declare const CLOUDFLARED_IMAGE_REF: string;
|
|
10
|
+
/**
|
|
11
|
+
* Where the connector serves its metrics, which include `/ready` (200 with at least one edge connection, else 503).
|
|
12
|
+
* The image is distroless, so its check is the binary's own `tunnel ready` in exec form: a Dockerfile HEALTHCHECK,
|
|
13
|
+
* kept by Coolify while its own check is off. The port is not published; only the container network reaches it.
|
|
14
|
+
*/
|
|
15
|
+
export declare const CLOUDFLARED_METRICS_PORT = 20241;
|
|
10
16
|
/**
|
|
11
17
|
* The inline Dockerfile a docker-image `cloudflared` app needs, copied from
|
|
12
18
|
* `scripts/shared-ingress.mjs`'s `cloudflaredDockerfile()`: the published
|
|
@@ -24,7 +30,7 @@ export declare function cloudflaredDockerfile(): string;
|
|
|
24
30
|
* archon) calls this once with its own app name and scope key.
|
|
25
31
|
*/
|
|
26
32
|
export interface EnsureCloudflaredConnectorInput {
|
|
27
|
-
coolify: Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'createDockerfileApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deploy'>;
|
|
33
|
+
coolify: Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'createDockerfileApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deploy'>;
|
|
28
34
|
tunnels: Pick<TunnelPort, 'getTunnelToken'>;
|
|
29
35
|
/** The Cloudflare tunnel this connector dials into (already created and configured by the caller). */
|
|
30
36
|
tunnelId: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { VERSIONS } from '../bootstrap/versions.js';
|
|
1
|
+
import { VERSIONS, pinnedRef } from '../bootstrap/versions.js';
|
|
2
2
|
import { isNotFoundError } from './executor.js';
|
|
3
3
|
/**
|
|
4
4
|
* #197: the pinned `cloudflared` image, the same release tag as
|
|
@@ -7,6 +7,12 @@ import { isNotFoundError } from './executor.js';
|
|
|
7
7
|
* `versions.json` (#247).
|
|
8
8
|
*/
|
|
9
9
|
export const CLOUDFLARED_IMAGE_REF = `cloudflare/cloudflared:${VERSIONS.cloudflared}`;
|
|
10
|
+
/**
|
|
11
|
+
* Where the connector serves its metrics, which include `/ready` (200 with at least one edge connection, else 503).
|
|
12
|
+
* The image is distroless, so its check is the binary's own `tunnel ready` in exec form: a Dockerfile HEALTHCHECK,
|
|
13
|
+
* kept by Coolify while its own check is off. The port is not published; only the container network reaches it.
|
|
14
|
+
*/
|
|
15
|
+
export const CLOUDFLARED_METRICS_PORT = 20241;
|
|
10
16
|
/**
|
|
11
17
|
* The inline Dockerfile a docker-image `cloudflared` app needs, copied from
|
|
12
18
|
* `scripts/shared-ingress.mjs`'s `cloudflaredDockerfile()`: the published
|
|
@@ -17,7 +23,12 @@ export const CLOUDFLARED_IMAGE_REF = `cloudflare/cloudflared:${VERSIONS.cloudfla
|
|
|
17
23
|
* variable `cloudflared` reads at boot.
|
|
18
24
|
*/
|
|
19
25
|
export function cloudflaredDockerfile() {
|
|
20
|
-
return
|
|
26
|
+
return [
|
|
27
|
+
`FROM cloudflare/cloudflared:${pinnedRef('cloudflared')}`,
|
|
28
|
+
`CMD ["tunnel", "--no-autoupdate", "--metrics", "0.0.0.0:${CLOUDFLARED_METRICS_PORT}", "run"]`,
|
|
29
|
+
`HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 CMD ["cloudflared", "tunnel", "--metrics", "127.0.0.1:${CLOUDFLARED_METRICS_PORT}", "ready"]`,
|
|
30
|
+
'',
|
|
31
|
+
].join('\n');
|
|
21
32
|
}
|
|
22
33
|
/**
|
|
23
34
|
* Finds or creates the one Coolify app that runs `cloudflared` for
|
|
@@ -79,6 +90,8 @@ export async function ensureCloudflaredConnector(input) {
|
|
|
79
90
|
});
|
|
80
91
|
appId = created.uuid;
|
|
81
92
|
justCreated = true;
|
|
93
|
+
// Before the first deploy: Coolify reads the Dockerfile's HEALTHCHECK only while its own check is off.
|
|
94
|
+
await coolify.updateApplication(appId, { health_check_enabled: false });
|
|
82
95
|
}
|
|
83
96
|
if (appId !== input.recordedConnectorAppId)
|
|
84
97
|
await input.recordAppId(appId);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { DeclaredOrg } from '../org.js';
|
|
2
|
+
import type { UnmanagedItem } from '../plan.js';
|
|
3
|
+
import type { DraftStep } from './diff.js';
|
|
4
|
+
/**
|
|
5
|
+
* F6: the Postgres databases of a company that get a nightly backup, and the
|
|
6
|
+
* recorded id each one's uuid sits under. The keys are second, documented
|
|
7
|
+
* copies of the constants in `executor.ts` and `platform-providers.ts` (the
|
|
8
|
+
* way `PLATFORM_BACKUP_DATABASE_SCOPE_KEYS` copies the platform's): a renamed
|
|
9
|
+
* key shows up as a database the planner no longer sees, and the test in
|
|
10
|
+
* `platform-providers.backup.test.ts` plans every name here from a recorded id.
|
|
11
|
+
* `app` is the hosting entry's database, recorded as a resource id.
|
|
12
|
+
*/
|
|
13
|
+
export declare function companyDatabaseKeys(org: Pick<DeclaredOrg, 'id'>): Readonly<Record<string, string>>;
|
|
14
|
+
/** What every recorded-id key of a company database's backup schedule starts with. */
|
|
15
|
+
export declare const BACKUP_SCOPE_PREFIX = "backup_";
|
|
16
|
+
/** The recorded-id key a company database's backup schedule uuid is stored under. */
|
|
17
|
+
export declare function backupScopeKey(db: string): string;
|
|
18
|
+
/** The databases an upgrade of `service` can migrate, by the names in {@link companyDatabaseKeys}. Empty: not coverable here. */
|
|
19
|
+
export declare function databasesForService(service: string): readonly string[] | 'all';
|
|
20
|
+
/**
|
|
21
|
+
* One `coolify:backup:<orgSlug>:<db>` step per company database that has a
|
|
22
|
+
* recorded uuid and no recorded backup yet. `storageUuid` undefined means the
|
|
23
|
+
* host did not wire backups, so nothing is planned and nothing is said; null
|
|
24
|
+
* means the platform's backup storage is not registered, so each database is
|
|
25
|
+
* reported as blocked instead (rule 2). A company that runs its own mail
|
|
26
|
+
* server carries a note that the Stalwart volume has no Postgres to dump.
|
|
27
|
+
*/
|
|
28
|
+
export declare function planCompanyBackups(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, storageUuid: string | null | undefined): {
|
|
29
|
+
steps: DraftStep[];
|
|
30
|
+
blocked: UnmanagedItem[];
|
|
31
|
+
};
|