@wtfalch/foundry 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/apply.d.ts +16 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -1
- package/dist/migrations/0030_hosting_entry_hostnames.sql +24 -0
- package/dist/migrations/0031_adoptions.sql +37 -0
- package/dist/org.d.ts +49 -4
- package/dist/org.js +134 -1
- package/dist/plan/adopt.d.ts +112 -0
- package/dist/plan/adopt.js +252 -0
- package/dist/plan/adoption-vault.d.ts +9 -0
- package/dist/plan/adoption-vault.js +1 -0
- package/dist/plan/agora.d.ts +7 -0
- package/dist/plan/agora.js +9 -0
- package/dist/plan/broker-entries.d.ts +108 -0
- package/dist/plan/broker-entries.js +214 -0
- package/dist/plan/cloudflared-connector.d.ts +10 -2
- package/dist/plan/cloudflared-connector.js +24 -16
- package/dist/plan/diff.d.ts +14 -0
- package/dist/plan/diff.js +488 -47
- package/dist/plan/executor.d.ts +141 -3
- package/dist/plan/executor.js +1100 -149
- package/dist/plan/hostnames.d.ts +24 -0
- package/dist/plan/hostnames.js +56 -0
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/locked-hostnames.d.ts +8 -0
- package/dist/plan/locked-hostnames.js +26 -0
- package/dist/plan/login-host.d.ts +1 -1
- package/dist/plan/login-host.js +5 -2
- package/dist/plan/plan-apply.d.ts +63 -3
- package/dist/plan/plan-apply.js +163 -5
- package/dist/plan/platform-diff.d.ts +66 -13
- package/dist/plan/platform-diff.js +117 -15
- package/dist/plan/platform-providers.d.ts +95 -2
- package/dist/plan/platform-providers.js +393 -27
- package/dist/plan/providers-readers.d.ts +8 -1
- package/dist/plan/providers-readers.js +58 -4
- package/dist/plan.d.ts +6 -0
- package/dist/store/postgres.d.ts +6 -1
- package/dist/store/postgres.js +80 -5
- package/dist/store/schema.d.ts +162 -0
- package/dist/store/schema.js +19 -1
- package/dist/store.d.ts +37 -0
- package/package.json +1 -1
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { DeclaredOrg, HostingEntry } from '../org.js';
|
|
2
|
+
/**
|
|
3
|
+
* The full names an entry is served at. With `hosting.usesDomain` off, or no
|
|
4
|
+
* domain, `hostnames` is not read: the `app` entry is at `<slug>.oikos.host`
|
|
5
|
+
* and every other entry is internal. `'*'` names nothing here; the wildcard is
|
|
6
|
+
* acted on elsewhere.
|
|
7
|
+
*/
|
|
8
|
+
export declare function entryHostnames(org: DeclaredOrg, entry: HostingEntry): string[];
|
|
9
|
+
/**
|
|
10
|
+
* The one primary name of the org's app: its first, or the name the app was
|
|
11
|
+
* served at before entries had hostnames. `APP_URL`, the ZITADEL redirect URI
|
|
12
|
+
* and the jobs registration read it.
|
|
13
|
+
*/
|
|
14
|
+
export declare function appHostname(org: DeclaredOrg): string;
|
|
15
|
+
/** What `domains` held on an `app` entry's Coolify app before entries had hostnames. */
|
|
16
|
+
export declare function legacyAppHostname(org: DeclaredOrg): string;
|
|
17
|
+
/**
|
|
18
|
+
* The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
|
|
19
|
+
* #164): a site's two names, every entry's names, an own-domain calls name and
|
|
20
|
+
* Agora's. An org that does not host keeps the single name it always had.
|
|
21
|
+
*/
|
|
22
|
+
export declare function tunnelIngressHostnames(org: DeclaredOrg): string[];
|
|
23
|
+
/** The ingress names that get a DNS record: those under the org's own domain. */
|
|
24
|
+
export declare function tunnelDnsHostnames(org: DeclaredOrg, domain: string): string[];
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { APP_ENTRY_KEY } from '../org.js';
|
|
2
|
+
import { agoraHostnameFor } from './agora.js';
|
|
3
|
+
/**
|
|
4
|
+
* The full names an entry is served at. With `hosting.usesDomain` off, or no
|
|
5
|
+
* domain, `hostnames` is not read: the `app` entry is at `<slug>.oikos.host`
|
|
6
|
+
* and every other entry is internal. `'*'` names nothing here; the wildcard is
|
|
7
|
+
* acted on elsewhere.
|
|
8
|
+
*/
|
|
9
|
+
export function entryHostnames(org, entry) {
|
|
10
|
+
const { domain } = org;
|
|
11
|
+
if (!org.hosting.usesDomain || !domain) {
|
|
12
|
+
return entry.key === APP_ENTRY_KEY ? [`${org.slug}.oikos.host`] : [];
|
|
13
|
+
}
|
|
14
|
+
return entry.hostnames.flatMap((name) => name === '*' ? [] : name === '@' ? [domain] : [`${name}.${domain}`]);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The one primary name of the org's app: its first, or the name the app was
|
|
18
|
+
* served at before entries had hostnames. `APP_URL`, the ZITADEL redirect URI
|
|
19
|
+
* and the jobs registration read it.
|
|
20
|
+
*/
|
|
21
|
+
export function appHostname(org) {
|
|
22
|
+
const entry = org.hosting.entries.find((e) => e.key === APP_ENTRY_KEY);
|
|
23
|
+
const first = entry ? entryHostnames(org, entry)[0] : undefined;
|
|
24
|
+
return first ?? legacyAppHostname(org);
|
|
25
|
+
}
|
|
26
|
+
/** What `domains` held on an `app` entry's Coolify app before entries had hostnames. */
|
|
27
|
+
export function legacyAppHostname(org) {
|
|
28
|
+
return org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
|
|
32
|
+
* #164): a site's two names, every entry's names, an own-domain calls name and
|
|
33
|
+
* Agora's. An org that does not host keeps the single name it always had.
|
|
34
|
+
*/
|
|
35
|
+
export function tunnelIngressHostnames(org) {
|
|
36
|
+
const hostnames = org.site.enabled && org.domain ? [org.domain, `cms.${org.domain}`] : [];
|
|
37
|
+
if (org.hosting.enabled) {
|
|
38
|
+
for (const entry of org.hosting.entries)
|
|
39
|
+
hostnames.push(...entryHostnames(org, entry));
|
|
40
|
+
}
|
|
41
|
+
else if (hostnames.length === 0) {
|
|
42
|
+
hostnames.push(legacyAppHostname(org));
|
|
43
|
+
}
|
|
44
|
+
if (org.calls.enabled && org.calls.usesDomain && org.domain) {
|
|
45
|
+
hostnames.push(`calls.${org.domain}`);
|
|
46
|
+
}
|
|
47
|
+
// #164: Agora always moves onto `agora.<domain>` once `org.domain` is set.
|
|
48
|
+
if (org.domain) {
|
|
49
|
+
hostnames.push(agoraHostnameFor(org));
|
|
50
|
+
}
|
|
51
|
+
return [...new Set(hostnames)];
|
|
52
|
+
}
|
|
53
|
+
/** The ingress names that get a DNS record: those under the org's own domain. */
|
|
54
|
+
export function tunnelDnsHostnames(org, domain) {
|
|
55
|
+
return tunnelIngressHostnames(org).filter((hostname) => hostname === domain || hostname.endsWith(`.${domain}`));
|
|
56
|
+
}
|
package/dist/plan/index.d.ts
CHANGED
|
@@ -24,6 +24,8 @@ export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCop
|
|
|
24
24
|
export type { ScopeResolver, RecordedIds } from './providers-readers.js';
|
|
25
25
|
export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
|
|
26
26
|
export type { AgoraHostPort, AgoraHostValues } from './agora.js';
|
|
27
|
+
export type { BrokerPort } from './broker-entries.js';
|
|
28
|
+
export { PlatformBrokerPort } from './broker-entries.js';
|
|
27
29
|
export type { LoginHostBinding, LoginHostBindingsPort } from './login-host.js';
|
|
28
30
|
export { loginHostnameFor } from './login-host.js';
|
|
29
31
|
export type { MailIdentityApp, MailIdentityAudienceCheck } from './mail-identity.js';
|
package/dist/plan/index.js
CHANGED
|
@@ -12,6 +12,7 @@ export { createZitadelAdminMinter } from './zitadel-machine-user.js';
|
|
|
12
12
|
export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
|
|
13
13
|
export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
14
14
|
export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
|
|
15
|
+
export { PlatformBrokerPort } from './broker-entries.js';
|
|
15
16
|
export { loginHostnameFor } from './login-host.js';
|
|
16
17
|
export { checkMailIdentityAudience } from './mail-identity.js';
|
|
17
18
|
export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { DeclaredOrg } from '../org.js';
|
|
2
|
+
/**
|
|
3
|
+
* The names under the org's domain that foundry owns, so no hosting entry may
|
|
4
|
+
* take one: Agora, the cms studio when the site is on, calls when it uses the
|
|
5
|
+
* domain, the sign-in host when the issuer does, and every platform service
|
|
6
|
+
* hostname that falls under the domain. Empty for an org with no domain.
|
|
7
|
+
*/
|
|
8
|
+
export declare function lockedHostnames(org: DeclaredOrg, platformHostnames: readonly string[]): Set<string>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { agoraHostnameFor } from './agora.js';
|
|
2
|
+
import { loginHostnameFor } from './login-host.js';
|
|
3
|
+
/**
|
|
4
|
+
* The names under the org's domain that foundry owns, so no hosting entry may
|
|
5
|
+
* take one: Agora, the cms studio when the site is on, calls when it uses the
|
|
6
|
+
* domain, the sign-in host when the issuer does, and every platform service
|
|
7
|
+
* hostname that falls under the domain. Empty for an org with no domain.
|
|
8
|
+
*/
|
|
9
|
+
export function lockedHostnames(org, platformHostnames) {
|
|
10
|
+
const { domain } = org;
|
|
11
|
+
const locked = new Set();
|
|
12
|
+
if (!domain)
|
|
13
|
+
return locked;
|
|
14
|
+
locked.add(agoraHostnameFor(org));
|
|
15
|
+
if (org.site.enabled)
|
|
16
|
+
locked.add(`cms.${domain}`);
|
|
17
|
+
if (org.calls.enabled && org.calls.usesDomain)
|
|
18
|
+
locked.add(`calls.${domain}`);
|
|
19
|
+
if (org.issuer.usesDomain)
|
|
20
|
+
locked.add(loginHostnameFor(org));
|
|
21
|
+
for (const hostname of platformHostnames) {
|
|
22
|
+
if (hostname.endsWith(`.${domain}`))
|
|
23
|
+
locked.add(hostname);
|
|
24
|
+
}
|
|
25
|
+
return locked;
|
|
26
|
+
}
|
|
@@ -66,7 +66,7 @@ export interface LoginHostBindingsPort {
|
|
|
66
66
|
* on the next run.
|
|
67
67
|
*/
|
|
68
68
|
export declare const LOGIN_BINDING_SCOPE_KEY = "login_host_binding";
|
|
69
|
-
/** The org's recorded app client ids (hosted app, cms, agora), sorted. */
|
|
69
|
+
/** The org's recorded app client ids (hosted app, each entry's own, cms, agora), sorted. */
|
|
70
70
|
export declare function loginHostClientIds(recorded: Readonly<Record<string, string>>): string[];
|
|
71
71
|
/** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
|
|
72
72
|
export declare function loginBindingState(host: string, clientIds: readonly string[]): string;
|
package/dist/plan/login-host.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, } from './agora.js';
|
|
1
|
+
import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, } from './agora.js';
|
|
2
2
|
/**
|
|
3
3
|
* #267: what both the planner (`diff.ts`) and the executor (`executor.ts`)
|
|
4
4
|
* need to agree on for an org's own company login host -- the hostname and
|
|
@@ -36,12 +36,15 @@ export function loginHostnameFor(org) {
|
|
|
36
36
|
* on the next run.
|
|
37
37
|
*/
|
|
38
38
|
export const LOGIN_BINDING_SCOPE_KEY = 'login_host_binding';
|
|
39
|
-
/** The org's recorded app client ids (hosted app, cms, agora), sorted. */
|
|
39
|
+
/** The org's recorded app client ids (hosted app, each entry's own, cms, agora), sorted. */
|
|
40
40
|
export function loginHostClientIds(recorded) {
|
|
41
41
|
return [
|
|
42
42
|
recorded[APP_CLIENT_ID_SCOPE_KEY],
|
|
43
43
|
recorded[CMS_CLIENT_ID_SCOPE_KEY],
|
|
44
44
|
recorded[AGORA_CLIENT_ID_SCOPE_KEY],
|
|
45
|
+
...Object.entries(recorded)
|
|
46
|
+
.filter(([key]) => key.startsWith(ENTRY_CLIENT_ID_SCOPE_PREFIX))
|
|
47
|
+
.map(([, id]) => id),
|
|
45
48
|
]
|
|
46
49
|
.filter((id) => Boolean(id))
|
|
47
50
|
.sort();
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import type { PlanApply } from '../apply.js';
|
|
1
|
+
import type { AdoptionRestored, PlanApply } from '../apply.js';
|
|
2
2
|
import type { DeclaredOrg, OrgId } from '../org.js';
|
|
3
|
-
import type { Plan, PlanStep } from '../plan.js';
|
|
3
|
+
import type { Plan, PlanStep, UnmanagedItem } from '../plan.js';
|
|
4
4
|
import type { DeclaredPlatform } from '../platform.js';
|
|
5
5
|
import type { AppliedState } from '../state.js';
|
|
6
|
-
import type { DeclaredOrgStore, PlatformStore } from '../store.js';
|
|
6
|
+
import type { Adoption, AdoptionStore, DeclaredOrgStore, PlatformStore } from '../store.js';
|
|
7
|
+
import type { AdoptionVault } from './adoption-vault.js';
|
|
7
8
|
import { type AgoraHostPort } from './agora.js';
|
|
8
9
|
import { type StepAction } from './diff.js';
|
|
9
10
|
import { type LiveReaders, type LiveResource, type PlatformLiveReaders } from './live.js';
|
|
@@ -147,6 +148,26 @@ export interface StepExecutor {
|
|
|
147
148
|
execute(action: StepAction, context: StepExecutionContext): Promise<{
|
|
148
149
|
resourceId?: string;
|
|
149
150
|
} | undefined>;
|
|
151
|
+
/**
|
|
152
|
+
* #311: what each `coolify:adopt` step will change, keyed by the step's key,
|
|
153
|
+
* and the adoption items that are blocked. Read from the live app on every
|
|
154
|
+
* plan. Optional: an executor without it plans the step with no details.
|
|
155
|
+
*/
|
|
156
|
+
adoptionReport?(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>): Promise<{
|
|
157
|
+
details: Record<string, string[]>;
|
|
158
|
+
blocked: UnmanagedItem[];
|
|
159
|
+
/** Resource keys whose live resource could not be read: their steps are not planned. */
|
|
160
|
+
withheld: string[];
|
|
161
|
+
}>;
|
|
162
|
+
/**
|
|
163
|
+
* #311: puts the adopted apps back as their snapshots recorded them and
|
|
164
|
+
* returns the env names it deleted. Refuses before any write when a
|
|
165
|
+
* snapshot's vault entry is missing. Optional like `adoptionReport`.
|
|
166
|
+
*/
|
|
167
|
+
restoreAdoption?(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, rows: readonly Adoption[]): Promise<{
|
|
168
|
+
envDeleted: string[];
|
|
169
|
+
tokenLeft?: string[];
|
|
170
|
+
}>;
|
|
150
171
|
}
|
|
151
172
|
/**
|
|
152
173
|
* #167: {@link StepExecutionContext}'s platform-scoped counterpart -- what
|
|
@@ -202,6 +223,9 @@ export interface PlatformStepExecutor {
|
|
|
202
223
|
driftedSteps?(platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>): Promise<{
|
|
203
224
|
archon?: boolean;
|
|
204
225
|
auth?: boolean;
|
|
226
|
+
backupStorage?: boolean;
|
|
227
|
+
/** #257 fix round 4: see `PlanPlatformOptions.drifted.backupStorageBlocked` (`platform-diff.ts`). */
|
|
228
|
+
backupStorageBlocked?: string;
|
|
205
229
|
}>;
|
|
206
230
|
execute(action: StepAction, context: PlatformStepExecutionContext): Promise<{
|
|
207
231
|
resourceId?: string;
|
|
@@ -247,6 +271,10 @@ export declare class StalePlanError extends Error {
|
|
|
247
271
|
export declare class ConfirmationRequiredError extends Error {
|
|
248
272
|
constructor(orgId: OrgId);
|
|
249
273
|
}
|
|
274
|
+
/** #311: the first successful apply after the adopting one confirmed the snapshot, so it is no longer offered. */
|
|
275
|
+
export declare class AdoptionConfirmedError extends Error {
|
|
276
|
+
constructor(orgId: OrgId, resourceKeys: readonly string[]);
|
|
277
|
+
}
|
|
250
278
|
export declare class NotAuthorizedError extends Error {
|
|
251
279
|
constructor(actor: AuditActor, orgId: OrgId);
|
|
252
280
|
}
|
|
@@ -295,6 +323,12 @@ export interface FoundryPlanApplyOptions {
|
|
|
295
323
|
* before zitadel-login's binding can be written.
|
|
296
324
|
*/
|
|
297
325
|
loginHostBindings?: boolean;
|
|
326
|
+
/**
|
|
327
|
+
* F5: true when the host's executor was built with a `broker` port.
|
|
328
|
+
* Optional, and false without it: each entry's broker entry is then
|
|
329
|
+
* reported blocked instead of planned.
|
|
330
|
+
*/
|
|
331
|
+
broker?: boolean;
|
|
298
332
|
/**
|
|
299
333
|
* #177: true when the host's executor was built with its own `filesOrganisation`
|
|
300
334
|
* / `aiOrganisation` port. Without one, the org step needs the platform's
|
|
@@ -302,6 +336,13 @@ export interface FoundryPlanApplyOptions {
|
|
|
302
336
|
*/
|
|
303
337
|
filesOrganisation?: boolean;
|
|
304
338
|
aiOrganisation?: boolean;
|
|
339
|
+
/**
|
|
340
|
+
* #311: where an adoption keeps the env values of the app it takes over, and
|
|
341
|
+
* the rows that hold the rest of the snapshot. Both are needed: with either
|
|
342
|
+
* absent, each adopted resource is reported blocked and planned for nothing.
|
|
343
|
+
*/
|
|
344
|
+
adoptionVault?: AdoptionVault;
|
|
345
|
+
adoptionStore?: AdoptionStore;
|
|
305
346
|
/** Overridable for tests; defaults to `() => new Date()`. */
|
|
306
347
|
now?: () => Date;
|
|
307
348
|
/**
|
|
@@ -349,13 +390,24 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
349
390
|
private readonly agora;
|
|
350
391
|
private readonly jobsScheduler;
|
|
351
392
|
private readonly loginHostBindings;
|
|
393
|
+
private readonly broker;
|
|
352
394
|
private readonly hostFiles;
|
|
353
395
|
private readonly hostAi;
|
|
396
|
+
private readonly adoptionVault;
|
|
397
|
+
private readonly adoptionStore;
|
|
354
398
|
private readonly now;
|
|
355
399
|
private readonly platformDeps;
|
|
356
400
|
constructor(options: FoundryPlanApplyOptions);
|
|
357
401
|
plan(id: OrgId): Promise<Plan>;
|
|
358
402
|
apply(plan: Plan, confirmedOrgName: string): Promise<AppliedState>;
|
|
403
|
+
/**
|
|
404
|
+
* #311: puts the org's adopted apps back as their snapshots recorded them.
|
|
405
|
+
* Refused unless `confirmedOrgName` matches the org's `displayName`, and
|
|
406
|
+
* when every snapshot is `confirmed`; a `confirmed` row beside an `adopted`
|
|
407
|
+
* one is left as it is. Returns resource keys and env names, never a value
|
|
408
|
+
* (rule 6).
|
|
409
|
+
*/
|
|
410
|
+
restore(id: OrgId, confirmedOrgName: string): Promise<AdoptionRestored>;
|
|
359
411
|
/**
|
|
360
412
|
* #167: {@link plan}, for the shared platform (#166) rather than an org --
|
|
361
413
|
* there is no id to pass, since `DeclaredPlatform` is one singleton record.
|
|
@@ -388,6 +440,14 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
388
440
|
private refuseDestroy;
|
|
389
441
|
private runSteps;
|
|
390
442
|
private computeFreshPlan;
|
|
443
|
+
/** #311: the `details` of each `coolify:adopt` step and the adoption items that are blocked. */
|
|
444
|
+
private addAdoptionReport;
|
|
445
|
+
/**
|
|
446
|
+
* An entry may not take a name foundry owns (`locked-hostnames.ts`). The
|
|
447
|
+
* store checks the names it knows; this adds the platform's own hostnames,
|
|
448
|
+
* which only the plan can read.
|
|
449
|
+
*/
|
|
450
|
+
private invalidHosting;
|
|
391
451
|
/**
|
|
392
452
|
* #177: an org's plan reads the shared platform's own addresses and
|
|
393
453
|
* readiness through the very `platform` deps `planPlatform`/`applyPlatform`
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
3
|
+
import { validateHosting } from '../org.js';
|
|
4
|
+
import { PLATFORM_SERVICE_KEYS } from '../platform.js';
|
|
5
|
+
import { adoptedConnectorKey, adoptedTunnelKey, declaredAdoptUuids } from './adopt.js';
|
|
3
6
|
import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
|
|
4
7
|
import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
|
|
5
8
|
import { readLivePlatformResources, readLiveResources, } from './live.js';
|
|
9
|
+
import { lockedHostnames } from './locked-hostnames.js';
|
|
6
10
|
import { planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
|
|
7
11
|
import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
|
|
8
12
|
export class DeclaredOrgNotFoundError extends Error {
|
|
@@ -25,6 +29,13 @@ export class ConfirmationRequiredError extends Error {
|
|
|
25
29
|
this.name = 'ConfirmationRequiredError';
|
|
26
30
|
}
|
|
27
31
|
}
|
|
32
|
+
/** #311: the first successful apply after the adopting one confirmed the snapshot, so it is no longer offered. */
|
|
33
|
+
export class AdoptionConfirmedError extends Error {
|
|
34
|
+
constructor(orgId, resourceKeys) {
|
|
35
|
+
super(`the snapshot of org ${orgId} is confirmed (${resourceKeys.join(', ')}); it can no longer be restored`);
|
|
36
|
+
this.name = 'AdoptionConfirmedError';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
28
39
|
export class NotAuthorizedError extends Error {
|
|
29
40
|
constructor(actor, orgId) {
|
|
30
41
|
super(`${actor.display} does not hold the permission to apply org ${orgId} (#9, #19)`);
|
|
@@ -63,6 +74,16 @@ export class PlatformConfirmationRequiredError extends Error {
|
|
|
63
74
|
this.name = 'PlatformConfirmationRequiredError';
|
|
64
75
|
}
|
|
65
76
|
}
|
|
77
|
+
/** The steps that read an entry's hostnames: `computeFreshPlan` drops them for an org whose hosting is invalid. */
|
|
78
|
+
const HOSTNAME_STEPS = new Set([
|
|
79
|
+
'coolify:adopt',
|
|
80
|
+
'coolify:app',
|
|
81
|
+
'coolify:broker-entry',
|
|
82
|
+
'coolify:database',
|
|
83
|
+
'cloudflare:tunnel',
|
|
84
|
+
'cloudflare:access',
|
|
85
|
+
'jobs:registration',
|
|
86
|
+
]);
|
|
66
87
|
/**
|
|
67
88
|
* The #19 implementation of {@link PlanApply}: computes a plan against live
|
|
68
89
|
* state, and refuses to apply one that has gone stale, one that touches a
|
|
@@ -80,8 +101,11 @@ export class FoundryPlanApply {
|
|
|
80
101
|
agora;
|
|
81
102
|
jobsScheduler;
|
|
82
103
|
loginHostBindings;
|
|
104
|
+
broker;
|
|
83
105
|
hostFiles;
|
|
84
106
|
hostAi;
|
|
107
|
+
adoptionVault;
|
|
108
|
+
adoptionStore;
|
|
85
109
|
now;
|
|
86
110
|
platformDeps;
|
|
87
111
|
constructor(options) {
|
|
@@ -95,8 +119,11 @@ export class FoundryPlanApply {
|
|
|
95
119
|
this.agora = options.agora;
|
|
96
120
|
this.jobsScheduler = options.jobsScheduler ?? false;
|
|
97
121
|
this.loginHostBindings = options.loginHostBindings ?? false;
|
|
122
|
+
this.broker = options.broker ?? false;
|
|
98
123
|
this.hostFiles = options.filesOrganisation ?? false;
|
|
99
124
|
this.hostAi = options.aiOrganisation ?? false;
|
|
125
|
+
this.adoptionVault = options.adoptionVault;
|
|
126
|
+
this.adoptionStore = options.adoptionStore;
|
|
100
127
|
this.now = options.now ?? (() => new Date());
|
|
101
128
|
this.platformDeps = options.platform;
|
|
102
129
|
}
|
|
@@ -123,6 +150,42 @@ export class FoundryPlanApply {
|
|
|
123
150
|
}
|
|
124
151
|
return this.runSteps(org, plan, detail);
|
|
125
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* #311: puts the org's adopted apps back as their snapshots recorded them.
|
|
155
|
+
* Refused unless `confirmedOrgName` matches the org's `displayName`, and
|
|
156
|
+
* when every snapshot is `confirmed`; a `confirmed` row beside an `adopted`
|
|
157
|
+
* one is left as it is. Returns resource keys and env names, never a value
|
|
158
|
+
* (rule 6).
|
|
159
|
+
*/
|
|
160
|
+
async restore(id, confirmedOrgName) {
|
|
161
|
+
const authorized = await this.authz.canApply(this.actor, id);
|
|
162
|
+
if (!authorized)
|
|
163
|
+
throw new NotAuthorizedError(this.actor, id);
|
|
164
|
+
const org = await this.store.get(id);
|
|
165
|
+
if (!org)
|
|
166
|
+
throw new DeclaredOrgNotFoundError(id);
|
|
167
|
+
if (confirmedOrgName.trim() !== org.displayName.trim())
|
|
168
|
+
throw new ConfirmationRequiredError(id);
|
|
169
|
+
const { adoptionStore, executor } = this;
|
|
170
|
+
if (!this.adoptionVault || !adoptionStore || !executor.restoreAdoption) {
|
|
171
|
+
throw new Error(`restore for org ${id}: no adoption vault is wired`);
|
|
172
|
+
}
|
|
173
|
+
const rows = await adoptionStore.list(id);
|
|
174
|
+
const adopted = rows.filter((row) => row.state === 'adopted');
|
|
175
|
+
const confirmed = rows.filter((row) => row.state === 'confirmed');
|
|
176
|
+
if (adopted.length === 0 && confirmed.length > 0) {
|
|
177
|
+
throw new AdoptionConfirmedError(id, confirmed.map((row) => row.resourceKey));
|
|
178
|
+
}
|
|
179
|
+
const state = await this.store.getAppliedState(id);
|
|
180
|
+
const { envDeleted, tokenLeft } = await executor.restoreAdoption(org, state?.serviceIds ?? {}, adopted);
|
|
181
|
+
for (const row of adopted)
|
|
182
|
+
await adoptionStore.setState(id, row.resourceKey, 'restored');
|
|
183
|
+
return {
|
|
184
|
+
restored: adopted.map((row) => row.resourceKey),
|
|
185
|
+
envDeleted,
|
|
186
|
+
...(tokenLeft?.length ? { tokenLeft } : {}),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
126
189
|
/**
|
|
127
190
|
* #167: {@link plan}, for the shared platform (#166) rather than an org --
|
|
128
191
|
* there is no id to pass, since `DeclaredPlatform` is one singleton record.
|
|
@@ -203,8 +266,25 @@ export class FoundryPlanApply {
|
|
|
203
266
|
}
|
|
204
267
|
const state = await this.store.getAppliedState(id);
|
|
205
268
|
const serviceIds = { ...(state?.serviceIds ?? {}) };
|
|
206
|
-
|
|
269
|
+
// #311: an adopted resource is released, never deleted. Its rows go to
|
|
270
|
+
// `released` and its ids leave `serviceIds` before the first delete, so a
|
|
271
|
+
// retried destroy skips them the same way.
|
|
272
|
+
const adoptedKeys = new Set((await this.adoptionStore?.list(id))?.map((r) => r.resourceKey));
|
|
273
|
+
const targets = destroyTargets(serviceIds, adoptedKeys, declaredAdoptUuids(org));
|
|
207
274
|
try {
|
|
275
|
+
for (const resourceKey of adoptedKeys) {
|
|
276
|
+
delete serviceIds[resourceKey];
|
|
277
|
+
await this.adoptionStore?.setState(id, resourceKey, 'released');
|
|
278
|
+
}
|
|
279
|
+
if (adoptedKeys.size > 0) {
|
|
280
|
+
await this.store.putAppliedState({
|
|
281
|
+
orgId: id,
|
|
282
|
+
serviceIds,
|
|
283
|
+
checkpoints: state?.checkpoints ?? [],
|
|
284
|
+
failedFields: state?.failedFields ?? [],
|
|
285
|
+
lastApplied: state?.lastApplied ?? null,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
208
288
|
for (const target of targets) {
|
|
209
289
|
// A provider 404 counts as deleted -- `StepDestroyer.destroy`'s own
|
|
210
290
|
// doc comment -- so nothing here needs to special-case "already
|
|
@@ -272,6 +352,7 @@ export class FoundryPlanApply {
|
|
|
272
352
|
const orgId = plan.orgId;
|
|
273
353
|
const previous = await this.store.getAppliedState(orgId);
|
|
274
354
|
const serviceIds = { ...(previous?.serviceIds ?? {}) };
|
|
355
|
+
const adoptedBefore = (await this.adoptionStore?.list(orgId))?.filter((row) => row.state === 'adopted');
|
|
275
356
|
const checkpoints = [];
|
|
276
357
|
// Backed by the very `serviceIds` map this method persists once, at the
|
|
277
358
|
// end — see `StepExecutionContext`'s doc comment for why.
|
|
@@ -335,6 +416,20 @@ export class FoundryPlanApply {
|
|
|
335
416
|
lastApplied: { at: this.now().toISOString(), declaredOrgVersion: hashOrg(org) },
|
|
336
417
|
};
|
|
337
418
|
await this.store.putAppliedState(applied);
|
|
419
|
+
// #311: the first successful apply after the adopting one confirms the
|
|
420
|
+
// snapshot. A row taken after the last successful apply was taken by this
|
|
421
|
+
// one, or by one that failed, and is not confirmed yet.
|
|
422
|
+
// Only that same snapshot: one this apply took again is not confirmed by it.
|
|
423
|
+
for (const row of adoptedBefore ?? []) {
|
|
424
|
+
if (previous?.lastApplied && row.takenAt <= previous.lastApplied.at) {
|
|
425
|
+
const now = await this.adoptionStore?.get(orgId, row.resourceKey);
|
|
426
|
+
if (now?.state === 'adopted' &&
|
|
427
|
+
now.takenAt === row.takenAt &&
|
|
428
|
+
now.settings.uuid === row.settings.uuid) {
|
|
429
|
+
await this.adoptionStore?.setState(orgId, row.resourceKey, 'confirmed');
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
338
433
|
await this.audit.recordApply({
|
|
339
434
|
actor: this.actor,
|
|
340
435
|
orgId,
|
|
@@ -348,15 +443,74 @@ export class FoundryPlanApply {
|
|
|
348
443
|
const org = await this.store.get(id);
|
|
349
444
|
if (!org)
|
|
350
445
|
throw new DeclaredOrgNotFoundError(id);
|
|
351
|
-
const [live, applied, platformReadiness, agoraHost] = await Promise.all([
|
|
446
|
+
const [live, applied, platformReadiness, agoraHost, adopted] = await Promise.all([
|
|
352
447
|
readLiveResources(id, this.readers),
|
|
353
448
|
this.store.getAppliedState(id),
|
|
354
449
|
this.orgPlatformReadiness(),
|
|
355
450
|
this.orgAgoraHost(id),
|
|
451
|
+
this.adoptionStore?.list(id) ?? [],
|
|
356
452
|
]);
|
|
357
|
-
const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
|
|
453
|
+
const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
|
|
454
|
+
loginHostBindings: this.loginHostBindings,
|
|
455
|
+
broker: this.broker,
|
|
456
|
+
adoption: {
|
|
457
|
+
wired: Boolean(this.adoptionVault && this.adoptionStore),
|
|
458
|
+
adopted,
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
await this.addAdoptionReport(org, detail, applied?.serviceIds ?? {});
|
|
462
|
+
const invalid = await this.invalidHosting(org);
|
|
463
|
+
if (invalid) {
|
|
464
|
+
// Rule 2: reported in `unmanaged`, not thrown. Steps that read the
|
|
465
|
+
// hostnames are dropped, so `apply` does not act on them.
|
|
466
|
+
detail.plannedSteps = detail.plannedSteps.filter(({ action }) => !HOSTNAME_STEPS.has(`${action.system}:${action.kind}`));
|
|
467
|
+
detail.unmanaged.push({
|
|
468
|
+
description: `${org.slug}'s hosting is blocked, not planned: ${invalid}`,
|
|
469
|
+
key: 'hosting:invalid',
|
|
470
|
+
});
|
|
471
|
+
}
|
|
358
472
|
return { org, plan: toPlan(detail), detail };
|
|
359
473
|
}
|
|
474
|
+
/** #311: the `details` of each `coolify:adopt` step and the adoption items that are blocked. */
|
|
475
|
+
async addAdoptionReport(org, detail, recordedIds) {
|
|
476
|
+
if (!this.adoptionVault || !this.adoptionStore || !this.executor.adoptionReport)
|
|
477
|
+
return;
|
|
478
|
+
const report = await this.executor.adoptionReport(org, recordedIds);
|
|
479
|
+
// A resource that cannot be read gets neither its adopt step nor its own (rule 2).
|
|
480
|
+
const withheld = new Set(report.withheld);
|
|
481
|
+
// The tunnel step waits for the adoption of the tunnel and of its connector (#312).
|
|
482
|
+
const tunnelHeld = withheld.has(adoptedTunnelKey(org)) || withheld.has(adoptedConnectorKey(org));
|
|
483
|
+
detail.plannedSteps = detail.plannedSteps.filter(({ action }) => !(action.system === 'coolify' && action.kind === 'adopt' && withheld.has(action.key)) &&
|
|
484
|
+
!(action.system === 'coolify' &&
|
|
485
|
+
(action.kind === 'app' || action.kind === 'database') &&
|
|
486
|
+
withheld.has(`coolify:${action.kind}:${action.key}`)) &&
|
|
487
|
+
!(action.system === 'cloudflare' && action.kind === 'tunnel' && tunnelHeld));
|
|
488
|
+
for (const { action, step } of detail.plannedSteps) {
|
|
489
|
+
const key = action.system === 'coolify' && action.kind === 'adopt'
|
|
490
|
+
? action.key
|
|
491
|
+
: action.system === 'cloudflare' && action.kind === 'tunnel'
|
|
492
|
+
? adoptedTunnelKey(org)
|
|
493
|
+
: undefined;
|
|
494
|
+
if (key !== undefined && report.details[key])
|
|
495
|
+
step.details = report.details[key];
|
|
496
|
+
}
|
|
497
|
+
detail.unmanaged.push(...report.blocked);
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* An entry may not take a name foundry owns (`locked-hostnames.ts`). The
|
|
501
|
+
* store checks the names it knows; this adds the platform's own hostnames,
|
|
502
|
+
* which only the plan can read.
|
|
503
|
+
*/
|
|
504
|
+
async invalidHosting(org) {
|
|
505
|
+
const platform = await this.platformDeps?.store.get();
|
|
506
|
+
const platformHostnames = platform
|
|
507
|
+
? PLATFORM_SERVICE_KEYS.map((k) => platform[k].hostname)
|
|
508
|
+
: [];
|
|
509
|
+
return validateHosting(org.hosting, {
|
|
510
|
+
domain: org.domain,
|
|
511
|
+
locked: lockedHostnames(org, platformHostnames),
|
|
512
|
+
});
|
|
513
|
+
}
|
|
360
514
|
/**
|
|
361
515
|
* #177: an org's plan reads the shared platform's own addresses and
|
|
362
516
|
* readiness through the very `platform` deps `planPlatform`/`applyPlatform`
|
|
@@ -549,7 +703,7 @@ const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
|
|
|
549
703
|
* belongs to is already left in place -- deleting only the rule and not the
|
|
550
704
|
* zone is a decision left for whoever wires teardown in, not made here.
|
|
551
705
|
*/
|
|
552
|
-
function destroyTargets(serviceIds) {
|
|
706
|
+
function destroyTargets(serviceIds, adoptedKeys, adoptedUuids) {
|
|
553
707
|
// Reverse-teardown order: everything under one of these prefixes is
|
|
554
708
|
// collected in this order, then every ZITADEL app (there can be several,
|
|
555
709
|
// #71), then the two scope ids last.
|
|
@@ -566,6 +720,9 @@ function destroyTargets(serviceIds) {
|
|
|
566
720
|
for (const [resourceKey, id] of Object.entries(serviceIds)) {
|
|
567
721
|
if (!resourceKey.startsWith(prefix))
|
|
568
722
|
continue;
|
|
723
|
+
// Never delete what was adopted: a row in any state, or a declared uuid (#311).
|
|
724
|
+
if (adoptedKeys.has(resourceKey) || adoptedUuids.has(id))
|
|
725
|
+
continue;
|
|
569
726
|
const [system, kind] = prefix.split(':');
|
|
570
727
|
targets.push({ system, kind, key: resourceKey.slice(prefix.length), id });
|
|
571
728
|
}
|
|
@@ -604,7 +761,8 @@ function destroyServiceIdKey(target) {
|
|
|
604
761
|
const STEP_FIELDS = {
|
|
605
762
|
'zitadel:issuer': 'issuer.usesDomain',
|
|
606
763
|
'zitadel:owner': 'owner',
|
|
607
|
-
'zitadel:customers': 'hosting.
|
|
764
|
+
'zitadel:customers': 'hosting.entries.uses.auth',
|
|
765
|
+
'coolify:broker-entry': 'hosting.entries.uses.auth',
|
|
608
766
|
'coolify:mail': 'mail',
|
|
609
767
|
'coolify:chat': 'chat.usesDomain',
|
|
610
768
|
'coolify:calls': 'calls.enabled',
|