@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,507 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Errors
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
/**
|
|
5
|
+
* Thrown by name for a step this executor deliberately does not implement:
|
|
6
|
+
* mail and chat (no admin API exists yet in either service) and the cms
|
|
7
|
+
* site (same), plus moving the issuer onto or off the org's domain (built,
|
|
8
|
+
* but not in v1 — domain verification and passkey invalidation need their
|
|
9
|
+
* own design). Never carries a token, secret or key (task brief: "errors
|
|
10
|
+
* carry the step and the provider's status only").
|
|
11
|
+
*/
|
|
12
|
+
export class NotBuiltError extends Error {
|
|
13
|
+
constructor(step, detail) {
|
|
14
|
+
super(`${step} is not built yet -- ${detail}`);
|
|
15
|
+
this.name = 'NotBuiltError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** `coolify:app` needs source code to build from; `DeclaredOrg.bespokeAppRepo` is the only source this executor knows (#71, #20). */
|
|
19
|
+
export class MissingBespokeAppRepoError extends Error {
|
|
20
|
+
constructor(slug) {
|
|
21
|
+
super(`${slug} has no declared bespokeAppRepo; coolify:app cannot provision a hosted app without one`);
|
|
22
|
+
this.name = 'MissingBespokeAppRepoError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Hostnames
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
/**
|
|
29
|
+
* `<slug>.oikos.host` (`../slug.ts`), or `DeclaredOrg.domain` once
|
|
30
|
+
* `hosting.usesDomain` is true — the same rule `diff.ts`'s `planApp` and
|
|
31
|
+
* `planSite` document, reused here for the org's own hosted app, its
|
|
32
|
+
* Cloudflare tunnel ingress and its ZITADEL issuer apps' redirect URIs
|
|
33
|
+
* (task brief: "Hostnames: `<slug>.oikos.host` ... or `DeclaredOrg.domain`
|
|
34
|
+
* when `hosting.usesDomain`").
|
|
35
|
+
*/
|
|
36
|
+
function hostnameFor(org) {
|
|
37
|
+
return org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Every app on the Coolify box shares one Traefik reverse proxy, and Coolify
|
|
41
|
+
* itself points that proxy's routing at whatever `domains` an application
|
|
42
|
+
* declares (`coolifyApp`'s `hostnameFor(org)`) -- so a tunnel's ingress only
|
|
43
|
+
* has to reach the proxy, never a specific app container.
|
|
44
|
+
* `wtfalch/app-template`'s bootstrap (`bootstrap/steps/cf-tunnel.ts`, prior
|
|
45
|
+
* art for the exact org this file's `cloudflareTunnel` implements, cited by
|
|
46
|
+
* https://github.com/wtfalch/foundry/issues/71) names this same constant and
|
|
47
|
+
* value.
|
|
48
|
+
*/
|
|
49
|
+
const COOLIFY_PROXY_ORIGIN = 'http://coolify-proxy:80';
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Executor
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
export class FoundryStepExecutor {
|
|
54
|
+
ports;
|
|
55
|
+
constructor(ports) {
|
|
56
|
+
this.ports = ports;
|
|
57
|
+
}
|
|
58
|
+
async execute(action, context) {
|
|
59
|
+
const { org } = context;
|
|
60
|
+
switch (`${action.system}:${action.kind}`) {
|
|
61
|
+
case 'zitadel:issuer':
|
|
62
|
+
return this.zitadelIssuer(action, org, context);
|
|
63
|
+
case 'zitadel:owner':
|
|
64
|
+
return this.zitadelOwner(action, org, context);
|
|
65
|
+
case 'zitadel:app':
|
|
66
|
+
return this.zitadelApp(action, org, context);
|
|
67
|
+
case 'zitadel:customers':
|
|
68
|
+
return this.zitadelCustomers(org, context);
|
|
69
|
+
case 'coolify:app':
|
|
70
|
+
return this.coolifyApp(action, org, context);
|
|
71
|
+
case 'coolify:database':
|
|
72
|
+
return this.coolifyDatabase(action, org, context);
|
|
73
|
+
case 'cloudflare:tunnel':
|
|
74
|
+
return this.cloudflareTunnel(action, org, context);
|
|
75
|
+
case 'cloudflare:zone':
|
|
76
|
+
return this.cloudflareZone(action);
|
|
77
|
+
case 'files:organisation':
|
|
78
|
+
return this.filesOrganisation(action, org);
|
|
79
|
+
case 'files:storage':
|
|
80
|
+
return this.filesStorage(action, org);
|
|
81
|
+
case 'ai:organisation':
|
|
82
|
+
return this.aiOrganisation(action, org);
|
|
83
|
+
case 'ai:budget':
|
|
84
|
+
return this.aiBudget(action, org);
|
|
85
|
+
case 'coolify:mail':
|
|
86
|
+
throw new NotBuiltError(`coolify:mail for ${org.slug}`, 'mail has no Stalwart admin API yet; tracked at https://github.com/wtfalch/email/issues/96');
|
|
87
|
+
case 'coolify:chat':
|
|
88
|
+
throw new NotBuiltError(`coolify:chat for ${org.slug}`, "chat's per-org Synapse+MAS has no admin API yet; tracked at https://github.com/wtfalch/chat/issues/40");
|
|
89
|
+
case 'cms:site':
|
|
90
|
+
throw new NotBuiltError(`cms:site for ${org.slug}`, 'the cms site has no admin API yet; tracked at https://github.com/wtfalch/cms/issues/20');
|
|
91
|
+
default:
|
|
92
|
+
throw new Error(`FoundryStepExecutor has no dispatch for ${action.system}:${action.kind}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// ---- zitadel --------------------------------------------------------
|
|
96
|
+
async zitadelIssuer(action, org, context) {
|
|
97
|
+
if (action.desired.provision !== true) {
|
|
98
|
+
// Moving an already-provisioned issuer onto or off the org's domain
|
|
99
|
+
// (decisions file: "domain verification and passkey invalidation need
|
|
100
|
+
// their own design" -- not built in v1).
|
|
101
|
+
throw new NotBuiltError(`zitadel:issuer move for ${org.slug}`, 'moving the issuer onto or off the org\'s own domain is not built in v1; see foundry\'s .claude/decisions-2026-09-24-executor.md, "Executor decisions"');
|
|
102
|
+
}
|
|
103
|
+
const { org: zOrg } = await this.ports.zitadel.findOrCreateOrg(org.slug);
|
|
104
|
+
context.recordScopeId('zitadel_org', zOrg.id);
|
|
105
|
+
return { resourceId: zOrg.id };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* `plan/diff.ts`'s `planOwner`: invites the org's owner into its ZITADEL
|
|
109
|
+
* org by email. Never logs, returns or throws the email itself -- only the
|
|
110
|
+
* invited user id, handed back as `resourceId` so `runSteps` records it
|
|
111
|
+
* under `zitadel:owner:<orgId>`, which is exactly what makes the next
|
|
112
|
+
* `plan()` call idempotent (`planOwner` plans nothing once that key is
|
|
113
|
+
* recorded).
|
|
114
|
+
*/
|
|
115
|
+
async zitadelOwner(action, org, context) {
|
|
116
|
+
const zitadelOrgId = context.recordedIds().zitadel_org;
|
|
117
|
+
if (!zitadelOrgId) {
|
|
118
|
+
throw new Error(`zitadel:owner for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
|
|
119
|
+
}
|
|
120
|
+
const { userId } = await this.ports.zitadel.inviteOrgOwner(zitadelOrgId, {
|
|
121
|
+
givenName: action.desired.givenName,
|
|
122
|
+
familyName: action.desired.familyName,
|
|
123
|
+
email: action.desired.email,
|
|
124
|
+
});
|
|
125
|
+
return { resourceId: userId };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
|
|
129
|
+
* in through a second ZITADEL org, `<slug> customers`, with
|
|
130
|
+
* self-registration on and a grant to the org's app project. Staff stay in
|
|
131
|
+
* the org's own invite-only ZITADEL org. The space keeps the name apart from
|
|
132
|
+
* every issuer org, which is named by a slug and a slug has no spaces. Every call here is idempotent, so
|
|
133
|
+
* a re-run after a crash finishes the job.
|
|
134
|
+
*/
|
|
135
|
+
async zitadelCustomers(org, context) {
|
|
136
|
+
const zitadelOrgId = context.recordedIds().zitadel_org;
|
|
137
|
+
if (!zitadelOrgId) {
|
|
138
|
+
throw new Error(`zitadel:customers for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
|
|
139
|
+
}
|
|
140
|
+
const projectId = await this.ensureZitadelProject(org, zitadelOrgId, context);
|
|
141
|
+
const { org: customers } = await this.ports.zitadel.findOrCreateOrg(`${org.slug} customers`);
|
|
142
|
+
await this.ports.zitadel.allowSelfRegistration(customers.id);
|
|
143
|
+
await this.ports.zitadel.findOrCreateProjectGrant(zitadelOrgId, projectId, customers.id);
|
|
144
|
+
return { resourceId: customers.id };
|
|
145
|
+
}
|
|
146
|
+
async zitadelApp(action, org, context) {
|
|
147
|
+
const appType = action.desired.appType;
|
|
148
|
+
const recorded = context.recordedIds();
|
|
149
|
+
const zitadelOrgId = recorded.zitadel_org;
|
|
150
|
+
if (!zitadelOrgId) {
|
|
151
|
+
throw new Error(`zitadel:app '${action.key}' for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
|
|
152
|
+
}
|
|
153
|
+
const projectId = await this.ensureZitadelProject(org, zitadelOrgId, context);
|
|
154
|
+
// A kind change deletes the recorded app then recreates it (task
|
|
155
|
+
// brief); the first-ever create for this key has nothing recorded, so
|
|
156
|
+
// this is a no-op then.
|
|
157
|
+
const priorAppId = recorded[`zitadel:app:${action.key}`];
|
|
158
|
+
if (priorAppId) {
|
|
159
|
+
await this.ports.zitadel.deleteApp(zitadelOrgId, projectId, priorAppId);
|
|
160
|
+
}
|
|
161
|
+
if (appType === 'api') {
|
|
162
|
+
const created = await this.ports.zitadel.createApiApp(zitadelOrgId, projectId, {
|
|
163
|
+
name: action.key,
|
|
164
|
+
});
|
|
165
|
+
// Handed off once, right here, before returning -- never in the
|
|
166
|
+
// return value, `serviceIds`, the store, a log or a thrown error (see
|
|
167
|
+
// `FoundryStepExecutorPorts.deliverSecret`'s doc comment).
|
|
168
|
+
await this.ports.deliverSecret(`zitadel:app:${org.slug}:${action.key}`, created.clientSecret);
|
|
169
|
+
return { resourceId: created.appId };
|
|
170
|
+
}
|
|
171
|
+
const hostname = hostnameFor(org);
|
|
172
|
+
// Native apps sign in via device code, with no redirect of their own
|
|
173
|
+
// (auth/scripts/provision.mjs:475-497, prior art cited in the decisions
|
|
174
|
+
// file) -- empty arrays rather than a web app's callback/logout URLs.
|
|
175
|
+
const created = await this.ports.zitadel.createOidcApp(zitadelOrgId, projectId, {
|
|
176
|
+
name: action.key,
|
|
177
|
+
appType,
|
|
178
|
+
redirectUris: appType === 'native' ? [] : [`https://${hostname}/auth/callback`],
|
|
179
|
+
postLogoutRedirectUris: appType === 'native' ? [] : [`https://${hostname}/`],
|
|
180
|
+
});
|
|
181
|
+
return { resourceId: created.appId };
|
|
182
|
+
}
|
|
183
|
+
async ensureZitadelProject(org, zitadelOrgId, context) {
|
|
184
|
+
const recorded = context.recordedIds();
|
|
185
|
+
const existing = recorded.zitadel_project;
|
|
186
|
+
if (existing)
|
|
187
|
+
return existing;
|
|
188
|
+
const { project } = await this.ports.zitadel.findOrCreateProject(zitadelOrgId, org.slug);
|
|
189
|
+
context.recordScopeId('zitadel_project', project.id);
|
|
190
|
+
return project.id;
|
|
191
|
+
}
|
|
192
|
+
// ---- coolify ---------------------------------------------------------
|
|
193
|
+
async ensureCoolifyProject(org, context) {
|
|
194
|
+
const recorded = context.recordedIds();
|
|
195
|
+
const existing = recorded.coolify_project;
|
|
196
|
+
if (existing)
|
|
197
|
+
return existing;
|
|
198
|
+
const found = await this.ports.coolify.findProjectByName(org.slug);
|
|
199
|
+
const projectUuid = found
|
|
200
|
+
? found.uuid
|
|
201
|
+
: (await this.ports.coolify.createProject(org.slug)).uuid;
|
|
202
|
+
context.recordScopeId('coolify_project', projectUuid);
|
|
203
|
+
return projectUuid;
|
|
204
|
+
}
|
|
205
|
+
async resolveServer() {
|
|
206
|
+
const servers = await this.ports.coolify.listServers();
|
|
207
|
+
const server = servers.find((s) => s.is_usable && s.is_reachable) ?? servers[0];
|
|
208
|
+
if (!server)
|
|
209
|
+
throw new Error('no usable Coolify server is registered on this instance');
|
|
210
|
+
return server;
|
|
211
|
+
}
|
|
212
|
+
async coolifyApp(action, org, context) {
|
|
213
|
+
if (action.desired.enabled === false) {
|
|
214
|
+
const appId = context.recordedIds()[`coolify:app:${action.key}`];
|
|
215
|
+
if (!appId) {
|
|
216
|
+
throw new Error(`coolify:app for ${org.slug}: no live app recorded, nothing to park`);
|
|
217
|
+
}
|
|
218
|
+
await this.ports.coolify.raw('POST', `/applications/${appId}/stop`);
|
|
219
|
+
return { resourceId: appId };
|
|
220
|
+
}
|
|
221
|
+
if (!org.bespokeAppRepo)
|
|
222
|
+
throw new MissingBespokeAppRepoError(org.slug);
|
|
223
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
224
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
225
|
+
const existing = await this.ports.coolify.findApplicationByName(org.slug, {
|
|
226
|
+
environmentIds: [environment.id],
|
|
227
|
+
});
|
|
228
|
+
let appId;
|
|
229
|
+
if (existing) {
|
|
230
|
+
appId = existing.uuid;
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
const server = await this.resolveServer();
|
|
234
|
+
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
235
|
+
const created = await this.ports.coolify.createGithubAppApplication({
|
|
236
|
+
project_uuid: projectUuid,
|
|
237
|
+
server_uuid: server.uuid,
|
|
238
|
+
environment_name: environment.name,
|
|
239
|
+
environment_uuid: environment.uuid,
|
|
240
|
+
github_app_uuid: githubApp.uuid,
|
|
241
|
+
git_repository: org.bespokeAppRepo,
|
|
242
|
+
git_branch: org.hosting.gitBranch,
|
|
243
|
+
build_pack: org.hosting.buildPack,
|
|
244
|
+
name: org.slug,
|
|
245
|
+
domains: hostnameFor(org),
|
|
246
|
+
});
|
|
247
|
+
appId = created.uuid;
|
|
248
|
+
}
|
|
249
|
+
// The database plans before the app (`diff.ts`'s `computePlanDetail`,
|
|
250
|
+
// hosting-wiring campaign) so that, on the same apply, the database's
|
|
251
|
+
// resource id is already recorded by the time this runs -- but this also
|
|
252
|
+
// covers the database being turned on after the app already exists (the
|
|
253
|
+
// app step still ran this apply, found the app by name, and lands here).
|
|
254
|
+
if (org.hosting.database) {
|
|
255
|
+
const dbId = context.recordedIds()[`coolify:database:${action.key}`];
|
|
256
|
+
if (dbId)
|
|
257
|
+
await this.pushDatabaseUrl(appId, dbId);
|
|
258
|
+
}
|
|
259
|
+
return { resourceId: appId };
|
|
260
|
+
}
|
|
261
|
+
async coolifyDatabase(action, org, context) {
|
|
262
|
+
if (action.desired.enabled === false) {
|
|
263
|
+
const dbId = context.recordedIds()[`coolify:database:${action.key}`];
|
|
264
|
+
if (!dbId) {
|
|
265
|
+
throw new Error(`coolify:database for ${org.slug}: no live database recorded, nothing to park`);
|
|
266
|
+
}
|
|
267
|
+
await this.ports.coolify.raw('POST', `/databases/${dbId}/stop`);
|
|
268
|
+
return { resourceId: dbId };
|
|
269
|
+
}
|
|
270
|
+
const existing = context.recordedIds()[`coolify:database:${action.key}`];
|
|
271
|
+
let dbId;
|
|
272
|
+
if (existing) {
|
|
273
|
+
dbId = existing;
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
277
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
278
|
+
const name = `${org.slug}-db`;
|
|
279
|
+
const found = (await this.ports.coolify.listDatabases()).find((db) => db.name === name && db.environment_id === environment.id);
|
|
280
|
+
if (found) {
|
|
281
|
+
dbId = found.uuid;
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
const server = await this.resolveServer();
|
|
285
|
+
const dbName = org.slug.replace(/-/g, '_');
|
|
286
|
+
const created = await this.ports.coolify.createPostgresDatabase({
|
|
287
|
+
project_uuid: projectUuid,
|
|
288
|
+
server_uuid: server.uuid,
|
|
289
|
+
environment_name: environment.name,
|
|
290
|
+
environment_uuid: environment.uuid,
|
|
291
|
+
name,
|
|
292
|
+
postgres_db: dbName,
|
|
293
|
+
postgres_user: dbName,
|
|
294
|
+
});
|
|
295
|
+
dbId = created.uuid;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// Handles the database being provisioned after the app already exists
|
|
299
|
+
// (`coolifyApp`'s own push handles the more common order, database
|
|
300
|
+
// before app on the same apply -- `diff.ts`'s `computePlanDetail`).
|
|
301
|
+
const appId = context.recordedIds()[`coolify:app:${action.key}`];
|
|
302
|
+
if (appId)
|
|
303
|
+
await this.pushDatabaseUrl(appId, dbId);
|
|
304
|
+
return { resourceId: dbId };
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Pushes the database's connection string into the app's environment as
|
|
308
|
+
* `DATABASE_URL` -- never also as `DATABASE_URL_OWNER` (Decision, this
|
|
309
|
+
* campaign's report). Archon's own boot script
|
|
310
|
+
* (`archon/scripts/migrate-and-start.sh`) treats a bare `DATABASE_URL` with
|
|
311
|
+
* no `DATABASE_URL_OWNER` as "before the split": it migrates and serves as
|
|
312
|
+
* the one role it has, which is exactly what a database Coolify just
|
|
313
|
+
* created (one superuser role, no separate runtime role yet) is. Setting
|
|
314
|
+
* both to the *same* superuser URL would instead take the split branch --
|
|
315
|
+
* `db-role-guard.sh` then refuses to boot, since `DATABASE_URL_OWNER` being
|
|
316
|
+
* set means it expects `DATABASE_URL` to connect as a distinct,
|
|
317
|
+
* non-superuser runtime role (`<database>_rt`), which nothing here creates.
|
|
318
|
+
* Minting that role's own password and URL is a provisioning step of its
|
|
319
|
+
* own, not built here (Finding, this campaign's report).
|
|
320
|
+
*
|
|
321
|
+
* The connection string is a live credential
|
|
322
|
+
* (`postgres://user:password@host/db`): it is read from
|
|
323
|
+
* `CoolifyPort.getDatabase` and handed straight to `bulkUpsertEnvs` and
|
|
324
|
+
* nowhere else in this class -- never returned, never recorded in
|
|
325
|
+
* `serviceIds` (hence never in the store or an audit record), never logged,
|
|
326
|
+
* never interpolated into a thrown error.
|
|
327
|
+
*/
|
|
328
|
+
async pushDatabaseUrl(appId, dbId) {
|
|
329
|
+
const db = await this.ports.coolify.getDatabase(dbId);
|
|
330
|
+
if (!db.internal_db_url)
|
|
331
|
+
return;
|
|
332
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, [
|
|
333
|
+
{ key: 'DATABASE_URL', value: db.internal_db_url },
|
|
334
|
+
]);
|
|
335
|
+
}
|
|
336
|
+
// ---- cloudflare --------------------------------------------------------
|
|
337
|
+
async cloudflareTunnel(action, org, context) {
|
|
338
|
+
if (action.desired.enabled === false) {
|
|
339
|
+
const tunnelId = context.recordedIds()[`cloudflare:tunnel:${action.key}`];
|
|
340
|
+
if (!tunnelId) {
|
|
341
|
+
throw new Error(`cloudflare:tunnel for ${org.slug}: no live tunnel recorded, nothing to park`);
|
|
342
|
+
}
|
|
343
|
+
// Cleared, not deleted (#7 corrections 1): an ingress with only the
|
|
344
|
+
// mandatory catch-all routes nothing.
|
|
345
|
+
await this.ports.cloudflare.putTunnelConfig(tunnelId, {
|
|
346
|
+
ingress: [{ service: 'http_status:404' }],
|
|
347
|
+
});
|
|
348
|
+
return { resourceId: tunnelId };
|
|
349
|
+
}
|
|
350
|
+
const tunnelName = `${org.slug}-tunnel`;
|
|
351
|
+
const existing = await this.ports.cloudflare.findTunnelByName(tunnelName);
|
|
352
|
+
const tunnel = existing ?? (await this.ports.cloudflare.createTunnel(tunnelName));
|
|
353
|
+
const hostname = hostnameFor(org);
|
|
354
|
+
// Routes the app's own hostname to Coolify's own Traefik reverse proxy,
|
|
355
|
+
// not a per-app container address -- `COOLIFY_PROXY_ORIGIN`, the same
|
|
356
|
+
// constant name and value `wtfalch/app-template`'s prior art used
|
|
357
|
+
// (`bootstrap/steps/cf-tunnel.ts`, offline.tf's bootstrap, cited by
|
|
358
|
+
// https://github.com/wtfalch/foundry/issues/71): every app on the
|
|
359
|
+
// Coolify box already gets a Traefik route from the `domains` its
|
|
360
|
+
// `coolify:app` step declared (`hostnameFor(org)`, this file's
|
|
361
|
+
// `coolifyApp`), so a cloudflared connector joined to the same Docker
|
|
362
|
+
// network only has to reach Traefik and let it route by Host header --
|
|
363
|
+
// the connector never needs to know the app's own container name or
|
|
364
|
+
// uuid. Verified evidence, not a guess (see this campaign's report);
|
|
365
|
+
// `http://<slug>:3000`, the placeholder this replaces, named a container
|
|
366
|
+
// and port no `coolify:app` step here ever configures.
|
|
367
|
+
await this.ports.cloudflare.putTunnelConfig(tunnel.id, {
|
|
368
|
+
ingress: [{ hostname, service: COOLIFY_PROXY_ORIGIN }, { service: 'http_status:404' }],
|
|
369
|
+
});
|
|
370
|
+
return { resourceId: tunnel.id };
|
|
371
|
+
}
|
|
372
|
+
async cloudflareZone(action) {
|
|
373
|
+
const domain = action.key;
|
|
374
|
+
const existing = await this.ports.cloudflare.findZoneByApex(domain);
|
|
375
|
+
if (existing)
|
|
376
|
+
return { resourceId: existing.id };
|
|
377
|
+
const created = await this.ports.cloudflare.createZone(domain);
|
|
378
|
+
return { resourceId: created.id };
|
|
379
|
+
}
|
|
380
|
+
// ---- files / ai --------------------------------------------------------
|
|
381
|
+
/**
|
|
382
|
+
* #80: provisions the org's own organisation on Files, before
|
|
383
|
+
* {@link filesStorage} can set its spend limit. `provisionOrganisation` is
|
|
384
|
+
* safely repeatable on its own (`FilesOrganisationPort`'s doc comment), so
|
|
385
|
+
* this is called every time regardless of whether it already ran --
|
|
386
|
+
* idempotent by find-or-create, not by checking `recordedIds` first.
|
|
387
|
+
*/
|
|
388
|
+
async filesOrganisation(action, org) {
|
|
389
|
+
const provisioned = await this.ports.filesOrganisation.provisionOrganisation({
|
|
390
|
+
organisationId: org.id,
|
|
391
|
+
ceilingBytes: action.desired.ceilingBytes ?? null,
|
|
392
|
+
ceilingMicros: action.desired.ceilingMicros ?? null,
|
|
393
|
+
});
|
|
394
|
+
return { resourceId: provisioned.organisationId };
|
|
395
|
+
}
|
|
396
|
+
async filesStorage(action, org) {
|
|
397
|
+
const limits = this.ports.filesLimitsFor(org.id);
|
|
398
|
+
await limits.setQuota(action.desired.limitBytes ?? null);
|
|
399
|
+
await limits.setBudget(action.desired.limitMicros ?? null);
|
|
400
|
+
return { resourceId: org.id };
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* #80: provisions the org's own organisation on AI and switches its AI use
|
|
404
|
+
* on, before {@link aiBudget} can set its own spend limit. Both
|
|
405
|
+
* `provisionOrganisation` and `setOrganisationOffers` are safely repeatable
|
|
406
|
+
* on their own (`AiOrganisationPort`'s doc comment), so both are called
|
|
407
|
+
* every time regardless of whether they already ran.
|
|
408
|
+
*/
|
|
409
|
+
async aiOrganisation(action, org) {
|
|
410
|
+
const provisioned = await this.ports.aiOrganisation.provisionOrganisation({
|
|
411
|
+
organisationId: org.id,
|
|
412
|
+
limitMicros: action.desired.limitMicros ?? null,
|
|
413
|
+
});
|
|
414
|
+
await this.ports.aiOrganisation.setOrganisationOffers(org.id, action.desired.offers ?? []);
|
|
415
|
+
return { resourceId: provisioned.organisationId };
|
|
416
|
+
}
|
|
417
|
+
async aiBudget(action, org) {
|
|
418
|
+
const limitMicros = action.desired.limitMicros ?? null;
|
|
419
|
+
if (limitMicros === null) {
|
|
420
|
+
// ai/packages/sdk/src/types.ts:134-137's UpdateBudget.limitMicros is a
|
|
421
|
+
// required string -- there is no "uncapped" this API can express yet
|
|
422
|
+
// (Finding, this campaign's report).
|
|
423
|
+
throw new Error(`ai:budget for ${org.slug}: AI's updateBudget has no uncapped representation (UpdateBudget.limitMicros is a required string); declare a numeric limit until AI's API adds one`);
|
|
424
|
+
}
|
|
425
|
+
const limits = this.ports.aiLimitsFor(org.id);
|
|
426
|
+
await limits.updateBudget({ keyId: null, limitMicros });
|
|
427
|
+
return { resourceId: org.id };
|
|
428
|
+
}
|
|
429
|
+
// ---- destroy -------------------------------------------------------------
|
|
430
|
+
/**
|
|
431
|
+
* {@link StepDestroyer.destroy}: deletes the one resource
|
|
432
|
+
* `FoundryPlanApply.destroy` names. A provider 404 (already gone) counts as
|
|
433
|
+
* deleted -- `isNotFoundError` below -- since a destroy retried after a
|
|
434
|
+
* partial failure must find an already-deleted resource a no-op, not a new
|
|
435
|
+
* error.
|
|
436
|
+
*/
|
|
437
|
+
async destroy(target, context) {
|
|
438
|
+
const { org, recordedIds } = context;
|
|
439
|
+
try {
|
|
440
|
+
switch (`${target.system}:${target.kind}`) {
|
|
441
|
+
case 'cloudflare:tunnel':
|
|
442
|
+
await this.ports.cloudflare.deleteTunnel(target.id);
|
|
443
|
+
return;
|
|
444
|
+
case 'coolify:app':
|
|
445
|
+
await this.ports.coolify.deleteApplication(target.id);
|
|
446
|
+
return;
|
|
447
|
+
case 'coolify:database':
|
|
448
|
+
await this.ports.coolify.deleteDatabase(target.id);
|
|
449
|
+
return;
|
|
450
|
+
case 'zitadel:app': {
|
|
451
|
+
const zitadelOrgId = recordedIds.zitadel_org;
|
|
452
|
+
const projectId = recordedIds.zitadel_project;
|
|
453
|
+
if (!zitadelOrgId || !projectId) {
|
|
454
|
+
throw new Error(`zitadel:app '${target.key}' for ${org.slug}: no ZITADEL org/project recorded, cannot delete`);
|
|
455
|
+
}
|
|
456
|
+
await this.ports.zitadel.deleteApp(zitadelOrgId, projectId, target.id);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
case 'zitadel:project': {
|
|
460
|
+
const zitadelOrgId = recordedIds.zitadel_org;
|
|
461
|
+
if (!zitadelOrgId) {
|
|
462
|
+
throw new Error(`zitadel:project for ${org.slug}: no ZITADEL org recorded, cannot delete project`);
|
|
463
|
+
}
|
|
464
|
+
await this.ports.zitadel.deleteProject(zitadelOrgId, target.id);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
case 'zitadel:customers':
|
|
468
|
+
case 'zitadel:org':
|
|
469
|
+
await this.ports.zitadel.deleteOrg(target.id);
|
|
470
|
+
return;
|
|
471
|
+
default:
|
|
472
|
+
throw new Error(`FoundryStepExecutor has no destroy dispatch for ${target.system}:${target.kind}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
catch (error) {
|
|
476
|
+
if (isNotFoundError(error))
|
|
477
|
+
return;
|
|
478
|
+
throw error;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* True for a provider's own "already gone" response, however it surfaces --
|
|
484
|
+
* `@wtfalch/providers`' `ZitadelError` carries a structured `status: number`
|
|
485
|
+
* (`zitadel.ts`), while its Coolify and Cloudflare clients throw a plain
|
|
486
|
+
* `Error` whose message embeds the HTTP status (`` `Coolify ${method} ${path}
|
|
487
|
+
* → ${status}: ${text}` ``, `` `CF ${method} ${path} → ${status}: ${msg}` ``).
|
|
488
|
+
* Ports are structural here (this file's own top comment: never import a
|
|
489
|
+
* concrete provider class), so this cannot `instanceof`-check a real error
|
|
490
|
+
* type -- it duck-types a `status` field first.
|
|
491
|
+
*
|
|
492
|
+
* A prior version fell back to scanning the *whole* message for a standalone
|
|
493
|
+
* `404` when no `status` field was present. That is unsafe: the Coolify and
|
|
494
|
+
* Cloudflare clients put the resource path -- which can contain a hex UUID
|
|
495
|
+
* -- ahead of the status in the message, so a UUID that happens to contain
|
|
496
|
+
* the digits `404` (`9a404b12-...`) made a genuine 500 read as a 404. The
|
|
497
|
+
* fallback below instead anchors to the exact position both clients put the
|
|
498
|
+
* status in: the first `→ `, after the client, method and path, and before
|
|
499
|
+
* `:`. It never scans the path or the response body.
|
|
500
|
+
*/
|
|
501
|
+
function isNotFoundError(error) {
|
|
502
|
+
const status = error?.status;
|
|
503
|
+
if (typeof status === 'number')
|
|
504
|
+
return status === 404;
|
|
505
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
506
|
+
return /^\S+ \S+ \S+ → 404:/.test(message);
|
|
507
|
+
}
|
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, 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, MissingBespokeAppRepoError } 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, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
|
|
5
|
+
export { FoundryStepExecutor, NotBuiltError, MissingBespokeAppRepoError } 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
|
*/
|