@wtfalch/foundry 0.1.0 → 0.3.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 +14 -0
- package/dist/calls-sfu-deploy-support.d.ts +57 -0
- package/dist/calls-sfu-deploy-support.js +82 -0
- package/dist/calls-sfu.d.ts +175 -0
- package/dist/calls-sfu.js +239 -0
- package/dist/index.d.ts +11 -6
- package/dist/index.js +7 -4
- package/dist/migrate.d.ts +38 -0
- package/dist/migrate.js +47 -0
- package/dist/migrations/0008_hosting.sql +24 -0
- package/dist/migrations/0009_hosting_git_branch_build_pack.sql +14 -0
- package/dist/migrations/0010_owner.sql +13 -0
- package/dist/migrations/0011_test_org.sql +12 -0
- package/dist/migrations/0012_audit.sql +21 -0
- package/dist/migrations/0013_access_emails.sql +6 -0
- package/dist/org.d.ts +130 -0
- package/dist/org.js +102 -0
- package/dist/plan/diff.d.ts +20 -5
- package/dist/plan/diff.js +533 -9
- package/dist/plan/executor.d.ts +823 -0
- package/dist/plan/executor.js +1242 -0
- package/dist/plan/index.d.ts +6 -4
- package/dist/plan/index.js +3 -2
- package/dist/plan/live.d.ts +8 -4
- package/dist/plan/plan-apply.d.ts +114 -4
- package/dist/plan/plan-apply.js +266 -14
- package/dist/plan/providers-readers.d.ts +102 -14
- package/dist/plan/providers-readers.js +245 -16
- package/dist/provision.d.ts +56 -0
- package/dist/provision.js +69 -0
- package/dist/slug.d.ts +12 -18
- package/dist/slug.js +66 -0
- package/dist/state.d.ts +26 -5
- package/dist/store/audit.d.ts +8 -0
- package/dist/store/audit.js +43 -0
- package/dist/store/postgres.d.ts +3 -1
- package/dist/store/postgres.js +84 -2
- package/dist/store/schema.d.ts +378 -0
- package/dist/store/schema.js +32 -1
- package/dist/store.d.ts +12 -0
- package/package.json +15 -11
package/dist/plan/index.d.ts
CHANGED
|
@@ -2,7 +2,9 @@ export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, File
|
|
|
2
2
|
export { readLiveResources } from './live.js';
|
|
3
3
|
export type { StepAction, PlannedStep, PlanDetail } from './diff.js';
|
|
4
4
|
export { computePlanDetail, toPlan } from './diff.js';
|
|
5
|
-
export type { AuditActor, AuditLog, AuthzGate, StepExecutor, FoundryPlanApplyOptions, } from './plan-apply.js';
|
|
6
|
-
export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, } from './plan-apply.js';
|
|
7
|
-
export type { ScopeResolver } from './providers-readers.js';
|
|
8
|
-
export { ProvidersCloudflareReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, } from './providers-readers.js';
|
|
5
|
+
export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, FoundryPlanApplyOptions, } from './plan-apply.js';
|
|
6
|
+
export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, } from './plan-apply.js';
|
|
7
|
+
export type { ScopeResolver, RecordedIds } from './providers-readers.js';
|
|
8
|
+
export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
|
|
9
|
+
export type { CoolifyPort, CloudflarePort, ZitadelPort, FilesOrgLimits, AiOrgLimits, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, } from './executor.js';
|
|
10
|
+
export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
|
package/dist/plan/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { readLiveResources } from './live.js';
|
|
2
2
|
export { computePlanDetail, toPlan } from './diff.js';
|
|
3
|
-
export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, } from './plan-apply.js';
|
|
4
|
-
export { ProvidersCloudflareReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, } from './providers-readers.js';
|
|
3
|
+
export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, } from './plan-apply.js';
|
|
4
|
+
export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
|
|
5
|
+
export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
|
package/dist/plan/live.d.ts
CHANGED
|
@@ -3,10 +3,14 @@ import type { OrgId } from '../org.js';
|
|
|
3
3
|
* One resource as seen live in one of foundry's six systems.
|
|
4
4
|
*
|
|
5
5
|
* `key` is unique within `(system, kind)`. Per-org singleton resources
|
|
6
|
-
* (issuer, mail, chat, storage's spend limit, AI's budget, the cms site
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* (issuer, mail, chat, storage's spend limit, AI's budget, the cms site, the
|
|
7
|
+
* org's own app, its database, its Cloudflare tunnel — see `diff.ts`) are
|
|
8
|
+
* keyed by the {@link OrgId} itself. Two resources key by something else
|
|
9
|
+
* instead (#71): the Cloudflare zone is keyed by the domain name, since a
|
|
10
|
+
* zone belongs to a domain rather than an org, and each declared ZITADEL
|
|
11
|
+
* issuer app is keyed by its own declared name, since an org can declare
|
|
12
|
+
* more than one. Anything else foundry can see in that system's account/org
|
|
13
|
+
* is keyed however that system names it, so it can still be matched, or left
|
|
10
14
|
* {@link https://github.com/wtfalch/foundry/issues/10 unmanaged}, by
|
|
11
15
|
* `computePlanDetail`.
|
|
12
16
|
*/
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { PlanApply } from '../apply.js';
|
|
2
|
-
import type { OrgId } from '../org.js';
|
|
2
|
+
import type { DeclaredOrg, OrgId } from '../org.js';
|
|
3
3
|
import type { Plan } from '../plan.js';
|
|
4
4
|
import type { AppliedState } from '../state.js';
|
|
5
5
|
import type { DeclaredOrgStore } from '../store.js';
|
|
6
6
|
import { type StepAction } from './diff.js';
|
|
7
|
-
import { type LiveReaders } from './live.js';
|
|
7
|
+
import { type LiveReaders, type LiveResource } from './live.js';
|
|
8
8
|
/** Who did it, for `@wtfalch/audit`'s "who, when, the plan, the result" (#9, #19). */
|
|
9
9
|
export interface AuditActor {
|
|
10
10
|
readonly id: string;
|
|
@@ -37,6 +37,14 @@ export interface AuditLog {
|
|
|
37
37
|
reason?: string;
|
|
38
38
|
at: Date;
|
|
39
39
|
}): Promise<void>;
|
|
40
|
+
/** `FoundryPlanApply.destroy`'s own record -- no `Plan` involved, so this is its own entry shape rather than a reuse of `recordApply`'s. */
|
|
41
|
+
recordDestroy(entry: {
|
|
42
|
+
actor: AuditActor;
|
|
43
|
+
orgId: OrgId;
|
|
44
|
+
outcome: 'destroyed' | 'refused' | 'failed';
|
|
45
|
+
reason?: string;
|
|
46
|
+
at: Date;
|
|
47
|
+
}): Promise<void>;
|
|
40
48
|
}
|
|
41
49
|
/**
|
|
42
50
|
* The seam onto `@wtfalch/authz`, gating `apply` only — `plan` is a read
|
|
@@ -47,18 +55,101 @@ export interface AuditLog {
|
|
|
47
55
|
*/
|
|
48
56
|
export interface AuthzGate {
|
|
49
57
|
canApply(actor: AuditActor, orgId: OrgId): Promise<boolean>;
|
|
58
|
+
/**
|
|
59
|
+
* Gates `destroy` -- deleting every resource foundry made for an org, then
|
|
60
|
+
* the org itself, is a stronger action than applying config, so it is its
|
|
61
|
+
* own permission rather than reusing `canApply`.
|
|
62
|
+
*/
|
|
63
|
+
canDestroy(actor: AuditActor, orgId: OrgId): Promise<boolean>;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* What {@link StepExecutor.execute} needs beyond the {@link StepAction}
|
|
67
|
+
* itself to act: the declared org (a real executor names its own live
|
|
68
|
+
* resources after `org.slug`/`org.bespokeAppRepo`/etc., not just the bare
|
|
69
|
+
* ids a `StepAction` carries) and read/write access to this org's *scope*
|
|
70
|
+
* ids — a Coolify project uuid, the org's own ZITADEL org/project id, a
|
|
71
|
+
* Cloudflare zone id — the ids one step's executor resolves or creates that a
|
|
72
|
+
* later step (or a later apply) needs again (executor decisions,
|
|
73
|
+
* `.claude/decisions-2026-09-24-executor.md`: "Scope ids: a Coolify project
|
|
74
|
+
* per org (`serviceIds['coolify_project']`) ... Created resources are
|
|
75
|
+
* recorded under `system:kind:key`").
|
|
76
|
+
*
|
|
77
|
+
* Decision (executor.ts, #20): rather than backing `recordScopeId` with its
|
|
78
|
+
* own direct `DeclaredOrgStore.putAppliedState` call, `recordedIds`/
|
|
79
|
+
* `recordScopeId` below are backed by the very `serviceIds` map `runSteps`
|
|
80
|
+
* already accumulates and persists once, atomically, at the end of a run. A
|
|
81
|
+
* direct store write from inside `execute` would race that final write:
|
|
82
|
+
* `runSteps` captures `previous?.serviceIds` once before the loop starts, so
|
|
83
|
+
* its own end-of-loop `store.putAppliedState` would silently clobber
|
|
84
|
+
* anything an executor wrote to the store mid-loop. Routing scope ids
|
|
85
|
+
* through the same in-memory map both writes to removes the race entirely,
|
|
86
|
+
* and also means `recordedIds` sees a resource id `runSteps` itself recorded
|
|
87
|
+
* for an *earlier* step in this same run (e.g. the ZITADEL org provisioned
|
|
88
|
+
* by an `issuer` step, needed by a later `app` step) without any extra
|
|
89
|
+
* plumbing.
|
|
90
|
+
*/
|
|
91
|
+
export interface StepExecutionContext {
|
|
92
|
+
readonly org: DeclaredOrg;
|
|
93
|
+
/**
|
|
94
|
+
* Every id foundry has recorded for this org so far — both `system:kind:key`
|
|
95
|
+
* resource ids (`runSteps` records these itself from `execute`'s own return
|
|
96
|
+
* value) and flat provider *scope* ids a step recorded via
|
|
97
|
+
* {@link recordScopeId}. A snapshot at the moment it's called, but since
|
|
98
|
+
* `runSteps` runs one step at a time and mutates the same underlying map
|
|
99
|
+
* this reads from, it always reflects everything recorded so far in this
|
|
100
|
+
* run, plus whatever a previous apply already persisted.
|
|
101
|
+
*/
|
|
102
|
+
recordedIds(): Readonly<Record<string, string>>;
|
|
103
|
+
/**
|
|
104
|
+
* Records a provider *scope* id — `coolify_project`, `zitadel_org`,
|
|
105
|
+
* `zitadel_project`, `cloudflare_zone` — alongside whatever `system:kind:key`
|
|
106
|
+
* resource id `execute`'s own return value records. `key` must carry no
|
|
107
|
+
* `:`, the same rule `state.ts`'s `AppliedState.serviceIds` documents, so a
|
|
108
|
+
* scope key can never collide with the `system:kind:key` form.
|
|
109
|
+
*/
|
|
110
|
+
recordScopeId(key: string, id: string): void;
|
|
50
111
|
}
|
|
51
112
|
/**
|
|
52
113
|
* Makes one {@link StepAction} true against a real system. No implementation
|
|
53
114
|
* ships in this package — #19's job is the seam and the refusal rules,
|
|
54
115
|
* proven against fakes; a real executor (calling Coolify/Zitadel/Cloudflare)
|
|
55
|
-
* is #20's "provision Company Factory end to end".
|
|
116
|
+
* is #20's "provision Company Factory end to end" (`executor.ts`'s
|
|
117
|
+
* `FoundryStepExecutor`).
|
|
56
118
|
*/
|
|
57
119
|
export interface StepExecutor {
|
|
58
|
-
execute(action: StepAction): Promise<{
|
|
120
|
+
execute(action: StepAction, context: StepExecutionContext): Promise<{
|
|
59
121
|
resourceId?: string;
|
|
60
122
|
} | undefined>;
|
|
61
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* One resource `FoundryPlanApply.destroy` has decided to tear down: an entry
|
|
126
|
+
* from `AppliedState.serviceIds` (`resourceKey`'s `system:kind:key` form, or
|
|
127
|
+
* one of the two ZITADEL scope keys, `zitadel_project`/`zitadel_org`),
|
|
128
|
+
* resolved back to the provider id it was recorded under.
|
|
129
|
+
*/
|
|
130
|
+
export interface DestroyTarget {
|
|
131
|
+
system: LiveResource['system'];
|
|
132
|
+
kind: string;
|
|
133
|
+
/** The foundry key this was recorded under: an org id, a declared issuer app's name, or the scope key itself for `kind: 'project' | 'org'`. */
|
|
134
|
+
key: string;
|
|
135
|
+
/** The provider's own id -- exactly what `AppliedState.serviceIds` holds under this resource's key. */
|
|
136
|
+
id: string;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Deletes one resource {@link DestroyTarget} names. The teardown half of
|
|
140
|
+
* {@link StepExecutor}: where `execute` makes a `StepAction` true, `destroy`
|
|
141
|
+
* undoes exactly the one resource it is given, one at a time, in the order
|
|
142
|
+
* `FoundryPlanApply.destroy` calls it. A provider 404 (already gone) counts
|
|
143
|
+
* as deleted, never an error -- a destroy interrupted after a resource was
|
|
144
|
+
* deleted but before its `serviceIds` entry was removed must not fail the
|
|
145
|
+
* same way twice on retry.
|
|
146
|
+
*/
|
|
147
|
+
export interface StepDestroyer {
|
|
148
|
+
destroy(target: DestroyTarget, context: {
|
|
149
|
+
readonly org: DeclaredOrg;
|
|
150
|
+
readonly recordedIds: Readonly<Record<string, string>>;
|
|
151
|
+
}): Promise<void>;
|
|
152
|
+
}
|
|
62
153
|
export declare class DeclaredOrgNotFoundError extends Error {
|
|
63
154
|
constructor(orgId: OrgId);
|
|
64
155
|
}
|
|
@@ -73,10 +164,15 @@ export declare class ConfirmationRequiredError extends Error {
|
|
|
73
164
|
export declare class NotAuthorizedError extends Error {
|
|
74
165
|
constructor(actor: AuditActor, orgId: OrgId);
|
|
75
166
|
}
|
|
167
|
+
/** The goal line's own gate: only a test org can be destroyed. */
|
|
168
|
+
export declare class NotATestOrgError extends Error {
|
|
169
|
+
constructor(orgId: OrgId);
|
|
170
|
+
}
|
|
76
171
|
export interface FoundryPlanApplyOptions {
|
|
77
172
|
store: DeclaredOrgStore;
|
|
78
173
|
readers: LiveReaders;
|
|
79
174
|
executor: StepExecutor;
|
|
175
|
+
destroyer: StepDestroyer;
|
|
80
176
|
audit: AuditLog;
|
|
81
177
|
authz: AuthzGate;
|
|
82
178
|
/** The operator running this plan/apply — every audit row's "who" (#9, #19). */
|
|
@@ -94,6 +190,7 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
94
190
|
private readonly store;
|
|
95
191
|
private readonly readers;
|
|
96
192
|
private readonly executor;
|
|
193
|
+
private readonly destroyer;
|
|
97
194
|
private readonly audit;
|
|
98
195
|
private readonly authz;
|
|
99
196
|
private readonly actor;
|
|
@@ -101,7 +198,20 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
101
198
|
constructor(options: FoundryPlanApplyOptions);
|
|
102
199
|
plan(id: OrgId): Promise<Plan>;
|
|
103
200
|
apply(plan: Plan, confirmedOrgName: string): Promise<AppliedState>;
|
|
201
|
+
/**
|
|
202
|
+
* Deletes every resource foundry recorded for `id`, in reverse creation
|
|
203
|
+
* order, then the declared org and its applied state themselves (goal
|
|
204
|
+
* line). Refused unless the stored `DeclaredOrg` has `test: true` and
|
|
205
|
+
* unless `confirmedOrgName` matches the org's `displayName` -- the same
|
|
206
|
+
* confirmation `apply` requires for a destructive step (#9), required here
|
|
207
|
+
* unconditionally since destroying an org is the most destructive step
|
|
208
|
+
* there is. A failure partway through stops and rethrows, leaving the
|
|
209
|
+
* declared org in place with whatever remains recorded in its applied
|
|
210
|
+
* state, so a retried `destroy` only tears down what's left.
|
|
211
|
+
*/
|
|
212
|
+
destroy(id: OrgId, confirmedOrgName: string): Promise<void>;
|
|
104
213
|
private refuse;
|
|
214
|
+
private refuseDestroy;
|
|
105
215
|
private runSteps;
|
|
106
216
|
private computeFreshPlan;
|
|
107
217
|
}
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -27,6 +27,13 @@ export class NotAuthorizedError extends Error {
|
|
|
27
27
|
this.name = 'NotAuthorizedError';
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
+
/** The goal line's own gate: only a test org can be destroyed. */
|
|
31
|
+
export class NotATestOrgError extends Error {
|
|
32
|
+
constructor(orgId) {
|
|
33
|
+
super(`org ${orgId} is not marked test; only a test org can be destroyed`);
|
|
34
|
+
this.name = 'NotATestOrgError';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
30
37
|
/**
|
|
31
38
|
* The #19 implementation of {@link PlanApply}: computes a plan against live
|
|
32
39
|
* state, and refuses to apply one that has gone stale, one that touches a
|
|
@@ -37,6 +44,7 @@ export class FoundryPlanApply {
|
|
|
37
44
|
store;
|
|
38
45
|
readers;
|
|
39
46
|
executor;
|
|
47
|
+
destroyer;
|
|
40
48
|
audit;
|
|
41
49
|
authz;
|
|
42
50
|
actor;
|
|
@@ -45,6 +53,7 @@ export class FoundryPlanApply {
|
|
|
45
53
|
this.store = options.store;
|
|
46
54
|
this.readers = options.readers;
|
|
47
55
|
this.executor = options.executor;
|
|
56
|
+
this.destroyer = options.destroyer;
|
|
48
57
|
this.audit = options.audit;
|
|
49
58
|
this.authz = options.authz;
|
|
50
59
|
this.actor = options.actor;
|
|
@@ -74,6 +83,71 @@ export class FoundryPlanApply {
|
|
|
74
83
|
}
|
|
75
84
|
return this.runSteps(org, plan, detail);
|
|
76
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Deletes every resource foundry recorded for `id`, in reverse creation
|
|
88
|
+
* order, then the declared org and its applied state themselves (goal
|
|
89
|
+
* line). Refused unless the stored `DeclaredOrg` has `test: true` and
|
|
90
|
+
* unless `confirmedOrgName` matches the org's `displayName` -- the same
|
|
91
|
+
* confirmation `apply` requires for a destructive step (#9), required here
|
|
92
|
+
* unconditionally since destroying an org is the most destructive step
|
|
93
|
+
* there is. A failure partway through stops and rethrows, leaving the
|
|
94
|
+
* declared org in place with whatever remains recorded in its applied
|
|
95
|
+
* state, so a retried `destroy` only tears down what's left.
|
|
96
|
+
*/
|
|
97
|
+
async destroy(id, confirmedOrgName) {
|
|
98
|
+
const authorized = await this.authz.canDestroy(this.actor, id);
|
|
99
|
+
if (!authorized) {
|
|
100
|
+
await this.refuseDestroy(id, 'not authorized');
|
|
101
|
+
throw new NotAuthorizedError(this.actor, id);
|
|
102
|
+
}
|
|
103
|
+
const org = await this.store.get(id);
|
|
104
|
+
if (!org)
|
|
105
|
+
throw new DeclaredOrgNotFoundError(id);
|
|
106
|
+
if (!org.test) {
|
|
107
|
+
await this.refuseDestroy(id, 'org is not marked test; only a test org can be destroyed');
|
|
108
|
+
throw new NotATestOrgError(id);
|
|
109
|
+
}
|
|
110
|
+
if (confirmedOrgName.trim() !== org.displayName.trim()) {
|
|
111
|
+
await this.refuseDestroy(id, "org's name not typed correctly");
|
|
112
|
+
throw new ConfirmationRequiredError(id);
|
|
113
|
+
}
|
|
114
|
+
const state = await this.store.getAppliedState(id);
|
|
115
|
+
const serviceIds = { ...(state?.serviceIds ?? {}) };
|
|
116
|
+
const targets = destroyTargets(serviceIds);
|
|
117
|
+
try {
|
|
118
|
+
for (const target of targets) {
|
|
119
|
+
// A provider 404 counts as deleted -- `StepDestroyer.destroy`'s own
|
|
120
|
+
// doc comment -- so nothing here needs to special-case "already
|
|
121
|
+
// gone" itself.
|
|
122
|
+
await this.destroyer.destroy(target, { org, recordedIds: { ...serviceIds } });
|
|
123
|
+
delete serviceIds[destroyServiceIdKey(target)];
|
|
124
|
+
await this.store.putAppliedState({
|
|
125
|
+
orgId: id,
|
|
126
|
+
serviceIds,
|
|
127
|
+
checkpoints: state?.checkpoints ?? [],
|
|
128
|
+
failedFields: state?.failedFields ?? [],
|
|
129
|
+
lastApplied: state?.lastApplied ?? null,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
await this.store.delete(id);
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
await this.audit.recordDestroy({
|
|
136
|
+
actor: this.actor,
|
|
137
|
+
orgId: id,
|
|
138
|
+
outcome: 'failed',
|
|
139
|
+
reason: e instanceof Error ? e.message : String(e),
|
|
140
|
+
at: this.now(),
|
|
141
|
+
});
|
|
142
|
+
throw e;
|
|
143
|
+
}
|
|
144
|
+
await this.audit.recordDestroy({
|
|
145
|
+
actor: this.actor,
|
|
146
|
+
orgId: id,
|
|
147
|
+
outcome: 'destroyed',
|
|
148
|
+
at: this.now(),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
77
151
|
async refuse(plan, reason) {
|
|
78
152
|
await this.audit.recordApply({
|
|
79
153
|
actor: this.actor,
|
|
@@ -84,16 +158,41 @@ export class FoundryPlanApply {
|
|
|
84
158
|
at: this.now(),
|
|
85
159
|
});
|
|
86
160
|
}
|
|
161
|
+
async refuseDestroy(orgId, reason) {
|
|
162
|
+
await this.audit.recordDestroy({
|
|
163
|
+
actor: this.actor,
|
|
164
|
+
orgId,
|
|
165
|
+
outcome: 'refused',
|
|
166
|
+
reason,
|
|
167
|
+
at: this.now(),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
87
170
|
async runSteps(org, plan, detail) {
|
|
88
171
|
const orgId = plan.orgId;
|
|
89
172
|
const previous = await this.store.getAppliedState(orgId);
|
|
90
173
|
const serviceIds = { ...(previous?.serviceIds ?? {}) };
|
|
91
174
|
const checkpoints = [];
|
|
175
|
+
// Backed by the very `serviceIds` map this method persists once, at the
|
|
176
|
+
// end — see `StepExecutionContext`'s doc comment for why.
|
|
177
|
+
const context = {
|
|
178
|
+
org,
|
|
179
|
+
recordedIds: () => ({ ...serviceIds }),
|
|
180
|
+
recordScopeId: (key, id) => {
|
|
181
|
+
serviceIds[key] = id;
|
|
182
|
+
},
|
|
183
|
+
};
|
|
92
184
|
try {
|
|
93
185
|
for (const planned of detail.plannedSteps) {
|
|
94
|
-
const result = await this.executor.execute(planned.action);
|
|
95
|
-
|
|
96
|
-
|
|
186
|
+
const result = await this.executor.execute(planned.action, context);
|
|
187
|
+
// `system:kind:key`, not just `kind`: two steps can share a `kind`
|
|
188
|
+
// (an org's several declared ZITADEL apps, #71) and would otherwise
|
|
189
|
+
// overwrite each other's id. `providers-readers.ts`'s
|
|
190
|
+
// `storeRecordedIds` reads this same key back to match a plan's live
|
|
191
|
+
// resources to what foundry itself created here.
|
|
192
|
+
if (result?.resourceId) {
|
|
193
|
+
const { system, kind, key } = planned.action;
|
|
194
|
+
serviceIds[`${system}:${kind}:${key}`] = result.resourceId;
|
|
195
|
+
}
|
|
97
196
|
checkpoints.push({ step: planned.step.description, completedAt: this.now().toISOString() });
|
|
98
197
|
}
|
|
99
198
|
}
|
|
@@ -105,7 +204,7 @@ export class FoundryPlanApply {
|
|
|
105
204
|
orgId,
|
|
106
205
|
serviceIds,
|
|
107
206
|
checkpoints,
|
|
108
|
-
failedFields:
|
|
207
|
+
failedFields: failedFields(org, detail, checkpoints.length),
|
|
109
208
|
lastApplied: previous?.lastApplied ?? null,
|
|
110
209
|
});
|
|
111
210
|
await this.audit.recordApply({
|
|
@@ -122,14 +221,7 @@ export class FoundryPlanApply {
|
|
|
122
221
|
orgId,
|
|
123
222
|
serviceIds,
|
|
124
223
|
checkpoints,
|
|
125
|
-
|
|
126
|
-
// "whether apply throws, partially applies, or does both" to #19, but
|
|
127
|
-
// this implementation was written before the field existed and has no
|
|
128
|
-
// opinion on it: `computePlanDetail` plans only the issuer, mail and
|
|
129
|
-
// chat, so it cannot yet say which declared fields it failed to make
|
|
130
|
-
// true. Writing `[]` here would claim it had checked. #20 wires the
|
|
131
|
-
// full provisioning surface and is what computes this list.
|
|
132
|
-
failedFields: previous?.failedFields ?? [],
|
|
224
|
+
failedFields: failedFields(org, detail, detail.plannedSteps.length),
|
|
133
225
|
lastApplied: { at: this.now().toISOString(), declaredOrgVersion: hashOrg(org) },
|
|
134
226
|
};
|
|
135
227
|
await this.store.putAppliedState(applied);
|
|
@@ -146,11 +238,171 @@ export class FoundryPlanApply {
|
|
|
146
238
|
const org = await this.store.get(id);
|
|
147
239
|
if (!org)
|
|
148
240
|
throw new DeclaredOrgNotFoundError(id);
|
|
149
|
-
const live = await
|
|
150
|
-
|
|
241
|
+
const [live, applied] = await Promise.all([
|
|
242
|
+
readLiveResources(id, this.readers),
|
|
243
|
+
this.store.getAppliedState(id),
|
|
244
|
+
]);
|
|
245
|
+
const detail = computePlanDetail(org, live, applied?.serviceIds ?? {});
|
|
151
246
|
return { org, plan: toPlan(detail), detail };
|
|
152
247
|
}
|
|
153
248
|
}
|
|
249
|
+
/** `AppliedState.serviceIds`' two flat ZITADEL scope keys (`executor.ts`'s `ensureZitadelProject`/`zitadelIssuer`) -- no `:`, unlike a resource key. */
|
|
250
|
+
const ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_project';
|
|
251
|
+
const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
|
|
252
|
+
/**
|
|
253
|
+
* Every resource `FoundryPlanApply.destroy` tears down, built from
|
|
254
|
+
* `AppliedState.serviceIds`, in the order the goal line and this campaign's
|
|
255
|
+
* report settle on: the Cloudflare tunnel first, then the Access gate (stop routing traffic to the
|
|
256
|
+
* app before tearing it down), then the org's own Coolify database and app,
|
|
257
|
+
* then the org's customers ZITADEL org (plan gap A9), then every ZITADEL app
|
|
258
|
+
* foundry recorded, then the ZITADEL project, then the
|
|
259
|
+
* ZITADEL org itself -- the one resource every other one in this org lives
|
|
260
|
+
* inside, so it goes last.
|
|
261
|
+
*
|
|
262
|
+
* ZITADEL apps are read directly from `serviceIds` (any key with the
|
|
263
|
+
* `zitadel:app:` prefix), not from the declared org's current
|
|
264
|
+
* `hosting.issuerApps` -- destroy tears down everything foundry actually
|
|
265
|
+
* recorded for this org, including an app since removed from the declared
|
|
266
|
+
* list (drift `diff.ts`'s own `planIssuerApps` doc comment already
|
|
267
|
+
* acknowledges: "removing an app from `issuerApps` entirely produces no step
|
|
268
|
+
* at all").
|
|
269
|
+
*
|
|
270
|
+
* The Cloudflare zone and the Coolify project are deliberately absent: no
|
|
271
|
+
* zone-delete or Coolify-project-delete method was named for this campaign
|
|
272
|
+
* (Finding, this campaign's report), so destroy leaves both in place. The
|
|
273
|
+
* Cloudflare rate limiting rule (#43, `diff.ts`'s `planRateLimit`) is absent
|
|
274
|
+
* for the same reason: it lives inside the same zone's ruleset, no rule-
|
|
275
|
+
* delete method exists on `CloudflarePort` (`executor.ts`), and the zone it
|
|
276
|
+
* belongs to is already left in place -- deleting only the rule and not the
|
|
277
|
+
* zone is a decision left for whoever wires teardown in, not made here.
|
|
278
|
+
*/
|
|
279
|
+
function destroyTargets(serviceIds) {
|
|
280
|
+
// Reverse-teardown order: everything under one of these prefixes is
|
|
281
|
+
// collected in this order, then every ZITADEL app (there can be several,
|
|
282
|
+
// #71), then the two scope ids last.
|
|
283
|
+
const ORDERED_PREFIXES = [
|
|
284
|
+
'cloudflare:tunnel:',
|
|
285
|
+
'cloudflare:access:',
|
|
286
|
+
'coolify:database:',
|
|
287
|
+
'coolify:app:',
|
|
288
|
+
'zitadel:customers:',
|
|
289
|
+
];
|
|
290
|
+
const targets = [];
|
|
291
|
+
for (const prefix of ORDERED_PREFIXES) {
|
|
292
|
+
for (const [resourceKey, id] of Object.entries(serviceIds)) {
|
|
293
|
+
if (!resourceKey.startsWith(prefix))
|
|
294
|
+
continue;
|
|
295
|
+
const [system, kind] = prefix.split(':');
|
|
296
|
+
targets.push({ system, kind, key: resourceKey.slice(prefix.length), id });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
for (const [resourceKey, id] of Object.entries(serviceIds)) {
|
|
300
|
+
if (!resourceKey.startsWith('zitadel:app:'))
|
|
301
|
+
continue;
|
|
302
|
+
targets.push({
|
|
303
|
+
system: 'zitadel',
|
|
304
|
+
kind: 'app',
|
|
305
|
+
key: resourceKey.slice('zitadel:app:'.length),
|
|
306
|
+
id,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
for (const [kind, scopeKey] of [
|
|
310
|
+
['project', ZITADEL_PROJECT_SCOPE_KEY],
|
|
311
|
+
['org', ZITADEL_ORG_SCOPE_KEY],
|
|
312
|
+
]) {
|
|
313
|
+
const id = serviceIds[scopeKey];
|
|
314
|
+
if (id)
|
|
315
|
+
targets.push({ system: 'zitadel', kind, key: scopeKey, id });
|
|
316
|
+
}
|
|
317
|
+
return targets;
|
|
318
|
+
}
|
|
319
|
+
/** The `AppliedState.serviceIds` key a completed {@link DestroyTarget} should be removed from. */
|
|
320
|
+
function destroyServiceIdKey(target) {
|
|
321
|
+
if (target.kind === 'project' || target.kind === 'org')
|
|
322
|
+
return target.key;
|
|
323
|
+
return `${target.system}:${target.kind}:${target.key}`;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* The declared field each planned step makes true, keyed `system:kind` as
|
|
327
|
+
* `diff.ts` plans it. A kind not listed here falls back to `system.kind`, so
|
|
328
|
+
* a new planner step still names something rather than vanishing.
|
|
329
|
+
*/
|
|
330
|
+
const STEP_FIELDS = {
|
|
331
|
+
'zitadel:issuer': 'issuer.usesDomain',
|
|
332
|
+
'zitadel:owner': 'owner',
|
|
333
|
+
'zitadel:customers': 'hosting.issuerApps',
|
|
334
|
+
'coolify:mail': 'mail',
|
|
335
|
+
'coolify:chat': 'chat.usesDomain',
|
|
336
|
+
'files:storage': 'storage',
|
|
337
|
+
'ai:budget': 'ai',
|
|
338
|
+
'cms:site': 'site',
|
|
339
|
+
'coolify:app': 'hosting.enabled',
|
|
340
|
+
'coolify:database': 'hosting.database',
|
|
341
|
+
'cloudflare:tunnel': 'hosting.tunnel',
|
|
342
|
+
'cloudflare:zone': 'hosting.usesDomain',
|
|
343
|
+
'cloudflare:access': 'hosting.accessEmails',
|
|
344
|
+
'cloudflare:ratelimit': 'hosting.usesDomain',
|
|
345
|
+
};
|
|
346
|
+
function stepField(action) {
|
|
347
|
+
// An org declares several issuer apps, each keyed by its name (#71).
|
|
348
|
+
if (action.system === 'zitadel' && action.kind === 'app') {
|
|
349
|
+
return `hosting.issuerApps.${action.key}`;
|
|
350
|
+
}
|
|
351
|
+
return STEP_FIELDS[`${action.system}:${action.kind}`] ?? `${action.system}.${action.kind}`;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* #25: the declared fields this apply could not make true, written fresh on
|
|
355
|
+
* every apply rather than carried forward.
|
|
356
|
+
*
|
|
357
|
+
* Three sources, in this order:
|
|
358
|
+
* - fields no planner step exists for yet, so no apply can make them true:
|
|
359
|
+
* SSO, guests and solo when turned on, calls on the org's domain, a
|
|
360
|
+
* bespoke app repo, an adopted app in the `repo` form (#7's `#15` comment:
|
|
361
|
+
* "declared but refused by `apply`"), and hosting on a domain the org has
|
|
362
|
+
* not recorded;
|
|
363
|
+
* - chat's reverse re-key, which the planner surfaces but which is not built
|
|
364
|
+
* (#7 amendment 3), so it counts as failed even when no step threw;
|
|
365
|
+
* - on a failed apply, the field of every planned step from the one that
|
|
366
|
+
* threw onwards (`completed` is how many steps finished).
|
|
367
|
+
*/
|
|
368
|
+
function failedFields(org, detail, completed) {
|
|
369
|
+
const failed = new Set();
|
|
370
|
+
if (org.sso.enabled)
|
|
371
|
+
failed.add('sso.enabled');
|
|
372
|
+
if (org.guests.enabled)
|
|
373
|
+
failed.add('guests.enabled');
|
|
374
|
+
if (org.solo.enabled)
|
|
375
|
+
failed.add('solo.enabled');
|
|
376
|
+
if (org.bespokeAppRepo !== null)
|
|
377
|
+
failed.add('bespokeAppRepo');
|
|
378
|
+
if (org.adoptedApp?.form === 'repo')
|
|
379
|
+
failed.add('adoptedApp');
|
|
380
|
+
if (org.calls.usesDomain)
|
|
381
|
+
failed.add('calls.usesDomain');
|
|
382
|
+
// `planZone` plans nothing without a domain to put the zone on.
|
|
383
|
+
if (org.hosting.usesDomain && org.domain === null)
|
|
384
|
+
failed.add('hosting.usesDomain');
|
|
385
|
+
// `planTunnel` shares that same domain requirement (#71, map gap N3): a
|
|
386
|
+
// declared `usesDomain: true` with no recorded domain plans no tunnel
|
|
387
|
+
// step either. A domainless org (`usesDomain: false`) is not a failure --
|
|
388
|
+
// it is the level-1 case N3 decided gets no per-org tunnel at all.
|
|
389
|
+
if (org.hosting.tunnel && org.hosting.usesDomain && org.domain === null) {
|
|
390
|
+
failed.add('hosting.tunnel');
|
|
391
|
+
}
|
|
392
|
+
for (const { action, step } of detail.plannedSteps) {
|
|
393
|
+
// Provisioning chat also desires `usesDomain: false`; only the reverse
|
|
394
|
+
// re-key is a destructive step with that value.
|
|
395
|
+
const reverseReKey = action.system === 'coolify' &&
|
|
396
|
+
action.kind === 'chat' &&
|
|
397
|
+
action.desired.usesDomain === false &&
|
|
398
|
+
step.destroys;
|
|
399
|
+
if (reverseReKey)
|
|
400
|
+
failed.add(stepField(action));
|
|
401
|
+
}
|
|
402
|
+
for (const { action } of detail.plannedSteps.slice(completed))
|
|
403
|
+
failed.add(stepField(action));
|
|
404
|
+
return [...failed];
|
|
405
|
+
}
|
|
154
406
|
/** Order-independent structural equality between two plans. */
|
|
155
407
|
function plansMatch(a, b) {
|
|
156
408
|
return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|