@wtfalch/foundry 0.34.0 → 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 +20 -0
- package/dist/bootstrap/versions.js +46 -0
- package/dist/calls-sfu.js +3 -3
- package/dist/index.d.ts +9 -4
- package/dist/index.js +5 -2
- package/dist/migrations/0053_heartbeats_provider.sql +9 -0
- package/dist/migrations/0053_org_version_pin.sql +6 -0
- package/dist/org.d.ts +60 -14
- package/dist/org.js +118 -28
- 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.js +3 -3
- package/dist/plan/cloudflared-connector.js +2 -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 +17 -8
- package/dist/plan/diff.js +131 -131
- package/dist/plan/edge-access.d.ts +15 -0
- package/dist/plan/edge-access.js +21 -2
- package/dist/plan/executor.d.ts +166 -7
- package/dist/plan/executor.js +547 -106
- 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 +5 -0
- package/dist/plan/held-key-refs.js +5 -0
- package/dist/plan/hostnames.d.ts +10 -0
- package/dist/plan/hostnames.js +14 -0
- package/dist/plan/index.d.ts +5 -0
- package/dist/plan/index.js +3 -0
- package/dist/plan/live.d.ts +6 -0
- package/dist/plan/live.js +3 -2
- package/dist/plan/locked-hostnames.d.ts +2 -1
- package/dist/plan/locked-hostnames.js +16 -3
- package/dist/plan/login-host.d.ts +9 -20
- package/dist/plan/login-host.js +10 -25
- package/dist/plan/mail-relay.d.ts +48 -29
- package/dist/plan/mail-relay.js +145 -68
- 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 +8 -2
- package/dist/plan/org-names.js +57 -1
- package/dist/plan/own-diff.d.ts +9 -8
- package/dist/plan/own-diff.js +42 -34
- package/dist/plan/own-hostnames.d.ts +27 -0
- package/dist/plan/own-hostnames.js +24 -0
- package/dist/plan/own-limits.d.ts +198 -7
- package/dist/plan/own-limits.js +163 -32
- package/dist/plan/own-services.js +3 -1
- package/dist/plan/own-sfu.js +3 -2
- package/dist/plan/plan-apply.d.ts +47 -7
- package/dist/plan/plan-apply.js +184 -38
- package/dist/plan/platform-diff.d.ts +133 -17
- package/dist/plan/platform-diff.js +231 -68
- package/dist/plan/platform-org.d.ts +79 -10
- package/dist/plan/platform-org.js +93 -13
- package/dist/plan/platform-providers.d.ts +118 -73
- package/dist/plan/platform-providers.js +770 -231
- 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/zitadel-login-policy.d.ts +1 -1
- package/dist/plan/zitadel-login-policy.js +76 -8
- package/dist/platform.d.ts +32 -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 +25 -3
- package/dist/store/postgres.js +43 -1
- package/dist/store/schema.d.ts +36 -0
- package/dist/store/schema.js +4 -0
- package/package.json +3 -3
- package/versions.digests.json +11 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
|
@@ -59,8 +59,8 @@ export const MAS_PORT = 8080;
|
|
|
59
59
|
*/
|
|
60
60
|
export function masDockerfile() {
|
|
61
61
|
return [
|
|
62
|
-
`FROM busybox:${
|
|
63
|
-
`FROM
|
|
62
|
+
`FROM busybox:${pinnedRef('busybox')} AS busybox`,
|
|
63
|
+
`FROM ghcr.io/element-hq/matrix-authentication-service:${pinnedRef('mas')}`,
|
|
64
64
|
'COPY --from=busybox /bin/busybox /busybox',
|
|
65
65
|
'CMD ["server", "--config", "/config/config.yaml"]',
|
|
66
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"]`,
|
|
@@ -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
|
|
@@ -24,7 +24,7 @@ export const CLOUDFLARED_METRICS_PORT = 20241;
|
|
|
24
24
|
*/
|
|
25
25
|
export function cloudflaredDockerfile() {
|
|
26
26
|
return [
|
|
27
|
-
`FROM
|
|
27
|
+
`FROM cloudflare/cloudflared:${pinnedRef('cloudflared')}`,
|
|
28
28
|
`CMD ["tunnel", "--no-autoupdate", "--metrics", "0.0.0.0:${CLOUDFLARED_METRICS_PORT}", "run"]`,
|
|
29
29
|
`HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 CMD ["cloudflared", "tunnel", "--metrics", "127.0.0.1:${CLOUDFLARED_METRICS_PORT}", "ready"]`,
|
|
30
30
|
'',
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { ownsServices } from '../org.js';
|
|
2
|
+
import { BOULE_DATABASE_SCOPE_KEY } from './boule.js';
|
|
3
|
+
import { CHAT_REKEY_MAS_DB_SCOPE_KEY, CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY } from './executor.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 function companyDatabaseKeys(org) {
|
|
14
|
+
return {
|
|
15
|
+
app: `coolify:database:${org.id}`,
|
|
16
|
+
cms: 'coolify_cms_database',
|
|
17
|
+
auth: 'coolify_platform_auth_database',
|
|
18
|
+
files: 'coolify_platform_files_database',
|
|
19
|
+
jobs: 'coolify_jobs_database',
|
|
20
|
+
chat: 'coolify_chat_service_database',
|
|
21
|
+
calls: 'coolify_calls_service_database',
|
|
22
|
+
synapse: 'coolify_calls_synapse_database',
|
|
23
|
+
mas: 'coolify_calls_mas_database',
|
|
24
|
+
chat_synapse: 'coolify_chat_synapse_database',
|
|
25
|
+
chat_mas: 'coolify_chat_mas_database',
|
|
26
|
+
boule: BOULE_DATABASE_SCOPE_KEY,
|
|
27
|
+
chat_rekey_synapse: CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY,
|
|
28
|
+
chat_rekey_mas: CHAT_REKEY_MAS_DB_SCOPE_KEY,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** What every recorded-id key of a company database's backup schedule starts with. */
|
|
32
|
+
export const BACKUP_SCOPE_PREFIX = 'backup_';
|
|
33
|
+
/** The recorded-id key a company database's backup schedule uuid is stored under. */
|
|
34
|
+
export function backupScopeKey(db) {
|
|
35
|
+
return `${BACKUP_SCOPE_PREFIX}${db}`;
|
|
36
|
+
}
|
|
37
|
+
/** The databases an upgrade of `service` can migrate, by the names in {@link companyDatabaseKeys}. Empty: not coverable here. */
|
|
38
|
+
export function databasesForService(service) {
|
|
39
|
+
switch (service) {
|
|
40
|
+
case 'zitadel':
|
|
41
|
+
return ['auth'];
|
|
42
|
+
case 'files':
|
|
43
|
+
return ['files'];
|
|
44
|
+
case 'jobs':
|
|
45
|
+
return ['jobs'];
|
|
46
|
+
case 'synapse':
|
|
47
|
+
return ['synapse', 'chat_synapse'];
|
|
48
|
+
case 'mas':
|
|
49
|
+
return ['mas', 'chat_mas'];
|
|
50
|
+
case 'postgres':
|
|
51
|
+
return 'all';
|
|
52
|
+
default:
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* One `coolify:backup:<orgSlug>:<db>` step per company database that has a
|
|
58
|
+
* recorded uuid and no recorded backup yet. `storageUuid` undefined means the
|
|
59
|
+
* host did not wire backups, so nothing is planned and nothing is said; null
|
|
60
|
+
* means the platform's backup storage is not registered, so each database is
|
|
61
|
+
* reported as blocked instead (rule 2). A company that runs its own mail
|
|
62
|
+
* server carries a note that the Stalwart volume has no Postgres to dump.
|
|
63
|
+
*/
|
|
64
|
+
export function planCompanyBackups(org, recordedIds, storageUuid) {
|
|
65
|
+
const steps = [];
|
|
66
|
+
const blocked = [];
|
|
67
|
+
if (storageUuid === undefined)
|
|
68
|
+
return { steps, blocked };
|
|
69
|
+
for (const [db, key] of Object.entries(companyDatabaseKeys(org))) {
|
|
70
|
+
const databaseId = recordedIds[key];
|
|
71
|
+
if (!databaseId || recordedIds[backupScopeKey(db)])
|
|
72
|
+
continue;
|
|
73
|
+
if (!storageUuid) {
|
|
74
|
+
blocked.push({
|
|
75
|
+
description: `${org.slug}'s ${db} database backup is blocked, not planned: the platform's backup storage is not registered yet`,
|
|
76
|
+
key: `backup-blocked:${org.slug}:${db}`,
|
|
77
|
+
});
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
steps.push({
|
|
81
|
+
action: {
|
|
82
|
+
system: 'coolify',
|
|
83
|
+
kind: 'backup',
|
|
84
|
+
key: `${org.slug}:${db}`,
|
|
85
|
+
desired: { databaseId, database: db, storageUuid },
|
|
86
|
+
},
|
|
87
|
+
step: {
|
|
88
|
+
description: `schedule a nightly backup of ${org.slug}'s ${db} database to the platform's backup bucket`,
|
|
89
|
+
destroys: false,
|
|
90
|
+
migrates: false,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (ownsServices(org)) {
|
|
95
|
+
blocked.push({
|
|
96
|
+
description: `${org.slug}'s mail server keeps its data on a volume, not in Postgres: no backup covers it yet`,
|
|
97
|
+
key: 'backup-not-covered:mail',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return { steps, blocked };
|
|
101
|
+
}
|
|
@@ -69,6 +69,12 @@ export interface DeployAndWaitPort {
|
|
|
69
69
|
deploymentUuid: string;
|
|
70
70
|
status: string;
|
|
71
71
|
}>;
|
|
72
|
+
/** Present on a real client; `build-record.ts` reads it to record the built commit. */
|
|
73
|
+
listApplicationDeployments?(uuid: string, take?: number): Promise<Array<{
|
|
74
|
+
deployment_uuid: string;
|
|
75
|
+
commit?: string;
|
|
76
|
+
status: string;
|
|
77
|
+
}>>;
|
|
72
78
|
}
|
|
73
79
|
/**
|
|
74
80
|
* How a call site proves the new version is the one that answers (or that
|
|
@@ -114,7 +120,9 @@ export interface DeployHealthRuntime {
|
|
|
114
120
|
* with no such ordering constraint calls {@link waitForNewDeployment}
|
|
115
121
|
* instead, never this plus {@link waitForHealthyVersion} by hand.
|
|
116
122
|
*/
|
|
117
|
-
export declare function waitForDeploymentFinished(coolify: DeployAndWaitPort, appId: string, runtime: DeployHealthRuntime, label: string, appliedRefDescription: string): Promise<
|
|
123
|
+
export declare function waitForDeploymentFinished(coolify: DeployAndWaitPort, appId: string, runtime: DeployHealthRuntime, label: string, appliedRefDescription: string): Promise<{
|
|
124
|
+
deploymentUuid: string;
|
|
125
|
+
}>;
|
|
118
126
|
/**
|
|
119
127
|
* Phase 2 alone: the health check named by `health.kind`, with no deploy and
|
|
120
128
|
* no Coolify polling -- see {@link waitForDeploymentFinished}'s own doc
|
|
@@ -133,4 +141,6 @@ export declare function waitForHealthyVersion(appId: string, health: DeployHealt
|
|
|
133
141
|
* what is still live so a caller's error is actionable without the helper
|
|
134
142
|
* itself knowing any step's scope keys.
|
|
135
143
|
*/
|
|
136
|
-
export declare function waitForNewDeployment(coolify: DeployAndWaitPort, appId: string, health: DeployHealthCheck, runtime: DeployHealthRuntime, sleep: (ms: number) => Promise<void>, label: string, appliedRefDescription: string): Promise<
|
|
144
|
+
export declare function waitForNewDeployment(coolify: DeployAndWaitPort, appId: string, health: DeployHealthCheck, runtime: DeployHealthRuntime, sleep: (ms: number) => Promise<void>, label: string, appliedRefDescription: string): Promise<{
|
|
145
|
+
deploymentUuid: string;
|
|
146
|
+
}>;
|
|
@@ -93,6 +93,7 @@ export async function waitForDeploymentFinished(coolify, appId, runtime, label,
|
|
|
93
93
|
if (status !== DEPLOYMENT_SUCCEEDED) {
|
|
94
94
|
throw new Error(`${label}: the deployment (${deploymentUuid}) of ${appId} did not finish (status: ${status}); ${appliedRefDescription}. Apply again`);
|
|
95
95
|
}
|
|
96
|
+
return { deploymentUuid };
|
|
96
97
|
}
|
|
97
98
|
/**
|
|
98
99
|
* Phase 2 alone: the health check named by `health.kind`, with no deploy and
|
|
@@ -132,8 +133,9 @@ export async function waitForHealthyVersion(appId, health, runtime, sleep, label
|
|
|
132
133
|
* itself knowing any step's scope keys.
|
|
133
134
|
*/
|
|
134
135
|
export async function waitForNewDeployment(coolify, appId, health, runtime, sleep, label, appliedRefDescription) {
|
|
135
|
-
await waitForDeploymentFinished(coolify, appId, runtime, label, appliedRefDescription);
|
|
136
|
+
const { deploymentUuid } = await waitForDeploymentFinished(coolify, appId, runtime, label, appliedRefDescription);
|
|
136
137
|
await waitForHealthyVersion(appId, health, runtime, sleep, label, appliedRefDescription);
|
|
138
|
+
return { deploymentUuid };
|
|
137
139
|
}
|
|
138
140
|
async function matchesHealth(fetchImpl, health) {
|
|
139
141
|
try {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { CoolifyPort } from './executor.js';
|
|
2
|
+
/** The Docker network of the server's default destination: everything on the platform side. */
|
|
3
|
+
export declare const PLATFORM_NETWORK = "coolify";
|
|
4
|
+
/**
|
|
5
|
+
* Whether a customer company gets its own `co-<slug>` network. Off for go-live: every company shares the
|
|
6
|
+
* platform network, because Archon (on it) calls each company's files, mail admin and jobs at internal
|
|
7
|
+
* addresses and Coolify cannot put Archon on a second network. Per-company networks return after go-live,
|
|
8
|
+
* once a design lists every call that crosses the split. `let` so tests can turn it on.
|
|
9
|
+
*/
|
|
10
|
+
export declare let COMPANY_NETWORKS: boolean;
|
|
11
|
+
/** Test-only: turns the per-company networks on or off. */
|
|
12
|
+
export declare function setCompanyNetworksForTests(on: boolean): void;
|
|
13
|
+
/** A company runs on the platform network: the owner always, a customer while {@link COMPANY_NETWORKS} is off. */
|
|
14
|
+
export declare function onPlatformNetwork(ownerCompany: boolean | undefined): boolean;
|
|
15
|
+
/** Coolify's destination name for a company, so a listing says who owns it. */
|
|
16
|
+
export declare const COMPANY_DESTINATION_NAME_PREFIX = "foundry:";
|
|
17
|
+
/** Docker's default address pools give about 30 user networks; stop a little short. */
|
|
18
|
+
export declare const DEFAULT_MAX_DESTINATIONS = 25;
|
|
19
|
+
/** The company's own Docker network: `co-<slug>`. */
|
|
20
|
+
export declare function companyNetworkName(slug: string): string;
|
|
21
|
+
/** Refuses a name Docker or this module cannot take; the message names no value. */
|
|
22
|
+
export declare function validateNetworkName(network: unknown): string;
|
|
23
|
+
/** A server already holds as many networks as the capacity gate allows. */
|
|
24
|
+
export declare class DestinationCapacityError extends Error {
|
|
25
|
+
readonly limit: number;
|
|
26
|
+
constructor(limit: number);
|
|
27
|
+
}
|
|
28
|
+
type DestinationRaw = Pick<CoolifyPort, 'raw'>;
|
|
29
|
+
/** The uuid of the server's destination on `network`, or undefined. Creates nothing. */
|
|
30
|
+
export declare function findDestination(coolify: DestinationRaw, serverUuid: string, network: string): Promise<string | undefined>;
|
|
31
|
+
/**
|
|
32
|
+
* The uuid of the server's destination on `network`; creates it when absent.
|
|
33
|
+
* Coolify makes the Docker network and attaches its proxy to it when the
|
|
34
|
+
* destination is created. Each call has a 60 second timeout. A create that
|
|
35
|
+
* fails (Coolify answers 409 when the network already exists) is followed by
|
|
36
|
+
* one more listing; found there, that uuid is used, so two runs at once and a
|
|
37
|
+
* re-run after a crash converge on one destination. `maxDestinations` stops a
|
|
38
|
+
* create on a server that already holds that many (`DestinationCapacityError`,
|
|
39
|
+
* before anything is created).
|
|
40
|
+
*/
|
|
41
|
+
export declare function ensureDestination(coolify: DestinationRaw, serverUuid: string, network: string, options?: {
|
|
42
|
+
name?: string;
|
|
43
|
+
maxDestinations?: number;
|
|
44
|
+
}): Promise<string>;
|
|
45
|
+
type PlacedPort = Partial<Pick<CoolifyPort, 'createGithubAppApplication' | 'createDockerImageApplication' | 'createDockerfileApplication' | 'createPostgresDatabase'>>;
|
|
46
|
+
/**
|
|
47
|
+
* The Coolify port with `destination_uuid` added to the four create calls
|
|
48
|
+
* (the other calls pass through). `resolve` gives the destination of the
|
|
49
|
+
* input's `server_uuid`; it runs once per server for this port. A create that
|
|
50
|
+
* already names a destination keeps it. A `resolve` that gives nothing leaves
|
|
51
|
+
* the create as it was. Composes with `limited`: `limited(placed(port, ...))`.
|
|
52
|
+
*/
|
|
53
|
+
export declare function placed<C extends PlacedPort>(coolify: C, resolve: (serverUuid: string) => Promise<string | undefined>): C;
|
|
54
|
+
/**
|
|
55
|
+
* `placed` on the platform's destination (Docker network `coolify`) of each
|
|
56
|
+
* server. Once a server has a company destination, a create without
|
|
57
|
+
* `destination_uuid` is refused by Coolify, so every platform create names the
|
|
58
|
+
* default one. A server whose listing has no `coolify` destination, and a port
|
|
59
|
+
* with no `raw`, get the create unchanged.
|
|
60
|
+
*/
|
|
61
|
+
export declare function placedOnPlatform<C extends PlacedPort & {
|
|
62
|
+
raw?: CoolifyPort['raw'];
|
|
63
|
+
}>(coolify: C): C;
|
|
64
|
+
/**
|
|
65
|
+
* `placed`, with the destination chosen by the step now running: `scope` gives a key (the company) and
|
|
66
|
+
* the resolver for it, or nothing, and a create then goes out as it was. One `placed` wrapper is made
|
|
67
|
+
* per key, so its answers are never shared between companies.
|
|
68
|
+
*/
|
|
69
|
+
export declare function placedByScope<C extends PlacedPort>(coolify: C, scope: () => {
|
|
70
|
+
key: string;
|
|
71
|
+
resolve: (serverUuid: string) => Promise<string | undefined>;
|
|
72
|
+
} | undefined): C;
|
|
73
|
+
export {};
|