@wtfalch/foundry 0.1.0 → 0.2.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/index.d.ts +6 -4
- package/dist/index.js +3 -2
- 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/org.d.ts +107 -0
- package/dist/org.js +65 -0
- package/dist/plan/diff.d.ts +20 -5
- package/dist/plan/diff.js +402 -9
- package/dist/plan/executor.d.ts +474 -0
- package/dist/plan/executor.js +507 -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 +251 -14
- package/dist/plan/providers-readers.d.ts +48 -13
- package/dist/plan/providers-readers.js +93 -16
- package/dist/state.d.ts +26 -5
- package/dist/store/postgres.d.ts +3 -1
- package/dist/store/postgres.js +65 -2
- package/dist/store/schema.d.ts +190 -0
- package/dist/store/schema.js +18 -0
- package/dist/store.d.ts +10 -0
- package/package.json +9 -11
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import type { DeclaredOrg, HostingBuildPack, OrgId } from '../org.js';
|
|
2
|
+
import type { StepAction } from './diff.js';
|
|
3
|
+
import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
|
|
4
|
+
/**
|
|
5
|
+
* The real {@link StepExecutor} (#20 "provision Company Factory end to
|
|
6
|
+
* end"): makes each {@link StepAction} `diff.ts` plans true against a real
|
|
7
|
+
* Coolify, Cloudflare and ZITADEL, plus per-org organisations, spend limits
|
|
8
|
+
* and (for AI) offers on Files and AI (#80). Scope and design are exactly
|
|
9
|
+
* what `.claude/decisions-2026-09-24-executor.md`'s "Executor decisions"
|
|
10
|
+
* section settled, referenced by name at each dispatch branch below.
|
|
11
|
+
*
|
|
12
|
+
* Built entirely from narrow ports (interfaces), never a concrete
|
|
13
|
+
* `@wtfalch/providers` client: this package holds no credential and never
|
|
14
|
+
* will (executor decisions: "the executor is constructed with narrow ports
|
|
15
|
+
* ... a host [archon] supplies real clients. How the host gets provider
|
|
16
|
+
* tokens from the keys Worker is the host's job, not this campaign's").
|
|
17
|
+
* Every port method here is named and shaped to match a real method on
|
|
18
|
+
* `@wtfalch/providers`' `CoolifyClient`/`CloudflareClient`/`ZitadelClient`
|
|
19
|
+
* (checked against `providers` `origin/main`, 2026-09-24), or on
|
|
20
|
+
* `@wtfalch/files`'/`@wtfalch/ai`'s own SDK clients (#80, checked against
|
|
21
|
+
* each repo's own `main`, 2026-09-25), so a real client satisfies the port
|
|
22
|
+
* structurally, with no adapter needed — see each port's own doc comment for
|
|
23
|
+
* the one real method it does not yet have.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* The Coolify calls this executor makes, shaped to match
|
|
27
|
+
* `@wtfalch/providers/coolify`'s `CoolifyClient` method-for-method — a real
|
|
28
|
+
* `CoolifyClient` instance satisfies this port with no adapter.
|
|
29
|
+
*
|
|
30
|
+
* There is no single `findOrCreateProject`/stop/start on `CoolifyClient`
|
|
31
|
+
* today (unlike ZITADEL's `findOrCreateOrg`/`findOrCreateProject`, which
|
|
32
|
+
* already exist): `findProjectByName` + `createProject` compose into
|
|
33
|
+
* "find or create" inside this executor instead of the port, and `raw` —
|
|
34
|
+
* `CoolifyClient`'s own documented escape hatch for "use — or add — a typed
|
|
35
|
+
* method instead" — carries the stop/start calls Coolify's REST API has
|
|
36
|
+
* (`POST /applications/{uuid}/stop`, `POST /databases/{uuid}/stop`) that
|
|
37
|
+
* `CoolifyClient` has not wrapped yet. Logged as a Decision in this
|
|
38
|
+
* campaign's report rather than inventing port methods (`findOrCreateProject`,
|
|
39
|
+
* `stop`, `start`) no real client could structurally satisfy.
|
|
40
|
+
*/
|
|
41
|
+
export interface CoolifyPort {
|
|
42
|
+
findProjectByName(name: string): Promise<{
|
|
43
|
+
uuid: string;
|
|
44
|
+
name: string;
|
|
45
|
+
} | null>;
|
|
46
|
+
createProject(name: string, description?: string): Promise<{
|
|
47
|
+
uuid: string;
|
|
48
|
+
}>;
|
|
49
|
+
findOrCreateEnvironment(projectUuid: string, name: string): Promise<{
|
|
50
|
+
environment: {
|
|
51
|
+
id: number;
|
|
52
|
+
uuid: string;
|
|
53
|
+
name: string;
|
|
54
|
+
};
|
|
55
|
+
created: boolean;
|
|
56
|
+
}>;
|
|
57
|
+
listServers(): Promise<Array<{
|
|
58
|
+
uuid: string;
|
|
59
|
+
is_usable: boolean;
|
|
60
|
+
is_reachable: boolean;
|
|
61
|
+
}>>;
|
|
62
|
+
requireGithubApp(preferredUuid?: string): Promise<{
|
|
63
|
+
uuid: string;
|
|
64
|
+
name: string;
|
|
65
|
+
}>;
|
|
66
|
+
findApplicationByName(name: string, scope?: {
|
|
67
|
+
environmentIds: number[];
|
|
68
|
+
}): Promise<{
|
|
69
|
+
uuid: string;
|
|
70
|
+
} | null>;
|
|
71
|
+
createGithubAppApplication(input: {
|
|
72
|
+
project_uuid: string;
|
|
73
|
+
server_uuid: string;
|
|
74
|
+
environment_name: string;
|
|
75
|
+
environment_uuid: string;
|
|
76
|
+
github_app_uuid: string;
|
|
77
|
+
git_repository: string;
|
|
78
|
+
git_branch: string;
|
|
79
|
+
build_pack: HostingBuildPack;
|
|
80
|
+
name?: string;
|
|
81
|
+
domains?: string;
|
|
82
|
+
}): Promise<{
|
|
83
|
+
uuid: string;
|
|
84
|
+
}>;
|
|
85
|
+
createPostgresDatabase(input: {
|
|
86
|
+
project_uuid: string;
|
|
87
|
+
server_uuid: string;
|
|
88
|
+
environment_name: string;
|
|
89
|
+
environment_uuid: string;
|
|
90
|
+
name: string;
|
|
91
|
+
postgres_db: string;
|
|
92
|
+
postgres_user: string;
|
|
93
|
+
}): Promise<{
|
|
94
|
+
uuid: string;
|
|
95
|
+
internal_db_url: string;
|
|
96
|
+
}>;
|
|
97
|
+
/**
|
|
98
|
+
* Coolify's own record of a database, re-read here (rather than trusted
|
|
99
|
+
* from `createPostgresDatabase`'s return value) so the app's env is wired
|
|
100
|
+
* from the database's live state whether this run just created it or found
|
|
101
|
+
* it already there (`coolifyApp`/`coolifyDatabase` below, hosting-wiring
|
|
102
|
+
* campaign). `internal_db_url` is `postgres://user:password@host/db` — a
|
|
103
|
+
* live credential, present in the raw Coolify API response and reachable
|
|
104
|
+
* only from inside the Coolify Docker network (`@wtfalch/providers`'
|
|
105
|
+
* `CoolifyClient.createPostgresDatabase` doc comment).
|
|
106
|
+
*/
|
|
107
|
+
getDatabase(uuid: string): Promise<{
|
|
108
|
+
uuid: string;
|
|
109
|
+
internal_db_url?: string;
|
|
110
|
+
}>;
|
|
111
|
+
/**
|
|
112
|
+
* Every database Coolify holds. `coolifyDatabase` finds its own by name and
|
|
113
|
+
* environment, so a crash between create and persist does not make a second
|
|
114
|
+
* one on the next apply. Only `uuid`, `name` and `environment_id` are read;
|
|
115
|
+
* the raw response also carries `internal_db_url`, which is never used here.
|
|
116
|
+
*/
|
|
117
|
+
listDatabases(): Promise<Array<{
|
|
118
|
+
uuid: string;
|
|
119
|
+
name: string;
|
|
120
|
+
environment_id?: number;
|
|
121
|
+
}>>;
|
|
122
|
+
/**
|
|
123
|
+
* Upsert environment variables on an application in one call — used here to
|
|
124
|
+
* push the database's `internal_db_url` in as the app's `DATABASE_URL`
|
|
125
|
+
* (hosting-wiring campaign). The value is a live credential: it must reach
|
|
126
|
+
* this call and nothing else — never a step's return value, `serviceIds`
|
|
127
|
+
* (hence the store), an audit record, a log or a thrown error. See
|
|
128
|
+
* `coolifyApp`'s `pushDatabaseUrl`.
|
|
129
|
+
*/
|
|
130
|
+
bulkUpsertEnvs(applicationUuid: string, envs: Array<{
|
|
131
|
+
key: string;
|
|
132
|
+
value: string;
|
|
133
|
+
buildTime?: boolean;
|
|
134
|
+
literal?: boolean;
|
|
135
|
+
multiline?: boolean;
|
|
136
|
+
}>): Promise<unknown>;
|
|
137
|
+
/** Escape hatch (`CoolifyClient.raw`) — used for stop/start, which have no typed method yet. */
|
|
138
|
+
raw(method: string, path: string, body?: unknown): Promise<unknown>;
|
|
139
|
+
/** `FoundryPlanApply.destroy`'s teardown of the org's own app. */
|
|
140
|
+
deleteApplication(uuid: string): Promise<unknown>;
|
|
141
|
+
/** `FoundryPlanApply.destroy`'s teardown of the org's own database. */
|
|
142
|
+
deleteDatabase(uuid: string): Promise<unknown>;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The Cloudflare calls this executor makes, shaped to match
|
|
146
|
+
* `@wtfalch/providers/cloudflare`'s `CloudflareClient` method-for-method.
|
|
147
|
+
*/
|
|
148
|
+
export interface CloudflarePort {
|
|
149
|
+
findZoneByApex(domain: string): Promise<{
|
|
150
|
+
id: string;
|
|
151
|
+
name: string;
|
|
152
|
+
} | undefined>;
|
|
153
|
+
createZone(name: string): Promise<{
|
|
154
|
+
id: string;
|
|
155
|
+
name: string;
|
|
156
|
+
}>;
|
|
157
|
+
findTunnelByName(name: string): Promise<{
|
|
158
|
+
id: string;
|
|
159
|
+
name: string;
|
|
160
|
+
} | undefined>;
|
|
161
|
+
createTunnel(name: string): Promise<{
|
|
162
|
+
id: string;
|
|
163
|
+
name: string;
|
|
164
|
+
}>;
|
|
165
|
+
putTunnelConfig(tunnelId: string, config: unknown): Promise<unknown>;
|
|
166
|
+
/** `FoundryPlanApply.destroy`'s teardown of the org's own Cloudflare tunnel. */
|
|
167
|
+
deleteTunnel(tunnelId: string): Promise<void>;
|
|
168
|
+
/**
|
|
169
|
+
* `FoundryPlanApply.destroy`'s teardown of any DNS record foundry created
|
|
170
|
+
* for the org. Nothing in this executor creates or records one yet (Finding,
|
|
171
|
+
* this campaign's report: no `cloudflare:dns` resource kind exists in
|
|
172
|
+
* `diff.ts`/`executor.ts` today), so `destroy` has no dispatch target for
|
|
173
|
+
* it either -- this port method exists ready for whichever step starts
|
|
174
|
+
* creating one.
|
|
175
|
+
*/
|
|
176
|
+
deleteDnsRecord(zoneId: string, recordId: string): Promise<void>;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The ZITADEL calls this executor makes. `findOrCreateOrg`/`findOrCreateProject`
|
|
180
|
+
* already exist on `@wtfalch/providers/zitadel`'s `ZitadelClient` (returning
|
|
181
|
+
* `{org|project, created}`, not the bare `{id}` this campaign's brief
|
|
182
|
+
* sketched — matched to the real shape here, logged as a Decision).
|
|
183
|
+
* `createOidcApp`/`createApiApp`/`deleteApp` do not exist yet — "being added
|
|
184
|
+
* to providers in parallel" (task brief) — so this port states the shapes
|
|
185
|
+
* they need to land with; a `ZitadelClient` that gains exactly these three
|
|
186
|
+
* methods satisfies this port with no adapter.
|
|
187
|
+
*/
|
|
188
|
+
export interface ZitadelPort {
|
|
189
|
+
findOrCreateOrg(name: string): Promise<{
|
|
190
|
+
org: {
|
|
191
|
+
id: string;
|
|
192
|
+
name: string;
|
|
193
|
+
};
|
|
194
|
+
created: boolean;
|
|
195
|
+
}>;
|
|
196
|
+
findOrCreateProject(orgId: string, name: string): Promise<{
|
|
197
|
+
project: {
|
|
198
|
+
id: string;
|
|
199
|
+
name: string;
|
|
200
|
+
};
|
|
201
|
+
created: boolean;
|
|
202
|
+
}>;
|
|
203
|
+
createOidcApp(orgId: string, projectId: string, input: {
|
|
204
|
+
name: string;
|
|
205
|
+
appType: 'web' | 'native';
|
|
206
|
+
redirectUris: string[];
|
|
207
|
+
postLogoutRedirectUris: string[];
|
|
208
|
+
}): Promise<{
|
|
209
|
+
appId: string;
|
|
210
|
+
clientId: string;
|
|
211
|
+
}>;
|
|
212
|
+
/**
|
|
213
|
+
* Returns `clientSecret` alongside `appId`/`clientId` — ZITADEL mints it
|
|
214
|
+
* once, on creation, and never shows it again (`providers`' `ZitadelClient.
|
|
215
|
+
* createApiApp`, commit 1d0fec2). The executor hands it straight to
|
|
216
|
+
* {@link FoundryStepExecutorPorts.deliverSecret} and never returns, stores
|
|
217
|
+
* or logs it itself (see that port's doc comment).
|
|
218
|
+
*/
|
|
219
|
+
createApiApp(orgId: string, projectId: string, input: {
|
|
220
|
+
name: string;
|
|
221
|
+
}): Promise<{
|
|
222
|
+
appId: string;
|
|
223
|
+
clientId: string;
|
|
224
|
+
clientSecret: string;
|
|
225
|
+
}>;
|
|
226
|
+
deleteApp(orgId: string, projectId: string, appId: string): Promise<void>;
|
|
227
|
+
/**
|
|
228
|
+
* The org-owner flow (goal line): find-or-create the human user by email in
|
|
229
|
+
* the org, then have ZITADEL email them an invite to set up a passkey.
|
|
230
|
+
* Idempotent on the user; the invite itself is not (a re-run always sends a
|
|
231
|
+
* fresh code) — `plan/diff.ts`'s `planOwner` is what makes a *re-run of
|
|
232
|
+
* foundry's own apply* idempotent, by planning nothing once the returned
|
|
233
|
+
* `userId` is recorded. Returns only ids, matching `createApiApp`'s own
|
|
234
|
+
* rule for anything invite/credential-shaped: the email itself is a
|
|
235
|
+
* parameter here, never part of the return value.
|
|
236
|
+
*/
|
|
237
|
+
inviteOrgOwner(orgId: string, owner: {
|
|
238
|
+
givenName: string;
|
|
239
|
+
familyName: string;
|
|
240
|
+
email: string;
|
|
241
|
+
}): Promise<{
|
|
242
|
+
userId: string;
|
|
243
|
+
created: boolean;
|
|
244
|
+
}>;
|
|
245
|
+
/** Lets anyone sign up to the org (plan gap A9). Idempotent. */
|
|
246
|
+
allowSelfRegistration(orgId: string): Promise<{
|
|
247
|
+
changed: boolean;
|
|
248
|
+
}>;
|
|
249
|
+
/** Grants another ZITADEL org access to a project (plan gap A9). Idempotent. */
|
|
250
|
+
findOrCreateProjectGrant(orgId: string, projectId: string, grantedOrgId: string): Promise<{
|
|
251
|
+
created: boolean;
|
|
252
|
+
}>;
|
|
253
|
+
/** `FoundryPlanApply.destroy`'s teardown of the org's ZITADEL project, once every app inside it is gone. */
|
|
254
|
+
deleteProject(orgId: string, projectId: string): Promise<void>;
|
|
255
|
+
/** `FoundryPlanApply.destroy`'s last step: removes the whole ZITADEL org and everything foundry made inside it. */
|
|
256
|
+
deleteOrg(orgId: string): Promise<void>;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* One org's spend limit on Files, matching `files/packages/sdk/src/client.ts:154-166`'s
|
|
260
|
+
* `setQuota`/`setBudget` (the caller's own organisation, resolved from its
|
|
261
|
+
* signed request — no organisation id parameter, hence a factory per org
|
|
262
|
+
* rather than one flat port).
|
|
263
|
+
*/
|
|
264
|
+
export interface FilesOrgLimits {
|
|
265
|
+
setQuota(limitBytes: string | null): Promise<unknown>;
|
|
266
|
+
setBudget(limitMicros: string | null): Promise<unknown>;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* One org's budget on AI, matching `ai/packages/sdk/src/client.ts:94-101`'s
|
|
270
|
+
* `updateBudget` (`UpdateBudget` from `ai/packages/sdk/src/types.ts:134-137`).
|
|
271
|
+
*/
|
|
272
|
+
export interface AiOrgLimits {
|
|
273
|
+
updateBudget(input: {
|
|
274
|
+
keyId: string | null;
|
|
275
|
+
limitMicros: string;
|
|
276
|
+
}): Promise<unknown>;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Provisions one org's organisation on Files (#80), matching
|
|
280
|
+
* `files/packages/sdk/src/client.ts:136-145`'s
|
|
281
|
+
* `createFilesManagementClient().provisionOrganisation`
|
|
282
|
+
* (`ProvisionOrganisationInput`/`ProvisionOrganisationResult`,
|
|
283
|
+
* `files/packages/sdk/src/types.ts:146-156`). Flat, not a factory, like
|
|
284
|
+
* {@link ZitadelPort}: `organisationId` is an explicit call argument here,
|
|
285
|
+
* unlike {@link FilesOrgLimits}'s tenant-scoped calls, which resolve the
|
|
286
|
+
* caller's own organisation from its signed request instead.
|
|
287
|
+
*
|
|
288
|
+
* Finding (this campaign's report): unlike {@link AiOrganisationPort}'s bearer
|
|
289
|
+
* credential, `createFilesManagementClient`'s whole transport is "a signed
|
|
290
|
+
* human proof or an issued management key" (`files/packages/sdk/src/
|
|
291
|
+
* client.ts:117-119`) — concretely an Ed25519-signed `x-wtfalch-management`
|
|
292
|
+
* request (`files/apps/host/src/lib/authz/service-assertion.ts`'s
|
|
293
|
+
* `signManagementRequest`/`AssertionTrust`). A host can construct this without
|
|
294
|
+
* any human session: it only needs the Ed25519 private key (`SigningIdentity`)
|
|
295
|
+
* an operator's `MANAGEMENT_PUBLIC_KEYS`/`MANAGEMENT_ISSUER` pins — the same
|
|
296
|
+
* signer {@link FilesOrgLimits} needs for `setQuota`/`setBudget`, since the
|
|
297
|
+
* `organisation` field inside the signed claim selects the org per call, so
|
|
298
|
+
* one shared keypair signs for every org rather than needing a secret per
|
|
299
|
+
* org.
|
|
300
|
+
*/
|
|
301
|
+
export interface FilesOrganisationPort {
|
|
302
|
+
provisionOrganisation(input: {
|
|
303
|
+
organisationId: string;
|
|
304
|
+
ceilingBytes: string | null;
|
|
305
|
+
ceilingMicros: string | null;
|
|
306
|
+
shareHost?: string | null;
|
|
307
|
+
}): Promise<{
|
|
308
|
+
organisationId: string;
|
|
309
|
+
ceilingBytes: string | null;
|
|
310
|
+
ceilingMicros: string | null;
|
|
311
|
+
usedBytes: string;
|
|
312
|
+
reservedBytes: string;
|
|
313
|
+
spentMicros: string;
|
|
314
|
+
suspended: boolean;
|
|
315
|
+
shareHost: string | null;
|
|
316
|
+
created: boolean;
|
|
317
|
+
}>;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Provisions one org's organisation on AI and switches its AI use on (#80),
|
|
321
|
+
* matching `ai/packages/sdk/src/client.ts:98-108`'s `provisionOrganisation`/
|
|
322
|
+
* `setOrganisationOffers` (`ProvisionOrganisation`/`ProvisionOrganisationResult`,
|
|
323
|
+
* `SetOrganisationOffers`/`OrganisationOffersView`,
|
|
324
|
+
* `ai/packages/sdk/src/types.ts:121-143`). Flat, not a factory, for the same
|
|
325
|
+
* reason as {@link FilesOrganisationPort}.
|
|
326
|
+
*
|
|
327
|
+
* Finding (this campaign's report): AI's machine credential is a plain bearer
|
|
328
|
+
* key — archon's own `AI_MANAGEMENT_KEY`, minted by
|
|
329
|
+
* `ai/apps/host/scripts/authz/bootstrap-archon.ts`'s `keys:issue-archon` with
|
|
330
|
+
* `ARCHON_AI_PERMISSIONS` (`ai.organisations:provision`,
|
|
331
|
+
* `ai.organisations:offer`, `ai.budgets:update`, among others) copied onto
|
|
332
|
+
* it — used directly with `createAiClient`, unlike Files' signed-assertion
|
|
333
|
+
* transport above.
|
|
334
|
+
*/
|
|
335
|
+
export interface AiOrganisationPort {
|
|
336
|
+
provisionOrganisation(input: {
|
|
337
|
+
organisationId: string;
|
|
338
|
+
limitMicros: string | null;
|
|
339
|
+
}): Promise<{
|
|
340
|
+
organisationId: string;
|
|
341
|
+
limitMicros: string | null;
|
|
342
|
+
created: boolean;
|
|
343
|
+
}>;
|
|
344
|
+
setOrganisationOffers(organisationId: string, offers: string[]): Promise<{
|
|
345
|
+
organisationId: string;
|
|
346
|
+
offers: string[];
|
|
347
|
+
}>;
|
|
348
|
+
}
|
|
349
|
+
export interface FoundryStepExecutorPorts {
|
|
350
|
+
coolify: CoolifyPort;
|
|
351
|
+
cloudflare: CloudflarePort;
|
|
352
|
+
zitadel: ZitadelPort;
|
|
353
|
+
/** A factory, not a flat client: Files/AI are org-scoped credentials the host resolves per org. */
|
|
354
|
+
filesLimitsFor: (orgId: OrgId) => FilesOrgLimits;
|
|
355
|
+
aiLimitsFor: (orgId: OrgId) => AiOrgLimits;
|
|
356
|
+
/** Flat, not a factory: `organisationId` is an explicit argument on both ports (see each port's own doc comment). */
|
|
357
|
+
filesOrganisation: FilesOrganisationPort;
|
|
358
|
+
aiOrganisation: AiOrganisationPort;
|
|
359
|
+
/**
|
|
360
|
+
* Hands a just-minted, one-time secret (today: an API app's
|
|
361
|
+
* `clientSecret`) to the host, immediately after the provider returns it
|
|
362
|
+
* and before the step that created it returns. This package "holds no
|
|
363
|
+
* credential and never will" (this file's own top doc comment), so a
|
|
364
|
+
* secret is never a step's return value, never goes into `serviceIds` (the
|
|
365
|
+
* store, hence the DB), and is never logged or interpolated into a thrown
|
|
366
|
+
* error -- this is its one way out of the executor. `ref` names what the
|
|
367
|
+
* secret belongs to (e.g. `zitadel:app:<org.slug>:<key>`); routing it
|
|
368
|
+
* anywhere durable (a keys Worker, a secret store) is the host's job, the
|
|
369
|
+
* same division `executor.ts`'s top comment draws for provider tokens
|
|
370
|
+
* themselves.
|
|
371
|
+
*/
|
|
372
|
+
deliverSecret(ref: string, secret: string): Promise<void>;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Thrown by name for a step this executor deliberately does not implement:
|
|
376
|
+
* mail and chat (no admin API exists yet in either service) and the cms
|
|
377
|
+
* site (same), plus moving the issuer onto or off the org's domain (built,
|
|
378
|
+
* but not in v1 — domain verification and passkey invalidation need their
|
|
379
|
+
* own design). Never carries a token, secret or key (task brief: "errors
|
|
380
|
+
* carry the step and the provider's status only").
|
|
381
|
+
*/
|
|
382
|
+
export declare class NotBuiltError extends Error {
|
|
383
|
+
constructor(step: string, detail: string);
|
|
384
|
+
}
|
|
385
|
+
/** `coolify:app` needs source code to build from; `DeclaredOrg.bespokeAppRepo` is the only source this executor knows (#71, #20). */
|
|
386
|
+
export declare class MissingBespokeAppRepoError extends Error {
|
|
387
|
+
constructor(slug: string);
|
|
388
|
+
}
|
|
389
|
+
export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer {
|
|
390
|
+
private readonly ports;
|
|
391
|
+
constructor(ports: FoundryStepExecutorPorts);
|
|
392
|
+
execute(action: StepAction, context: StepExecutionContext): Promise<{
|
|
393
|
+
resourceId?: string;
|
|
394
|
+
} | undefined>;
|
|
395
|
+
private zitadelIssuer;
|
|
396
|
+
/**
|
|
397
|
+
* `plan/diff.ts`'s `planOwner`: invites the org's owner into its ZITADEL
|
|
398
|
+
* org by email. Never logs, returns or throws the email itself -- only the
|
|
399
|
+
* invited user id, handed back as `resourceId` so `runSteps` records it
|
|
400
|
+
* under `zitadel:owner:<orgId>`, which is exactly what makes the next
|
|
401
|
+
* `plan()` call idempotent (`planOwner` plans nothing once that key is
|
|
402
|
+
* recorded).
|
|
403
|
+
*/
|
|
404
|
+
private zitadelOwner;
|
|
405
|
+
/**
|
|
406
|
+
* `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
|
|
407
|
+
* in through a second ZITADEL org, `<slug> customers`, with
|
|
408
|
+
* self-registration on and a grant to the org's app project. Staff stay in
|
|
409
|
+
* the org's own invite-only ZITADEL org. The space keeps the name apart from
|
|
410
|
+
* every issuer org, which is named by a slug and a slug has no spaces. Every call here is idempotent, so
|
|
411
|
+
* a re-run after a crash finishes the job.
|
|
412
|
+
*/
|
|
413
|
+
private zitadelCustomers;
|
|
414
|
+
private zitadelApp;
|
|
415
|
+
private ensureZitadelProject;
|
|
416
|
+
private ensureCoolifyProject;
|
|
417
|
+
private resolveServer;
|
|
418
|
+
private coolifyApp;
|
|
419
|
+
private coolifyDatabase;
|
|
420
|
+
/**
|
|
421
|
+
* Pushes the database's connection string into the app's environment as
|
|
422
|
+
* `DATABASE_URL` -- never also as `DATABASE_URL_OWNER` (Decision, this
|
|
423
|
+
* campaign's report). Archon's own boot script
|
|
424
|
+
* (`archon/scripts/migrate-and-start.sh`) treats a bare `DATABASE_URL` with
|
|
425
|
+
* no `DATABASE_URL_OWNER` as "before the split": it migrates and serves as
|
|
426
|
+
* the one role it has, which is exactly what a database Coolify just
|
|
427
|
+
* created (one superuser role, no separate runtime role yet) is. Setting
|
|
428
|
+
* both to the *same* superuser URL would instead take the split branch --
|
|
429
|
+
* `db-role-guard.sh` then refuses to boot, since `DATABASE_URL_OWNER` being
|
|
430
|
+
* set means it expects `DATABASE_URL` to connect as a distinct,
|
|
431
|
+
* non-superuser runtime role (`<database>_rt`), which nothing here creates.
|
|
432
|
+
* Minting that role's own password and URL is a provisioning step of its
|
|
433
|
+
* own, not built here (Finding, this campaign's report).
|
|
434
|
+
*
|
|
435
|
+
* The connection string is a live credential
|
|
436
|
+
* (`postgres://user:password@host/db`): it is read from
|
|
437
|
+
* `CoolifyPort.getDatabase` and handed straight to `bulkUpsertEnvs` and
|
|
438
|
+
* nowhere else in this class -- never returned, never recorded in
|
|
439
|
+
* `serviceIds` (hence never in the store or an audit record), never logged,
|
|
440
|
+
* never interpolated into a thrown error.
|
|
441
|
+
*/
|
|
442
|
+
private pushDatabaseUrl;
|
|
443
|
+
private cloudflareTunnel;
|
|
444
|
+
private cloudflareZone;
|
|
445
|
+
/**
|
|
446
|
+
* #80: provisions the org's own organisation on Files, before
|
|
447
|
+
* {@link filesStorage} can set its spend limit. `provisionOrganisation` is
|
|
448
|
+
* safely repeatable on its own (`FilesOrganisationPort`'s doc comment), so
|
|
449
|
+
* this is called every time regardless of whether it already ran --
|
|
450
|
+
* idempotent by find-or-create, not by checking `recordedIds` first.
|
|
451
|
+
*/
|
|
452
|
+
private filesOrganisation;
|
|
453
|
+
private filesStorage;
|
|
454
|
+
/**
|
|
455
|
+
* #80: provisions the org's own organisation on AI and switches its AI use
|
|
456
|
+
* on, before {@link aiBudget} can set its own spend limit. Both
|
|
457
|
+
* `provisionOrganisation` and `setOrganisationOffers` are safely repeatable
|
|
458
|
+
* on their own (`AiOrganisationPort`'s doc comment), so both are called
|
|
459
|
+
* every time regardless of whether they already ran.
|
|
460
|
+
*/
|
|
461
|
+
private aiOrganisation;
|
|
462
|
+
private aiBudget;
|
|
463
|
+
/**
|
|
464
|
+
* {@link StepDestroyer.destroy}: deletes the one resource
|
|
465
|
+
* `FoundryPlanApply.destroy` names. A provider 404 (already gone) counts as
|
|
466
|
+
* deleted -- `isNotFoundError` below -- since a destroy retried after a
|
|
467
|
+
* partial failure must find an already-deleted resource a no-op, not a new
|
|
468
|
+
* error.
|
|
469
|
+
*/
|
|
470
|
+
destroy(target: DestroyTarget, context: {
|
|
471
|
+
readonly org: DeclaredOrg;
|
|
472
|
+
readonly recordedIds: Readonly<Record<string, string>>;
|
|
473
|
+
}): Promise<void>;
|
|
474
|
+
}
|