@wtfalch/foundry 0.45.0 → 0.46.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/index.d.ts +5 -3
- package/dist/index.js +3 -2
- package/dist/org.d.ts +37 -0
- package/dist/org.js +135 -10
- package/dist/plan/agora.d.ts +14 -0
- package/dist/plan/agora.js +21 -0
- package/dist/plan/backup-gate.d.ts +15 -0
- package/dist/plan/backup-gate.js +38 -0
- package/dist/plan/boule-owners-http.d.ts +12 -0
- package/dist/plan/boule-owners-http.js +59 -0
- package/dist/plan/boule.d.ts +38 -2
- package/dist/plan/boule.js +72 -1
- package/dist/plan/company-backups.d.ts +7 -1
- package/dist/plan/company-backups.js +42 -2
- package/dist/plan/cookie-domain.d.ts +41 -0
- package/dist/plan/cookie-domain.js +71 -0
- package/dist/plan/desktop-auth.d.ts +58 -0
- package/dist/plan/desktop-auth.js +144 -0
- package/dist/plan/desktop-callback.d.ts +26 -0
- package/dist/plan/desktop-callback.js +59 -0
- package/dist/plan/diff.d.ts +13 -4
- package/dist/plan/diff.js +272 -84
- package/dist/plan/executor.d.ts +136 -6
- package/dist/plan/executor.js +725 -98
- package/dist/plan/health-gaps.js +5 -0
- package/dist/plan/index.d.ts +3 -0
- package/dist/plan/index.js +2 -0
- package/dist/plan/job-app.d.ts +23 -0
- package/dist/plan/job-app.js +41 -0
- package/dist/plan/live.d.ts +6 -0
- package/dist/plan/live.js +16 -0
- package/dist/plan/login-host.d.ts +28 -0
- package/dist/plan/login-host.js +36 -0
- package/dist/plan/org-names.js +9 -1
- package/dist/plan/org-postgres-job.d.ts +22 -0
- package/dist/plan/org-postgres-job.js +91 -0
- package/dist/plan/org-postgres-migrate.d.ts +115 -0
- package/dist/plan/org-postgres-migrate.js +466 -0
- package/dist/plan/org-postgres.d.ts +90 -0
- package/dist/plan/org-postgres.js +293 -0
- package/dist/plan/plan-apply.d.ts +30 -2
- package/dist/plan/plan-apply.js +104 -7
- package/dist/plan/platform-diff.d.ts +45 -0
- package/dist/plan/platform-diff.js +159 -4
- package/dist/plan/platform-org.d.ts +2 -0
- package/dist/plan/platform-org.js +4 -0
- package/dist/plan/platform-providers.d.ts +12 -2
- package/dist/plan/platform-providers.js +279 -9
- package/dist/plan/post-logout-uris.d.ts +22 -0
- package/dist/plan/post-logout-uris.js +43 -0
- package/dist/plan/providers-readers.d.ts +10 -1
- package/dist/plan/providers-readers.js +41 -1
- package/dist/plan/sign-in-bindings.d.ts +4 -0
- package/dist/plan/sign-in-bindings.js +10 -0
- package/dist/plan/sign-in-org.d.ts +19 -6
- package/dist/plan/sign-in-org.js +35 -10
- package/dist/plan/sign-in.d.ts +12 -5
- package/dist/plan/sign-in.js +12 -5
- package/dist/plan/signin-db-move.js +10 -34
- package/package.json +5 -5
package/dist/plan/health-gaps.js
CHANGED
|
@@ -32,6 +32,11 @@ export const EXIT_JOBS = [
|
|
|
32
32
|
export const REPO_DOCKERFILE_CHECKED = [
|
|
33
33
|
{ repo: 'wtfalch/auth', dockerfile: '/login/Dockerfile', seen: 'login image, docker inspect' },
|
|
34
34
|
{ repo: 'wtfalch/auth', dockerfile: '/broker/Dockerfile', seen: 'broker image, docker inspect' },
|
|
35
|
+
{
|
|
36
|
+
repo: 'wtfalch/agora',
|
|
37
|
+
dockerfile: '/apps/desktop-auth/Dockerfile',
|
|
38
|
+
seen: 'apps/desktop-auth/Dockerfile HEALTHCHECK line (calls /health)',
|
|
39
|
+
},
|
|
35
40
|
{ repo: 'wtfalch/ai', target: 'worker', seen: 'ai-worker image, docker inspect' },
|
|
36
41
|
{ repo: 'wtfalch/integrations', target: 'runner', seen: 'integrations/Dockerfile line 49' },
|
|
37
42
|
{
|
package/dist/plan/index.d.ts
CHANGED
|
@@ -20,6 +20,9 @@ export type { PlatformOrgClientFactories } from './platform-org-ports.js';
|
|
|
20
20
|
export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
|
|
21
21
|
export type { AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot } from './adopt.js';
|
|
22
22
|
export { AdoptRefusedError } from './adopt.js';
|
|
23
|
+
export { InvalidMigrationConfirmationError, ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationNotConfirmedError, } from './org-postgres-migrate.js';
|
|
24
|
+
export type { OrgPostgresMigrationConfirmation } from './org-postgres-migrate.js';
|
|
25
|
+
export { OrgPostgresMigrationBackupMissingError } from './backup-gate.js';
|
|
23
26
|
export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, FoundryApplyOptions, } from './plan-apply.js';
|
|
24
27
|
export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
|
|
25
28
|
export type { UnconfirmedUpgrade } from './confirm-upgrade.js';
|
package/dist/plan/index.js
CHANGED
|
@@ -9,6 +9,8 @@ export { provisionSfuApp, sfuCompose, sfuConfig } from './own-sfu.js';
|
|
|
9
9
|
export { OWN_FILES_R2_SCOPE_KEY, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, OwnServicesBlockedError, orgNetwork, orgTarget, platformNetwork, platformTarget, scopeFromOrg, scopeFromPlatform, } from './own-services.js';
|
|
10
10
|
export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
|
|
11
11
|
export { AdoptRefusedError } from './adopt.js';
|
|
12
|
+
export { InvalidMigrationConfirmationError, ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationNotConfirmedError, } from './org-postgres-migrate.js';
|
|
13
|
+
export { OrgPostgresMigrationBackupMissingError } from './backup-gate.js';
|
|
12
14
|
export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
|
|
13
15
|
export { MAIL_STORE_SWITCH_SERVICE } from './mail-store.js';
|
|
14
16
|
export { NetworkMoveBackupMissingError, UpgradeBackupMissingError } from './backup-gate.js';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { PlatformAuthDeps } from './platform-providers.js';
|
|
2
|
+
/** A failure a job step words itself. Anything else that goes wrong is reported without its cause, which could carry a URL. */
|
|
3
|
+
export declare class JobFailure extends Error {
|
|
4
|
+
}
|
|
5
|
+
export interface JobRuntime {
|
|
6
|
+
sleep?: (ms: number) => Promise<void>;
|
|
7
|
+
now?: () => number;
|
|
8
|
+
/** How long the job may run (default 30 minutes). */
|
|
9
|
+
signinDbMoveTimeoutMs?: number;
|
|
10
|
+
/** Pause between polls of the job (default 10 seconds). */
|
|
11
|
+
signinDbMovePollMs?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Waits for a one-shot job app to report healthy, which it does only when its work is verified. The
|
|
15
|
+
* job never exits on success, so any exit is final: `restarting` at once, `exited` once the job was
|
|
16
|
+
* seen running or stayed exited for {@link JOB_EXITED_POLLS} polls. It has failed too at the deadline.
|
|
17
|
+
* A healthy status read before any other could be the container before the stop, so it counts only
|
|
18
|
+
* after a different status was seen.
|
|
19
|
+
*/
|
|
20
|
+
export declare function waitForJob(coolify: Pick<PlatformAuthDeps['coolify'], 'getApplication'>, appId: string, runtime: JobRuntime, words: {
|
|
21
|
+
job: string;
|
|
22
|
+
stopped: string;
|
|
23
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** A failure a job step words itself. Anything else that goes wrong is reported without its cause, which could carry a URL. */
|
|
2
|
+
export class JobFailure extends Error {
|
|
3
|
+
}
|
|
4
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
5
|
+
/** Polls of an `exited` job with no `running` seen before it counts as a stop (an `exited` read can be the container before the deploy). */
|
|
6
|
+
const JOB_EXITED_POLLS = 6;
|
|
7
|
+
/**
|
|
8
|
+
* Waits for a one-shot job app to report healthy, which it does only when its work is verified. The
|
|
9
|
+
* job never exits on success, so any exit is final: `restarting` at once, `exited` once the job was
|
|
10
|
+
* seen running or stayed exited for {@link JOB_EXITED_POLLS} polls. It has failed too at the deadline.
|
|
11
|
+
* A healthy status read before any other could be the container before the stop, so it counts only
|
|
12
|
+
* after a different status was seen.
|
|
13
|
+
*/
|
|
14
|
+
export async function waitForJob(coolify, appId, runtime, words) {
|
|
15
|
+
const now = runtime.now ?? Date.now;
|
|
16
|
+
const sleep = runtime.sleep ?? defaultSleep;
|
|
17
|
+
const timeout = runtime.signinDbMoveTimeoutMs ?? 1_800_000;
|
|
18
|
+
const interval = runtime.signinDbMovePollMs ?? 10_000;
|
|
19
|
+
const deadline = now() + timeout;
|
|
20
|
+
let left = false;
|
|
21
|
+
let running = false;
|
|
22
|
+
let exitedPolls = 0;
|
|
23
|
+
for (;;) {
|
|
24
|
+
const { status } = await coolify.getApplication(appId);
|
|
25
|
+
if (status === 'running:healthy' && left)
|
|
26
|
+
return;
|
|
27
|
+
if (status !== 'running:healthy')
|
|
28
|
+
left = true;
|
|
29
|
+
if (status?.startsWith('running'))
|
|
30
|
+
running = true;
|
|
31
|
+
exitedPolls = status?.startsWith('exited') ? exitedPolls + 1 : 0;
|
|
32
|
+
if (status?.startsWith('restarting') ||
|
|
33
|
+
(status?.startsWith('exited') && (running || exitedPolls >= JOB_EXITED_POLLS))) {
|
|
34
|
+
throw new JobFailure(words.stopped);
|
|
35
|
+
}
|
|
36
|
+
if (now() >= deadline) {
|
|
37
|
+
throw new JobFailure(`${words.job} did not finish within ${Math.round(timeout / 1000)}s (last status: ${status ?? 'unknown'})`);
|
|
38
|
+
}
|
|
39
|
+
await sleep(interval);
|
|
40
|
+
}
|
|
41
|
+
}
|
package/dist/plan/live.d.ts
CHANGED
|
@@ -57,6 +57,12 @@ export interface LiveResource {
|
|
|
57
57
|
* clients for `FilesReader`/`AiReader` (and every other reader here) is #20,
|
|
58
58
|
* a non-goal of #27.
|
|
59
59
|
*/
|
|
60
|
+
/**
|
|
61
|
+
* The `zitadel` live resource kind a reader emits, keyed by the app's name,
|
|
62
|
+
* when a recorded app is missing from the listing and a direct read by app id
|
|
63
|
+
* answered not-found (#557). The planner offers the replacement only for it.
|
|
64
|
+
*/
|
|
65
|
+
export declare const APP_GONE_KIND = "app-gone";
|
|
60
66
|
export interface ZitadelReader {
|
|
61
67
|
listResources(orgId: OrgId): Promise<LiveResource[]>;
|
|
62
68
|
}
|
package/dist/plan/live.js
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
import { ownsServices } from '../org.js';
|
|
2
2
|
import { ownHostnames } from './own-hostnames.js';
|
|
3
3
|
import { OWN_SERVICES_APPLIED_SCOPE_KEY, orgTarget } from './own-services.js';
|
|
4
|
+
/**
|
|
5
|
+
* One narrow interface per system (#19 "Done when"): a single read, scoped
|
|
6
|
+
* to one org, returning everything foundry can see there. Real
|
|
7
|
+
* implementations follow `bootstrap/coolify-api.ts`'s pattern in
|
|
8
|
+
* `wtfalch/app-template` — a token read from the environment or the secrets
|
|
9
|
+
* file, never the command line, never logged. Nothing here calls anything
|
|
10
|
+
* real; `plan-apply.test.ts` and `diff.test.ts` use fakes. Wiring real
|
|
11
|
+
* clients for `FilesReader`/`AiReader` (and every other reader here) is #20,
|
|
12
|
+
* a non-goal of #27.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* The `zitadel` live resource kind a reader emits, keyed by the app's name,
|
|
16
|
+
* when a recorded app is missing from the listing and a direct read by app id
|
|
17
|
+
* answered not-found (#557). The planner offers the replacement only for it.
|
|
18
|
+
*/
|
|
19
|
+
export const APP_GONE_KIND = 'app-gone';
|
|
4
20
|
/** Every live resource foundry can see for `orgId`, across all six systems. */
|
|
5
21
|
export async function readLiveResources(orgId, readers, org, orgRecordedIds) {
|
|
6
22
|
const [zitadel, coolify, cloudflare, files, ai, cms] = await Promise.all([
|
|
@@ -104,6 +104,11 @@ export interface LoginHostBinding {
|
|
|
104
104
|
apps?: Record<string, 'agora' | 'boule' | 'archon'>;
|
|
105
105
|
/** In-app sign-in: the login serves this host under `/sign-in`. Absent: the root. */
|
|
106
106
|
basePath?: typeof SIGN_IN_BASE_PATH;
|
|
107
|
+
/**
|
|
108
|
+
* The domain this host's company-wide cookies use: a bare lower-case name that the host equals or sits
|
|
109
|
+
* under. Absent: host-only cookies. An older login ignores the field.
|
|
110
|
+
*/
|
|
111
|
+
cookieDomain?: string;
|
|
107
112
|
}
|
|
108
113
|
/**
|
|
109
114
|
* The app hosts that sign in on their own address when the company has `signIn.inApp`: Agora's and
|
|
@@ -138,6 +143,14 @@ export interface LoginHostBindingsPort {
|
|
|
138
143
|
* The login-host step calls it after all its upserts and before any app is repointed.
|
|
139
144
|
*/
|
|
140
145
|
flush?(): Promise<void>;
|
|
146
|
+
/**
|
|
147
|
+
* Adds or replaces `clientId -> organisation id` entries in the document's `clientOrganizations`, the
|
|
148
|
+
* map the shared login host checks a customers client's organisation scope against. Merged by key.
|
|
149
|
+
* Optional: a port without it cannot write the map, and the plan says so in one fixed line.
|
|
150
|
+
*/
|
|
151
|
+
upsertClientOrganizations?(entries: Readonly<Record<string, string>>): Promise<void>;
|
|
152
|
+
/** Removes those client ids from the map. An id that is absent is done. */
|
|
153
|
+
deleteClientOrganizations?(clientIds: readonly string[]): Promise<void>;
|
|
141
154
|
}
|
|
142
155
|
/**
|
|
143
156
|
* Scope id holding what the binding was last written with
|
|
@@ -160,6 +173,21 @@ export declare function customersEntryKeys(org: DeclaredOrg): Set<string>;
|
|
|
160
173
|
* app hosts that have their own binding (in-app sign-in) are not in the company's.
|
|
161
174
|
*/
|
|
162
175
|
export declare function loginHostClientIds(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, except?: readonly string[]): string[];
|
|
176
|
+
/**
|
|
177
|
+
* Scope id holding the map last written to `clientOrganizations`: sorted `clientId=organisationId` pairs
|
|
178
|
+
* joined by `,` ({@link clientOrganizationsState}). Absent or empty means none.
|
|
179
|
+
*/
|
|
180
|
+
export declare const LOGIN_CLIENT_ORGS_SCOPE_KEY = "login_client_orgs";
|
|
181
|
+
/**
|
|
182
|
+
* The customers clients of the org and the customers organisation each signs in to, for the shared login
|
|
183
|
+
* host to check: every customers entry whose client id and the org's `zitadel:customers:<org.id>` are both
|
|
184
|
+
* recorded. Staff clients are not here.
|
|
185
|
+
*/
|
|
186
|
+
export declare function customersClientOrganizations(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): Record<string, string>;
|
|
187
|
+
/** {@link LOGIN_CLIENT_ORGS_SCOPE_KEY}'s value for a map. */
|
|
188
|
+
export declare function clientOrganizationsState(map: Readonly<Record<string, string>>): string;
|
|
189
|
+
/** The client ids a recorded {@link LOGIN_CLIENT_ORGS_SCOPE_KEY} value holds. */
|
|
190
|
+
export declare function clientOrganizationIds(state: string | undefined): string[];
|
|
163
191
|
/** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
|
|
164
192
|
export declare function loginBindingState(host: string, clientIds: readonly string[]): string;
|
|
165
193
|
/**
|
package/dist/plan/login-host.js
CHANGED
|
@@ -189,6 +189,42 @@ export function loginHostClientIds(org, recorded, except = []) {
|
|
|
189
189
|
].filter((id) => Boolean(id));
|
|
190
190
|
return [...new Set(ids)].filter((id) => !except.includes(id)).sort();
|
|
191
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* Scope id holding the map last written to `clientOrganizations`: sorted `clientId=organisationId` pairs
|
|
194
|
+
* joined by `,` ({@link clientOrganizationsState}). Absent or empty means none.
|
|
195
|
+
*/
|
|
196
|
+
export const LOGIN_CLIENT_ORGS_SCOPE_KEY = 'login_client_orgs';
|
|
197
|
+
/**
|
|
198
|
+
* The customers clients of the org and the customers organisation each signs in to, for the shared login
|
|
199
|
+
* host to check: every customers entry whose client id and the org's `zitadel:customers:<org.id>` are both
|
|
200
|
+
* recorded. Staff clients are not here.
|
|
201
|
+
*/
|
|
202
|
+
export function customersClientOrganizations(org, recorded) {
|
|
203
|
+
const customersOrgId = recorded[`zitadel:customers:${org.id}`];
|
|
204
|
+
if (!customersOrgId)
|
|
205
|
+
return {};
|
|
206
|
+
const map = {};
|
|
207
|
+
for (const key of customersEntryKeys(org)) {
|
|
208
|
+
const clientId = recorded[ENTRY_CLIENT_ID_SCOPE_PREFIX + key];
|
|
209
|
+
if (clientId)
|
|
210
|
+
map[clientId] = customersOrgId;
|
|
211
|
+
}
|
|
212
|
+
return map;
|
|
213
|
+
}
|
|
214
|
+
/** {@link LOGIN_CLIENT_ORGS_SCOPE_KEY}'s value for a map. */
|
|
215
|
+
export function clientOrganizationsState(map) {
|
|
216
|
+
return Object.entries(map)
|
|
217
|
+
.map(([clientId, orgId]) => `${clientId}=${orgId}`)
|
|
218
|
+
.sort()
|
|
219
|
+
.join(',');
|
|
220
|
+
}
|
|
221
|
+
/** The client ids a recorded {@link LOGIN_CLIENT_ORGS_SCOPE_KEY} value holds. */
|
|
222
|
+
export function clientOrganizationIds(state) {
|
|
223
|
+
return (state ?? '')
|
|
224
|
+
.split(',')
|
|
225
|
+
.map((pair) => pair.split('=')[0] ?? '')
|
|
226
|
+
.filter((id) => id.length > 0);
|
|
227
|
+
}
|
|
192
228
|
/** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
|
|
193
229
|
export function loginBindingState(host, clientIds) {
|
|
194
230
|
return `${host} ${clientIds.join(',')}`;
|
package/dist/plan/org-names.js
CHANGED
|
@@ -11,7 +11,15 @@ import { appNameFor } from './platform-diff.js';
|
|
|
11
11
|
*/
|
|
12
12
|
export function companyServiceNames(org) {
|
|
13
13
|
const s = org.slug;
|
|
14
|
-
const names = [
|
|
14
|
+
const names = [
|
|
15
|
+
`${s}-agora`,
|
|
16
|
+
`${s}-boule`,
|
|
17
|
+
`${s}-boule-db`,
|
|
18
|
+
`${s}-postgres`,
|
|
19
|
+
`${s}-postgres-job`,
|
|
20
|
+
`${s}-tunnel`,
|
|
21
|
+
`${s}-cloudflared`,
|
|
22
|
+
];
|
|
15
23
|
if (org.chat?.enabled) {
|
|
16
24
|
names.push(...Object.values(chatInternalNames(s)), ...Object.values(chatRekeyedInternalNames(s)));
|
|
17
25
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { OrgServiceDatabase } from '../org.js';
|
|
2
|
+
/** Env of the job app: the server's admin URL, and one password per home that is not recorded yet. */
|
|
3
|
+
export declare const ORG_POSTGRES_JOB_ADMIN_ENV = "ADMIN_DATABASE_URL";
|
|
4
|
+
/** Env name of a home's owner password: `PW_<HOME>`. */
|
|
5
|
+
export declare function orgPostgresPasswordEnv(schema: string): string;
|
|
6
|
+
/** Env name of the runtime role's password, set for desktop-auth only (its migrate has no role step). */
|
|
7
|
+
export declare function orgPostgresRuntimePasswordEnv(schema: string): string;
|
|
8
|
+
/** Only the home that has no app of its own to create its runtime role gets one from the job. */
|
|
9
|
+
export declare function jobCreatesRuntimeRole(home: OrgServiceDatabase): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* The job's script (D-F). CONNECT stays with PUBLIC, the Postgres default: the template apps create
|
|
12
|
+
* their own runtime role on first boot and none of them grants it CONNECT, so a revoke would lock
|
|
13
|
+
* out the service (found by the T-PROVISION test against Boule's own boot script). What keeps a
|
|
14
|
+
* home closed is the schema: `public` is revoked from PUBLIC and owned by the home's owner.
|
|
15
|
+
* Idempotent per home: a role or database that exists is kept, and an owner
|
|
16
|
+
* password is set only for a home the caller passes, which is a home not yet recorded. Roles are made
|
|
17
|
+
* by `format('%I')` and passwords by `format('%L')`, so no value is spliced into SQL by the shell.
|
|
18
|
+
* It never prints a URL or a password: psql's output is discarded and an error line names the home.
|
|
19
|
+
*/
|
|
20
|
+
export declare function orgPostgresProvisionScript(homes: readonly OrgServiceDatabase[]): string[];
|
|
21
|
+
/** The job: the same pinned image as the legacy databases, so `psql` matches the server's major version. */
|
|
22
|
+
export declare function orgPostgresProvisionDockerfile(homes: readonly OrgServiceDatabase[]): string;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { pinnedRef } from '../bootstrap/versions.js';
|
|
2
|
+
/** Env of the job app: the server's admin URL, and one password per home that is not recorded yet. */
|
|
3
|
+
export const ORG_POSTGRES_JOB_ADMIN_ENV = 'ADMIN_DATABASE_URL';
|
|
4
|
+
const JOB_DONE_FILE = '/tmp/done';
|
|
5
|
+
/** Left by a run that did not finish: a restart of the same container then stops at once instead of running again. */
|
|
6
|
+
const JOB_FAILED_FILE = '/tmp/failed';
|
|
7
|
+
const NAME = /^[a-z][a-z0-9_]{0,62}$/;
|
|
8
|
+
/** Env name of a home's owner password: `PW_<HOME>`. */
|
|
9
|
+
export function orgPostgresPasswordEnv(schema) {
|
|
10
|
+
return `PW_${schema.toUpperCase()}`;
|
|
11
|
+
}
|
|
12
|
+
/** Env name of the runtime role's password, set for desktop-auth only (its migrate has no role step). */
|
|
13
|
+
export function orgPostgresRuntimePasswordEnv(schema) {
|
|
14
|
+
return `${orgPostgresPasswordEnv(schema)}_RT`;
|
|
15
|
+
}
|
|
16
|
+
/** Only the home that has no app of its own to create its runtime role gets one from the job. */
|
|
17
|
+
export function jobCreatesRuntimeRole(home) {
|
|
18
|
+
return home.schema === 'desktop_auth';
|
|
19
|
+
}
|
|
20
|
+
function checked(name) {
|
|
21
|
+
if (!NAME.test(name))
|
|
22
|
+
throw new Error(`Postgres name is not valid: ${name}`);
|
|
23
|
+
return name;
|
|
24
|
+
}
|
|
25
|
+
function homeBlock(home) {
|
|
26
|
+
const owner = checked(home.ownerRole);
|
|
27
|
+
const db = checked(home.database);
|
|
28
|
+
const pw = orgPostgresPasswordEnv(home.schema);
|
|
29
|
+
const rtCreated = jobCreatesRuntimeRole(home);
|
|
30
|
+
const rtPw = orgPostgresRuntimePasswordEnv(home.schema);
|
|
31
|
+
const attributes = home.split ? 'LOGIN CREATEROLE' : 'LOGIN';
|
|
32
|
+
const collation = home.collateC ? " LC_COLLATE ''C'' LC_CTYPE ''C''" : '';
|
|
33
|
+
const lines = [
|
|
34
|
+
`[ -n "\${${pw}:-}" ] || fail "${home.schema}"`,
|
|
35
|
+
...(rtCreated ? [`[ -n "\${${rtPw}:-}" ] || fail "${home.schema}"`] : []),
|
|
36
|
+
`psql "$${ORG_POSTGRES_JOB_ADMIN_ENV}" -X -q -v ON_ERROR_STOP=1 > /dev/null 2>&1 <<'SQL' || fail "${home.schema}"`,
|
|
37
|
+
`\\set owner '${owner}'`,
|
|
38
|
+
`\\set db '${db}'`,
|
|
39
|
+
`\\getenv pw ${pw}`,
|
|
40
|
+
`SELECT format('CREATE ROLE %I ${attributes}', :'owner') WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'owner') \\gexec`,
|
|
41
|
+
`SELECT format('ALTER ROLE %I PASSWORD %L', :'owner', :'pw') \\gexec`,
|
|
42
|
+
`SELECT format('CREATE DATABASE %I OWNER %I TEMPLATE template0 ENCODING ''UTF8''${collation}', :'db', :'owner') WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = :'db') \\gexec`,
|
|
43
|
+
];
|
|
44
|
+
if (rtCreated) {
|
|
45
|
+
const rt = checked(home.runtimeRole);
|
|
46
|
+
lines.push(`\\set rt '${rt}'`, `\\getenv pwrt ${rtPw}`, "SELECT format('CREATE ROLE %I LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS NOREPLICATION', :'rt') WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'rt') \\gexec", "SELECT format('ALTER ROLE %I PASSWORD %L', :'rt', :'pwrt') \\gexec", 'GRANT CONNECT ON DATABASE :"db" TO :"rt";');
|
|
47
|
+
}
|
|
48
|
+
lines.push('\\connect :"db"', 'REVOKE ALL ON SCHEMA public FROM PUBLIC;', 'ALTER SCHEMA public OWNER TO :"owner";');
|
|
49
|
+
if (rtCreated) {
|
|
50
|
+
lines.push('GRANT USAGE ON SCHEMA public TO :"rt";', 'ALTER DEFAULT PRIVILEGES FOR ROLE :"owner" IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO :"rt";', 'ALTER DEFAULT PRIVILEGES FOR ROLE :"owner" IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO :"rt";');
|
|
51
|
+
}
|
|
52
|
+
lines.push('SQL', `echo "org-postgres: ${home.schema} ready"`);
|
|
53
|
+
return lines;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The job's script (D-F). CONNECT stays with PUBLIC, the Postgres default: the template apps create
|
|
57
|
+
* their own runtime role on first boot and none of them grants it CONNECT, so a revoke would lock
|
|
58
|
+
* out the service (found by the T-PROVISION test against Boule's own boot script). What keeps a
|
|
59
|
+
* home closed is the schema: `public` is revoked from PUBLIC and owned by the home's owner.
|
|
60
|
+
* Idempotent per home: a role or database that exists is kept, and an owner
|
|
61
|
+
* password is set only for a home the caller passes, which is a home not yet recorded. Roles are made
|
|
62
|
+
* by `format('%I')` and passwords by `format('%L')`, so no value is spliced into SQL by the shell.
|
|
63
|
+
* It never prints a URL or a password: psql's output is discarded and an error line names the home.
|
|
64
|
+
*/
|
|
65
|
+
export function orgPostgresProvisionScript(homes) {
|
|
66
|
+
if (homes.length === 0)
|
|
67
|
+
throw new Error('org-postgres job: no homes');
|
|
68
|
+
return [
|
|
69
|
+
'#!/bin/sh',
|
|
70
|
+
'set -eu',
|
|
71
|
+
`trap '[ -f ${JOB_DONE_FILE} ] || touch ${JOB_FAILED_FILE}' EXIT`,
|
|
72
|
+
"trap 'exit 1' HUP INT TERM",
|
|
73
|
+
`[ ! -f ${JOB_FAILED_FILE} ] || exit 1`,
|
|
74
|
+
`rm -f ${JOB_DONE_FILE}`,
|
|
75
|
+
'fail() { echo "org-postgres: $1 failed"; exit 1; }',
|
|
76
|
+
...homes.flatMap(homeBlock),
|
|
77
|
+
`echo "org-postgres: ${homes.length} homes ready"`,
|
|
78
|
+
`touch ${JOB_DONE_FILE}`,
|
|
79
|
+
'exec sleep infinity',
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
/** The job: the same pinned image as the legacy databases, so `psql` matches the server's major version. */
|
|
83
|
+
export function orgPostgresProvisionDockerfile(homes) {
|
|
84
|
+
const encoded = Buffer.from(`${orgPostgresProvisionScript(homes).join('\n')}\n`, 'utf8').toString('base64');
|
|
85
|
+
return [
|
|
86
|
+
`FROM postgres:${pinnedRef('postgres')}`,
|
|
87
|
+
`RUN echo '${encoded}' | base64 -d > /usr/local/bin/org-postgres && chmod 0755 /usr/local/bin/org-postgres`,
|
|
88
|
+
'ENTRYPOINT ["/usr/local/bin/org-postgres"]',
|
|
89
|
+
`HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=1 CMD ["test", "-f", "${JOB_DONE_FILE}"]`,
|
|
90
|
+
].join('\n');
|
|
91
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { type DeclaredOrg, type OrgServiceSchema } from '../org.js';
|
|
2
|
+
import type { UnmanagedItem } from '../plan.js';
|
|
3
|
+
import type { DraftStep } from './diff.js';
|
|
4
|
+
import type { CoolifyPort } from './executor.js';
|
|
5
|
+
import { type JobRuntime } from './job-app.js';
|
|
6
|
+
import type { StepScope } from './own-services.js';
|
|
7
|
+
/** Set only by `confirmOrgPostgresMigration`. Without it no migration step is planned, applied or run. */
|
|
8
|
+
export declare const ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY = "org_postgres_migration_confirmed";
|
|
9
|
+
/** The copy job's app id while it exists. */
|
|
10
|
+
export declare const ORG_POSTGRES_MIGRATE_APP_SCOPE_KEY = "org_postgres_migrate_app";
|
|
11
|
+
export interface OrgPostgresMigrationConfirmation {
|
|
12
|
+
/** Id of the staging copy the operator ran this on first, for example `staging-2026-10-20`. 3-80 characters. */
|
|
13
|
+
stagingCopy: string;
|
|
14
|
+
/** Who confirmed. */
|
|
15
|
+
confirmedBy: string;
|
|
16
|
+
/** ISO time. */
|
|
17
|
+
confirmedAt: string;
|
|
18
|
+
}
|
|
19
|
+
/** Thrown before anything is called when the confirmation is not recorded. */
|
|
20
|
+
export declare class OrgPostgresMigrationNotConfirmedError extends Error {
|
|
21
|
+
constructor();
|
|
22
|
+
}
|
|
23
|
+
/** Thrown by `confirmOrgPostgresMigration` for a confirmation that is not valid; nothing is recorded. */
|
|
24
|
+
export declare class InvalidMigrationConfirmationError extends Error {
|
|
25
|
+
readonly reason: string;
|
|
26
|
+
constructor(reason: string);
|
|
27
|
+
}
|
|
28
|
+
/** Why a confirmation is not valid, or null. Pure. */
|
|
29
|
+
export declare function invalidMigrationConfirmation(c: OrgPostgresMigrationConfirmation): string | null;
|
|
30
|
+
/** Pure. The recorded confirmation, or null when absent or not valid. */
|
|
31
|
+
export declare function parseMigrationConfirmation(raw: string | undefined): OrgPostgresMigrationConfirmation | null;
|
|
32
|
+
/** The home a planned `org-postgres-migrate` step names, from its action key `<org.id>:<home>`. */
|
|
33
|
+
export declare function migrateHomeOf(action: {
|
|
34
|
+
key: string;
|
|
35
|
+
desired: Record<string, unknown>;
|
|
36
|
+
}): string;
|
|
37
|
+
export interface OrgPostgresMigrationPlan {
|
|
38
|
+
steps: DraftStep[];
|
|
39
|
+
unmanaged: UnmanagedItem[];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Pure. The migration step for the first home that has a legacy database and is ready, and only once the
|
|
43
|
+
* confirmation is recorded. `legacy` is the homes {@link planOrgPostgres} reports as unmigrated. A home is
|
|
44
|
+
* ready when the server and its database are recorded: provisioning goes first. Unconfirmed: no step, one
|
|
45
|
+
* line. Migrated homes whose old database is still recorded get a line, because the old database is never
|
|
46
|
+
* deleted here.
|
|
47
|
+
*/
|
|
48
|
+
export declare function planOrgPostgresMigration(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, legacy: readonly OrgServiceSchema[]): OrgPostgresMigrationPlan;
|
|
49
|
+
export declare const ORG_POSTGRES_MIGRATE_ENV: {
|
|
50
|
+
readonly oldUrl: "OLD_DATABASE_URL";
|
|
51
|
+
readonly newUrl: "NEW_DATABASE_URL";
|
|
52
|
+
readonly newAdminUrl: "NEW_ADMIN_DATABASE_URL";
|
|
53
|
+
};
|
|
54
|
+
/** The job app's name in Coolify. */
|
|
55
|
+
export declare const orgPostgresMigrateJobName: (org: Pick<DeclaredOrg, "slug">) => string;
|
|
56
|
+
/**
|
|
57
|
+
* The job's script. It reads the old database and writes the new one; the old database is never
|
|
58
|
+
* written. A new database that holds tables is a stop, never a drop. It prints counts and fixed
|
|
59
|
+
* messages, never a URL.
|
|
60
|
+
*/
|
|
61
|
+
export declare function orgPostgresMigrateScript(ownerRole: string): string[];
|
|
62
|
+
/** The job: the same pinned image as the servers, so `pg_dump` matches their major version. */
|
|
63
|
+
export declare function orgPostgresMigrateDockerfile(ownerRole: string): string;
|
|
64
|
+
type MigrateCoolify = Pick<CoolifyPort, 'findOrCreateEnvironment' | 'findApplicationByName' | 'getApplication' | 'createDockerfileApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deploy' | 'raw'>;
|
|
65
|
+
/** The apps that use a home's database. */
|
|
66
|
+
export interface MigrationConsumers {
|
|
67
|
+
appIds: readonly string[];
|
|
68
|
+
/** A template app takes `DATABASE_URL_OWNER` and `DATABASE_URL`; a matrix stack renders its config from the server on the next apply. */
|
|
69
|
+
template: boolean;
|
|
70
|
+
}
|
|
71
|
+
export interface OrgPostgresMigrateRuntime extends JobRuntime {
|
|
72
|
+
/** How long a consumer may take to report stopped (default 2 minutes). */
|
|
73
|
+
signinDbStopTimeoutMs?: number;
|
|
74
|
+
/** How long a consumer may take to report healthy on the new database (default 5 minutes). */
|
|
75
|
+
readyTimeoutMs?: number;
|
|
76
|
+
}
|
|
77
|
+
/** Everything the step takes from the executor. New ones go here, never as positional arguments. */
|
|
78
|
+
export interface OrgPostgresMigrateDeps {
|
|
79
|
+
coolify: MigrateCoolify;
|
|
80
|
+
ensureProject(): Promise<string>;
|
|
81
|
+
serverUuid(): Promise<string>;
|
|
82
|
+
/** Starts the company's Postgres server when it is not running and returns its live record. */
|
|
83
|
+
startServer(uuid: string): Promise<{
|
|
84
|
+
internal_db_url?: string;
|
|
85
|
+
status?: string;
|
|
86
|
+
}>;
|
|
87
|
+
/** Reads an old Coolify database as it is. Never starts, writes or deletes it. */
|
|
88
|
+
readLegacyDatabase(uuid: string): Promise<{
|
|
89
|
+
internal_db_url?: string;
|
|
90
|
+
status?: string;
|
|
91
|
+
}>;
|
|
92
|
+
/** The password the host holds under a ref, or undefined. */
|
|
93
|
+
resolveSecret(ref: string): Promise<string | undefined>;
|
|
94
|
+
consumers(home: OrgServiceSchema): Promise<MigrationConsumers>;
|
|
95
|
+
onSecret?(value: string): void;
|
|
96
|
+
runtime?: OrgPostgresMigrateRuntime;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Copies one home's data from its legacy Coolify database to the company's Postgres server (#444).
|
|
100
|
+
*
|
|
101
|
+
* 1. The recorded confirmation is checked before anything is called.
|
|
102
|
+
* 2. The consumer apps are stopped, and the step waits until Coolify reports them stopped.
|
|
103
|
+
* 3. A one-shot job app dumps the old database into the new, empty one and compares the tables, the
|
|
104
|
+
* row count of each and the value of each sequence in both.
|
|
105
|
+
* 4. A template app gets the new `DATABASE_URL_OWNER` and `DATABASE_URL` (the runtime password it has now).
|
|
106
|
+
* 5. `org_postgres_migrated.<home>` is recorded and persisted BEFORE a consumer starts on the new database.
|
|
107
|
+
*
|
|
108
|
+
* The old database is never written to or deleted. A failure before step 4 starts the consumers on the
|
|
109
|
+
* old database again. A failure around step 4 reads the live env back and acts on what is really set;
|
|
110
|
+
* when it cannot tell, the consumers stay stopped and the error says so. A rerun that finds the new
|
|
111
|
+
* URL already set records and starts without copying. A matrix stack is left stopped: its config is
|
|
112
|
+
* rendered from the server by the next apply, which starts it.
|
|
113
|
+
*/
|
|
114
|
+
export declare function migrateOrgPostgresHome(scope: StepScope, deps: OrgPostgresMigrateDeps, org: DeclaredOrg, homeKey: string): Promise<void>;
|
|
115
|
+
export {};
|