@wtfalch/foundry 0.2.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/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 +7 -4
- package/dist/index.js +6 -4
- package/dist/migrate.d.ts +38 -0
- package/dist/migrate.js +47 -0
- package/dist/migrations/0012_audit.sql +21 -0
- package/dist/migrations/0013_access_emails.sql +6 -0
- package/dist/org.d.ts +23 -0
- package/dist/org.js +37 -0
- package/dist/plan/diff.js +136 -5
- package/dist/plan/executor.d.ts +363 -14
- package/dist/plan/executor.js +759 -24
- package/dist/plan/index.d.ts +2 -2
- package/dist/plan/index.js +2 -2
- package/dist/plan/plan-apply.js +17 -2
- package/dist/plan/providers-readers.d.ts +54 -1
- package/dist/plan/providers-readers.js +153 -1
- 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/store/audit.d.ts +8 -0
- package/dist/store/audit.js +43 -0
- package/dist/store/postgres.js +20 -1
- package/dist/store/schema.d.ts +188 -0
- package/dist/store/schema.js +14 -1
- package/dist/store.d.ts +2 -0
- package/package.json +9 -3
package/dist/plan/executor.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/postmaster';
|
|
1
2
|
import type { DeclaredOrg, HostingBuildPack, OrgId } from '../org.js';
|
|
2
3
|
import type { StepAction } from './diff.js';
|
|
3
4
|
import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
|
|
@@ -68,6 +69,17 @@ export interface CoolifyPort {
|
|
|
68
69
|
}): Promise<{
|
|
69
70
|
uuid: string;
|
|
70
71
|
} | null>;
|
|
72
|
+
/**
|
|
73
|
+
* One application's own live status (`` `running:healthy`, `exited:unhealthy`
|
|
74
|
+
* `` -- `@wtfalch/providers`' `CoolifyApplication.status` doc comment) --
|
|
75
|
+
* `findOrCreateCmsApp`'s own check of whether an app it found by name
|
|
76
|
+
* (rather than just created) needs starting again (#116 review round 3,
|
|
77
|
+
* Finding 1).
|
|
78
|
+
*/
|
|
79
|
+
getApplication(uuid: string): Promise<{
|
|
80
|
+
uuid: string;
|
|
81
|
+
status?: string;
|
|
82
|
+
}>;
|
|
71
83
|
createGithubAppApplication(input: {
|
|
72
84
|
project_uuid: string;
|
|
73
85
|
server_uuid: string;
|
|
@@ -79,6 +91,8 @@ export interface CoolifyPort {
|
|
|
79
91
|
build_pack: HostingBuildPack;
|
|
80
92
|
name?: string;
|
|
81
93
|
domains?: string;
|
|
94
|
+
/** Selects which of two Dockerfiles in one repo to build (`cmsSite`'s own site/cms apps). */
|
|
95
|
+
dockerfile_location?: string;
|
|
82
96
|
}): Promise<{
|
|
83
97
|
uuid: string;
|
|
84
98
|
}>;
|
|
@@ -99,7 +113,8 @@ export interface CoolifyPort {
|
|
|
99
113
|
* from `createPostgresDatabase`'s return value) so the app's env is wired
|
|
100
114
|
* from the database's live state whether this run just created it or found
|
|
101
115
|
* it already there (`coolifyApp`/`coolifyDatabase` below, hosting-wiring
|
|
102
|
-
* campaign). `internal_db_url` is
|
|
116
|
+
* campaign; `cmsSite`'s own cms database, #117). `internal_db_url` is
|
|
117
|
+
* `postgres://user:password@host/db` — a
|
|
103
118
|
* live credential, present in the raw Coolify API response and reachable
|
|
104
119
|
* only from inside the Coolify Docker network (`@wtfalch/providers`'
|
|
105
120
|
* `CoolifyClient.createPostgresDatabase` doc comment).
|
|
@@ -122,10 +137,12 @@ export interface CoolifyPort {
|
|
|
122
137
|
/**
|
|
123
138
|
* Upsert environment variables on an application in one call — used here to
|
|
124
139
|
* push the database's `internal_db_url` in as the app's `DATABASE_URL`
|
|
125
|
-
* (hosting-wiring campaign)
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
140
|
+
* (hosting-wiring campaign), and, for the cms app, its `DATABASE_URL_OWNER`
|
|
141
|
+
* plus a freshly minted `DATABASE_URL` runtime-role credential (#117). Each
|
|
142
|
+
* value is a live credential: it must reach this call and nothing else —
|
|
143
|
+
* never a step's return value, `serviceIds` (hence the store), an audit
|
|
144
|
+
* record, a log or a thrown error. See `coolifyApp`'s `pushDatabaseUrl` and
|
|
145
|
+
* `cmsSite`'s `ensureCmsRuntimeUrl`.
|
|
129
146
|
*/
|
|
130
147
|
bulkUpsertEnvs(applicationUuid: string, envs: Array<{
|
|
131
148
|
key: string;
|
|
@@ -134,8 +151,50 @@ export interface CoolifyPort {
|
|
|
134
151
|
literal?: boolean;
|
|
135
152
|
multiline?: boolean;
|
|
136
153
|
}>): Promise<unknown>;
|
|
154
|
+
/**
|
|
155
|
+
* Every environment variable currently on the application — read before
|
|
156
|
+
* generating `AUTH_COOKIE_SECRET` (`ensureAuthCookieSecret`, level-1
|
|
157
|
+
* app-env campaign) so that value is only ever generated once, never
|
|
158
|
+
* overwritten. Every key comes back twice (a production row and an
|
|
159
|
+
* `is_preview` duplicate, `@wtfalch/providers`' `CoolifyClient.listEnvs`
|
|
160
|
+
* doc comment), so a caller checking "does this key already exist" filters
|
|
161
|
+
* `is_preview === false` the same way a deployed-config reader must.
|
|
162
|
+
*/
|
|
163
|
+
listEnvs(applicationUuid: string): Promise<Array<{
|
|
164
|
+
key: string;
|
|
165
|
+
value: string;
|
|
166
|
+
is_preview: boolean;
|
|
167
|
+
}>>;
|
|
137
168
|
/** Escape hatch (`CoolifyClient.raw`) — used for stop/start, which have no typed method yet. */
|
|
138
169
|
raw(method: string, path: string, body?: unknown): Promise<unknown>;
|
|
170
|
+
/**
|
|
171
|
+
* Persistent (named volume) and file storages already attached to an
|
|
172
|
+
* application (#75) — `ensureLogVolume` reads this before creating one, so
|
|
173
|
+
* a re-run never mounts a second volume at the same path.
|
|
174
|
+
*/
|
|
175
|
+
listStorages(applicationUuid: string): Promise<{
|
|
176
|
+
persistent_storages?: Array<{
|
|
177
|
+
uuid: string;
|
|
178
|
+
name?: string;
|
|
179
|
+
mount_path: string;
|
|
180
|
+
}>;
|
|
181
|
+
file_storages?: Array<{
|
|
182
|
+
uuid: string;
|
|
183
|
+
mount_path: string;
|
|
184
|
+
}>;
|
|
185
|
+
}>;
|
|
186
|
+
/** Mounts a named volume on an application (#75) — `ensureLogVolume`'s create path. */
|
|
187
|
+
createPersistentStorage(applicationUuid: string, input: {
|
|
188
|
+
name: string;
|
|
189
|
+
mount_path: string;
|
|
190
|
+
}): Promise<unknown>;
|
|
191
|
+
/**
|
|
192
|
+
* Restarts the running container without a rebuild (#75) — `ensureLogVolume`
|
|
193
|
+
* calls this after mounting a volume on an app that was already running,
|
|
194
|
+
* since Coolify only binds a newly created storage into the container at
|
|
195
|
+
* its next start.
|
|
196
|
+
*/
|
|
197
|
+
restart(applicationUuid: string): Promise<unknown>;
|
|
139
198
|
/** `FoundryPlanApply.destroy`'s teardown of the org's own app. */
|
|
140
199
|
deleteApplication(uuid: string): Promise<unknown>;
|
|
141
200
|
/** `FoundryPlanApply.destroy`'s teardown of the org's own database. */
|
|
@@ -165,6 +224,31 @@ export interface CloudflarePort {
|
|
|
165
224
|
putTunnelConfig(tunnelId: string, config: unknown): Promise<unknown>;
|
|
166
225
|
/** `FoundryPlanApply.destroy`'s teardown of the org's own Cloudflare tunnel. */
|
|
167
226
|
deleteTunnel(tunnelId: string): Promise<void>;
|
|
227
|
+
/** `cloudflareAccess` finds the org's Access app by domain before creating one (#84). */
|
|
228
|
+
listAccessApps(): Promise<Array<{
|
|
229
|
+
id: string;
|
|
230
|
+
name: string;
|
|
231
|
+
domain: string;
|
|
232
|
+
}>>;
|
|
233
|
+
createAccessApp(app: {
|
|
234
|
+
name: string;
|
|
235
|
+
domain: string;
|
|
236
|
+
type: string;
|
|
237
|
+
}): Promise<{
|
|
238
|
+
id: string;
|
|
239
|
+
}>;
|
|
240
|
+
listAccessPolicies(appId: string): Promise<Array<{
|
|
241
|
+
id: string;
|
|
242
|
+
name: string;
|
|
243
|
+
}>>;
|
|
244
|
+
createAccessPolicy(appId: string, policy: {
|
|
245
|
+
name: string;
|
|
246
|
+
decision: string;
|
|
247
|
+
include: unknown[];
|
|
248
|
+
}): Promise<{
|
|
249
|
+
id: string;
|
|
250
|
+
}>;
|
|
251
|
+
deleteAccessApp(appId: string): Promise<void>;
|
|
168
252
|
/**
|
|
169
253
|
* `FoundryPlanApply.destroy`'s teardown of any DNS record foundry created
|
|
170
254
|
* for the org. Nothing in this executor creates or records one yet (Finding,
|
|
@@ -174,6 +258,57 @@ export interface CloudflarePort {
|
|
|
174
258
|
* creating one.
|
|
175
259
|
*/
|
|
176
260
|
deleteDnsRecord(zoneId: string, recordId: string): Promise<void>;
|
|
261
|
+
/** `coolify:mail`'s lookup of an existing MX/TXT record before upserting it, filtered by type so a stale record of a different type is never mistaken for a match. */
|
|
262
|
+
findDnsRecord(zoneId: string, name: string, type?: string): Promise<Array<{
|
|
263
|
+
id: string;
|
|
264
|
+
type: string;
|
|
265
|
+
content: string;
|
|
266
|
+
name: string;
|
|
267
|
+
}>>;
|
|
268
|
+
createDnsRecord(zoneId: string, rec: {
|
|
269
|
+
type: string;
|
|
270
|
+
name: string;
|
|
271
|
+
content: string;
|
|
272
|
+
proxied?: boolean;
|
|
273
|
+
ttl?: number;
|
|
274
|
+
}): Promise<{
|
|
275
|
+
id: string;
|
|
276
|
+
name: string;
|
|
277
|
+
content: string;
|
|
278
|
+
}>;
|
|
279
|
+
updateDnsRecord(zoneId: string, recordId: string, rec: {
|
|
280
|
+
type: string;
|
|
281
|
+
name: string;
|
|
282
|
+
content: string;
|
|
283
|
+
proxied?: boolean;
|
|
284
|
+
ttl?: number;
|
|
285
|
+
}): Promise<{
|
|
286
|
+
id: string;
|
|
287
|
+
name: string;
|
|
288
|
+
content: string;
|
|
289
|
+
}>;
|
|
290
|
+
/**
|
|
291
|
+
* Escape hatch (mirrors `CoolifyPort.raw`'s own precedent above) --
|
|
292
|
+
* `@wtfalch/providers` 0.5.0's `CloudflareClient` has no typed method for
|
|
293
|
+
* Health Checks or zone rulesets/rate-limiting rules
|
|
294
|
+
* (https://github.com/wtfalch/foundry/issues/43), only this public,
|
|
295
|
+
* generic `request<T>(method, path, body?, missingOkay?)`, which
|
|
296
|
+
* this port names directly rather than inventing a narrower method no real
|
|
297
|
+
* client could structurally satisfy. `cloudflareRateLimit` below is its one
|
|
298
|
+
* caller today. `missingOkay` makes a 404 resolve to `undefined` instead of
|
|
299
|
+
* throwing, matching `CloudflareClient`'s own real behavior exactly.
|
|
300
|
+
*/
|
|
301
|
+
request<T>(method: string, path: string, body?: unknown, missingOkay?: boolean): Promise<T>;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* The Stalwart calls this executor makes for one org's mail, shaped to match
|
|
305
|
+
* `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` method-for-method
|
|
306
|
+
* with the `StalwartClient` bound, the same way {@link CloudflarePort} mirrors
|
|
307
|
+
* `CloudflareClient`.
|
|
308
|
+
*/
|
|
309
|
+
export interface MailPort {
|
|
310
|
+
applyOrgMail(org: OrgMailOrg, options: OrgMailOptions): Promise<OrgMailResult>;
|
|
311
|
+
parkOrgMail(org: OrgMailOrg, options: OrgMailOptions): Promise<void>;
|
|
177
312
|
}
|
|
178
313
|
/**
|
|
179
314
|
* The ZITADEL calls this executor makes. `findOrCreateOrg`/`findOrCreateProject`
|
|
@@ -350,6 +485,7 @@ export interface FoundryStepExecutorPorts {
|
|
|
350
485
|
coolify: CoolifyPort;
|
|
351
486
|
cloudflare: CloudflarePort;
|
|
352
487
|
zitadel: ZitadelPort;
|
|
488
|
+
mail: MailPort;
|
|
353
489
|
/** A factory, not a flat client: Files/AI are org-scoped credentials the host resolves per org. */
|
|
354
490
|
filesLimitsFor: (orgId: OrgId) => FilesOrgLimits;
|
|
355
491
|
aiLimitsFor: (orgId: OrgId) => AiOrgLimits;
|
|
@@ -373,25 +509,58 @@ export interface FoundryStepExecutorPorts {
|
|
|
373
509
|
}
|
|
374
510
|
/**
|
|
375
511
|
* Thrown by name for a step this executor deliberately does not implement:
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
* carry the step and the provider's status only").
|
|
512
|
+
* chat (no admin API exists yet), plus moving the issuer onto or off the
|
|
513
|
+
* org's domain (built, but not in v1 — domain verification and passkey
|
|
514
|
+
* invalidation need their own design). Never carries a token, secret or key
|
|
515
|
+
* (task brief: "errors carry the step and the provider's status only").
|
|
381
516
|
*/
|
|
382
517
|
export declare class NotBuiltError extends Error {
|
|
383
518
|
constructor(step: string, detail: string);
|
|
384
519
|
}
|
|
385
|
-
/**
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
520
|
+
/**
|
|
521
|
+
* The health URLs an outside prober should call for this org (#43): one per
|
|
522
|
+
* app foundry runs for it. A hosting org's app answers at its own hostname,
|
|
523
|
+
* and a site org's site app and cms app both serve `/api/health`
|
|
524
|
+
* (https://github.com/wtfalch/cms/tree/main/apps). An org that runs neither
|
|
525
|
+
* has nothing to probe. Exported so archon, which owns uptime, never repeats
|
|
526
|
+
* this file's hostname rules.
|
|
527
|
+
*/
|
|
528
|
+
export declare function healthUrls(org: DeclaredOrg): string[];
|
|
389
529
|
export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer {
|
|
390
530
|
private readonly ports;
|
|
391
531
|
constructor(ports: FoundryStepExecutorPorts);
|
|
392
532
|
execute(action: StepAction, context: StepExecutionContext): Promise<{
|
|
393
533
|
resourceId?: string;
|
|
394
534
|
} | undefined>;
|
|
535
|
+
/**
|
|
536
|
+
* `plan/diff.ts`'s `planAccess` (#84): puts the org's app hostname behind a
|
|
537
|
+
* Cloudflare Access app that lets only `hosting.accessEmails` through.
|
|
538
|
+
* foundry names its Access app `foundry:<orgId>` and reuses only an app with
|
|
539
|
+
* that name, so a re-run after a crash does not duplicate it. It refuses,
|
|
540
|
+
* rather than adopt, when another Access app already holds the hostname:
|
|
541
|
+
* destroy would later delete it. It also refuses when its own app carries
|
|
542
|
+
* any policy but foundry's, since that policy could let others in.
|
|
543
|
+
*/
|
|
544
|
+
private cloudflareAccess;
|
|
545
|
+
/**
|
|
546
|
+
* `plan/diff.ts`'s `planRateLimit` (#43): one Cloudflare rate limiting
|
|
547
|
+
* rule, scoped to `SIGN_IN_PATH_EXPRESSION`, on the org's own Cloudflare
|
|
548
|
+
* zone -- only ever called for an own-domain org (`planRateLimit`'s own
|
|
549
|
+
* gate), so `org.domain` is set and that zone is the org's alone, never
|
|
550
|
+
* shared with another org the way `oikos.host` is.
|
|
551
|
+
*
|
|
552
|
+
* Rate limiting rules attach to a zone's `http_ratelimit` phase entry
|
|
553
|
+
* point ruleset, one `GET`/`PUT` pair per zone (`@wtfalch/providers` 0.5.0
|
|
554
|
+
* has no typed method for either -- `CloudflarePort.request`'s own doc
|
|
555
|
+
* comment) -- so "find by name" here means reading that one ruleset's
|
|
556
|
+
* `rules[]` and matching `RATE_LIMIT_RULE_DESCRIPTION`, the same shape
|
|
557
|
+
* `cloudflareAccess` above finds its own Access app by name. The Free
|
|
558
|
+
* plan allows exactly one rule per zone, so a rule already there under a
|
|
559
|
+
* different description is refused, never adopted or replaced, mirroring
|
|
560
|
+
* `cloudflareAccess`'s own refusal when a foreign Access app already
|
|
561
|
+
* holds the hostname.
|
|
562
|
+
*/
|
|
563
|
+
private cloudflareRateLimit;
|
|
395
564
|
private zitadelIssuer;
|
|
396
565
|
/**
|
|
397
566
|
* `plan/diff.ts`'s `planOwner`: invites the org's owner into its ZITADEL
|
|
@@ -416,6 +585,28 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
416
585
|
private ensureCoolifyProject;
|
|
417
586
|
private resolveServer;
|
|
418
587
|
private coolifyApp;
|
|
588
|
+
/**
|
|
589
|
+
* #75: mounts a persistent volume at {@link LOG_DIR} on the app, unless one
|
|
590
|
+
* is already there. Found by `mount_path`, not a recorded id — no
|
|
591
|
+
* `serviceIds` entry exists for a storage the way one does for the app or
|
|
592
|
+
* database itself, and Coolify's own listing is cheap enough to check
|
|
593
|
+
* first every time, the same way `coolifyDatabase` finds its database by
|
|
594
|
+
* name before creating one. Never called from the `enabled === false`
|
|
595
|
+
* (park) branch above, so parking an app never touches its volume either.
|
|
596
|
+
*
|
|
597
|
+
* Mounting a volume on an application already running does not attach it
|
|
598
|
+
* to the live container -- Coolify only binds a storage in at the
|
|
599
|
+
* container's next start (Finding, refuted against the first version of
|
|
600
|
+
* this method: it created the storage and let a passing `listStorages`
|
|
601
|
+
* read report `hasLogVolume: true` on the very next plan, with the
|
|
602
|
+
* container itself still writing nowhere). `wasAlreadyRunning` tells this
|
|
603
|
+
* method whether `appId` names an app `coolifyApp` found already there
|
|
604
|
+
* (restart it once the volume is created) or one it just created fresh
|
|
605
|
+
* (already about to get its first deploy/start from
|
|
606
|
+
* `createGithubAppApplication`'s own instant-deploy path, so a restart
|
|
607
|
+
* here would only be a redundant extra one).
|
|
608
|
+
*/
|
|
609
|
+
private ensureLogVolume;
|
|
419
610
|
private coolifyDatabase;
|
|
420
611
|
/**
|
|
421
612
|
* Pushes the database's connection string into the app's environment as
|
|
@@ -440,8 +631,166 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
440
631
|
* never interpolated into a thrown error.
|
|
441
632
|
*/
|
|
442
633
|
private pushDatabaseUrl;
|
|
634
|
+
/**
|
|
635
|
+
* Pushes what the org's app needs to sign a user in through ZITADEL (gap
|
|
636
|
+
* P2, `.plans/2026-09-26-level-1-org-map.md`) -- a second `bulkUpsertEnvs`
|
|
637
|
+
* call rather than folding into `pushDatabaseUrl`'s, since this one runs
|
|
638
|
+
* unconditionally (`coolifyApp`) where that one runs only once
|
|
639
|
+
* `hosting.database` is declared and provisioned.
|
|
640
|
+
*
|
|
641
|
+
* `APP_URL` and `LOG_DIR` (#75) are pushed unconditionally; every other
|
|
642
|
+
* value is pushed only once its own source step has actually recorded it
|
|
643
|
+
* (`context.recordedIds()`) -- on a first-ever apply,
|
|
644
|
+
* run one step at a time in `diff.ts`'s `computePlanDetail` order, that is
|
|
645
|
+
* `zitadel:issuer` (`AUTH_ORGANIZATION_ID`), `zitadel:owner`
|
|
646
|
+
* (`OWNER_USER_ID`, gap P3) and every `zitadel:app` step
|
|
647
|
+
* (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
|
|
648
|
+
* reason. AUTH_APP_KEY (waits on an auth broker registration API) and
|
|
649
|
+
* DATABASE_URL_OWNER (a runtime-role split not built yet, `pushDatabaseUrl`'s
|
|
650
|
+
* own doc comment) are out of scope here (task brief).
|
|
651
|
+
*/
|
|
652
|
+
private pushAppEnv;
|
|
653
|
+
/**
|
|
654
|
+
* `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
|
|
655
|
+
* only the first time (task brief), never regenerated or overwritten, so a
|
|
656
|
+
* later apply's `bulkUpsertEnvs` call simply omits the key rather than
|
|
657
|
+
* upsert a fresh value over it.
|
|
658
|
+
*/
|
|
659
|
+
private ensureAuthCookieSecret;
|
|
660
|
+
/**
|
|
661
|
+
* `plan/diff.ts`'s `planSite` (#116, slicing #99's "provision the cms app,
|
|
662
|
+
* database and site app per org"): the 2026-09-25 decision
|
|
663
|
+
* (https://github.com/wtfalch/cms/blob/main/.plans/2026-09-25-org-sites.md)
|
|
664
|
+
* means foundry never calls a cms admin API for the org's site -- it
|
|
665
|
+
* provisions two ordinary Coolify applications from the one
|
|
666
|
+
* https://github.com/wtfalch/cms repo, the same way `coolifyApp` above
|
|
667
|
+
* provisions the org's own hosted app: a site app (`apps/site`, the
|
|
668
|
+
* reference renderer) at `org.domain`, built from
|
|
669
|
+
* `CMS_SITE_APP_DOCKERFILE`, and a cms app (`apps/host`, single-org mode,
|
|
670
|
+
* https://github.com/wtfalch/cms/blob/main/docs/adr/0013-one-cms-per-org.md)
|
|
671
|
+
* at {@link cmsHostnameFor}, built from the repo's own root
|
|
672
|
+
* `CMS_APP_DOCKERFILE` -- one repo, two applications, told apart by
|
|
673
|
+
* `dockerfile_location`.
|
|
674
|
+
*
|
|
675
|
+
* `action.key` is `org.id` (`planSite`'s own singleton key), so `execute`'s
|
|
676
|
+
* own auto-record has only one `resourceId` slot for this step
|
|
677
|
+
* (`cms:site:<orgId>`) -- returned here as the cms app's id, the resource
|
|
678
|
+
* this step is named for. The site app's id has no such slot, so both ids
|
|
679
|
+
* are also recorded as scope ids ({@link CMS_APP_SCOPE_KEY}/
|
|
680
|
+
* {@link SITE_APP_SCOPE_KEY}), read back by the park branch below on a
|
|
681
|
+
* later apply, when nothing gets provisioned to return one -- read
|
|
682
|
+
* independently of each other there, since a crash partway through a
|
|
683
|
+
* first-ever provision can leave only one of the two actually recorded.
|
|
684
|
+
*
|
|
685
|
+
* #117: this step also finds or creates the cms app's own Postgres
|
|
686
|
+
* database ({@link ensureCmsDatabase}) and pushes its owner and runtime
|
|
687
|
+
* connection strings ({@link ensureCmsRuntimeUrl}) onto the cms app, in the
|
|
688
|
+
* same `bulkUpsertEnvs` call as `APP_URL`/`CMS_ORGANISATION_ID`/
|
|
689
|
+
* `CMS_SITE_URL` below. The database's id is recorded as a third flat scope
|
|
690
|
+
* id ({@link CMS_DATABASE_SCOPE_KEY}), for the same reason the two app ids
|
|
691
|
+
* are: no `system:kind:key` slot of its own. Never touched from the park
|
|
692
|
+
* branch above -- parking stops the two apps only, same as #116's own
|
|
693
|
+
* destroy note; this step never parks or destroys the database.
|
|
694
|
+
*/
|
|
695
|
+
private cmsSite;
|
|
696
|
+
/**
|
|
697
|
+
* `cmsSite`'s own find-or-create of a Postgres database for the cms app,
|
|
698
|
+
* modeled on `coolifyDatabase` above: `ensureCoolifyProject` (already run
|
|
699
|
+
* by `cmsSite` before this is called) and `findOrCreateEnvironment` give
|
|
700
|
+
* the project/environment this reuses, then `listDatabases` finds one
|
|
701
|
+
* already created by name before `createPostgresDatabase` makes one.
|
|
702
|
+
* Named `<org.slug>-cms-db`, distinct from `coolifyDatabase`'s own
|
|
703
|
+
* `<org.slug>-db`, so the two never collide in the same project for an org
|
|
704
|
+
* that has both.
|
|
705
|
+
*
|
|
706
|
+
* `context.recordedIds()` is checked first, the same short-circuit
|
|
707
|
+
* `coolifyDatabase` itself does not have (that step's own `resourceId` slot
|
|
708
|
+
* already covers it) -- here it is load-bearing, since this database has
|
|
709
|
+
* no `system:kind:key` slot of its own ({@link CMS_DATABASE_SCOPE_KEY}'s
|
|
710
|
+
* doc comment) and `cms:site` runs on every apply once `org.site.enabled`,
|
|
711
|
+
* not just the first.
|
|
712
|
+
*
|
|
713
|
+
* Destroying this database is out of scope here (#117, same as #116's own
|
|
714
|
+
* destroy note for the cms/site apps).
|
|
715
|
+
*/
|
|
716
|
+
private ensureCmsDatabase;
|
|
717
|
+
/**
|
|
718
|
+
* `null` once the cms app already has a `DATABASE_URL` -- never rotated,
|
|
719
|
+
* the same rule `ensureAuthCookieSecret` follows for `AUTH_COOKIE_SECRET`,
|
|
720
|
+
* and for the same kind of reason: `@wtfalch/cms`'s own
|
|
721
|
+
* `apps/host/scripts/migrate.mjs` (`ensureRuntimeRole`) creates or updates
|
|
722
|
+
* the live `cms_rt` Postgres role from exactly the username/password this
|
|
723
|
+
* method mints, once, at container boot -- overwriting `DATABASE_URL` on a
|
|
724
|
+
* later apply would leave that already-created role's real password out of
|
|
725
|
+
* sync with whatever foundry hands the app next.
|
|
726
|
+
*
|
|
727
|
+
* The password is minted with `randomBytes` (the same import
|
|
728
|
+
* `ensureAuthCookieSecret` already uses) and encoded `base64url` -- `+`/`/`
|
|
729
|
+
* swapped for `-`/`_`, no `=` padding -- so it is already URL-safe in a
|
|
730
|
+
* `postgres://` URL's userinfo segment with no further percent-encoding
|
|
731
|
+
* needed. `dbInternalUrl` (`getDatabase`'s own `internal_db_url`) is parsed
|
|
732
|
+
* with `URL` for its host, path and query string; its own superuser
|
|
733
|
+
* username and password are discarded -- this mints a distinct, restricted
|
|
734
|
+
* role's own credential, never reuses the superuser's. The query string
|
|
735
|
+
* (`search`) is kept as-is and appended unchanged (#117 review round 2):
|
|
736
|
+
* a parameter there (e.g. `?sslmode=require`) is a connection requirement
|
|
737
|
+
* Coolify itself put on this database, not something specific to the
|
|
738
|
+
* superuser role, so the migration (`DATABASE_URL_OWNER`) and runtime
|
|
739
|
+
* (`DATABASE_URL`) connections must carry the same one.
|
|
740
|
+
*
|
|
741
|
+
* The password is a live credential: built here and handed straight to
|
|
742
|
+
* `cmsSite`'s own `bulkUpsertEnvs` call and nowhere else in this class --
|
|
743
|
+
* never returned, recorded, logged, or interpolated into a thrown error
|
|
744
|
+
* (the same rule `pushDatabaseUrl`'s doc comment states). That rule is why
|
|
745
|
+
* `URL`'s own parse failure is caught below rather than left to propagate:
|
|
746
|
+
* nothing here states or controls the superuser password's alphabet
|
|
747
|
+
* (Coolify's own, unlike the `cms_rt` one minted just below it), and
|
|
748
|
+
* Node's `TypeError [ERR_INVALID_URL]` on an unescaped `/`, `#` or `?` in
|
|
749
|
+
* it carries the *whole* input -- credential included -- on its own
|
|
750
|
+
* `.input` property (never in `.message`, but still reachable by a logger
|
|
751
|
+
* that inspects the error object, not just its message). Caught and
|
|
752
|
+
* turned into "skip `DATABASE_URL` for this apply" instead: `cmsSite`
|
|
753
|
+
* still pushes `DATABASE_URL_OWNER` and its other envs, and a later apply
|
|
754
|
+
* retries this once `listEnvs` still shows no `DATABASE_URL`.
|
|
755
|
+
*/
|
|
756
|
+
private ensureCmsRuntimeUrl;
|
|
757
|
+
/**
|
|
758
|
+
* `cmsSite`'s own find-by-name-then-create flow for one of its two
|
|
759
|
+
* applications -- the same shape `coolifyApp` above inlines for its own,
|
|
760
|
+
* single, application, extracted here since `cmsSite` needs it twice in
|
|
761
|
+
* one step. Both of `cmsSite`'s apps share `CMS_REPO`'s own default branch
|
|
762
|
+
* (`main`) -- there is no per-org `gitBranch`/`buildPack` for this repo
|
|
763
|
+
* the way `org.hosting` carries for the org's *own* app, since the cms
|
|
764
|
+
* site is not that app.
|
|
765
|
+
*
|
|
766
|
+
* An app found by name (rather than just created) is started back up if
|
|
767
|
+
* it is not already running or on its way there (#116 review round 3,
|
|
768
|
+
* Finding 1): parking (`cmsSite`'s own `enabled: false` branch) stops both
|
|
769
|
+
* apps without deleting them, so re-enabling finds them here, still
|
|
770
|
+
* stopped, and `ProvidersCmsReader` would otherwise keep reporting
|
|
771
|
+
* `enabled: false` forever -- the plan never converging back to on.
|
|
772
|
+
* `coolifyApp`'s own analogous gap (it never restarted a found-but-stopped
|
|
773
|
+
* app either) has since closed the same way, inline in that method (#130);
|
|
774
|
+
* this method is `cmsSite`'s only caller.
|
|
775
|
+
*/
|
|
776
|
+
private findOrCreateCmsApp;
|
|
443
777
|
private cloudflareTunnel;
|
|
444
778
|
private cloudflareZone;
|
|
779
|
+
/**
|
|
780
|
+
* `plan/diff.ts`'s `planMail`: gives an org mail, moves it between modes,
|
|
781
|
+
* or parks it on the one shared Stalwart instance (`this.ports.mail`,
|
|
782
|
+
* `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` bound), then
|
|
783
|
+
* publishes whatever DNS records the call returns.
|
|
784
|
+
*/
|
|
785
|
+
private coolifyMail;
|
|
786
|
+
/**
|
|
787
|
+
* Upserts each DNS record `applyOrgMail` says foundry must publish, found
|
|
788
|
+
* by an exact name+type match (`CloudflarePort.findDnsRecord`'s own doc
|
|
789
|
+
* comment) so update never mistakes a stale record of a different type for
|
|
790
|
+
* a match. A `DKIM` record can be absent on the first call, since Stalwart
|
|
791
|
+
* mints the key on a background task; the next apply publishes it.
|
|
792
|
+
*/
|
|
793
|
+
private publishMailDnsRecords;
|
|
445
794
|
/**
|
|
446
795
|
* #80: provisions the org's own organisation on Files, before
|
|
447
796
|
* {@link filesStorage} can set its spend limit. `provisionOrganisation` is
|