@wtfalch/foundry 0.2.0 → 0.4.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 +192 -0
- package/dist/calls-sfu.js +256 -0
- package/dist/index.d.ts +8 -5
- 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/migrations/0014_calls_enabled.sql +7 -0
- package/dist/migrations/0015_domain_unique.sql +39 -0
- package/dist/org.d.ts +41 -2
- package/dist/org.js +85 -0
- package/dist/plan/calls-matrix.d.ts +135 -0
- package/dist/plan/calls-matrix.js +275 -0
- package/dist/plan/diff.d.ts +8 -4
- package/dist/plan/diff.js +239 -9
- package/dist/plan/executor.d.ts +751 -14
- package/dist/plan/executor.js +1538 -32
- package/dist/plan/index.d.ts +3 -3
- package/dist/plan/index.js +2 -2
- package/dist/plan/plan-apply.js +29 -7
- 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 +74 -2
- package/dist/store/audit.d.ts +8 -0
- package/dist/store/audit.js +43 -0
- package/dist/store/postgres.d.ts +11 -6
- package/dist/store/postgres.js +104 -27
- package/dist/store/schema.d.ts +205 -0
- package/dist/store/schema.js +15 -1
- package/dist/store.d.ts +12 -0
- package/package.json +9 -3
package/dist/plan/executor.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { URL } from 'node:url';
|
|
3
|
+
import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
|
|
4
|
+
import { MAS_IMAGE, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
1
5
|
// ---------------------------------------------------------------------------
|
|
2
6
|
// Errors
|
|
3
7
|
// ---------------------------------------------------------------------------
|
|
4
8
|
/**
|
|
5
9
|
* Thrown by name for a step this executor deliberately does not implement:
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* carry the step and the provider's status only").
|
|
10
|
+
* chat (no admin API exists yet), plus moving the issuer onto or off the
|
|
11
|
+
* org's domain (built, but not in v1 — domain verification and passkey
|
|
12
|
+
* invalidation need their own design). Never carries a token, secret or key
|
|
13
|
+
* (task brief: "errors carry the step and the provider's status only").
|
|
11
14
|
*/
|
|
12
15
|
export class NotBuiltError extends Error {
|
|
13
16
|
constructor(step, detail) {
|
|
@@ -15,13 +18,6 @@ export class NotBuiltError extends Error {
|
|
|
15
18
|
this.name = 'NotBuiltError';
|
|
16
19
|
}
|
|
17
20
|
}
|
|
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
21
|
// ---------------------------------------------------------------------------
|
|
26
22
|
// Hostnames
|
|
27
23
|
// ---------------------------------------------------------------------------
|
|
@@ -36,6 +32,83 @@ export class MissingBespokeAppRepoError extends Error {
|
|
|
36
32
|
function hostnameFor(org) {
|
|
37
33
|
return org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
|
|
38
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* `cms.<domain>` -- the cms studio's own hostname (`cmsSite` below), apart
|
|
37
|
+
* from the public site itself at `org.domain` (used directly there). Called
|
|
38
|
+
* from `cmsSite` and from `zitadelApp` for the fixed `cms` client (#139),
|
|
39
|
+
* both of which only run once `org.site.enabled` is true, so `org.domain`
|
|
40
|
+
* is non-null by the same `validateSite` guarantee
|
|
41
|
+
* {@link tunnelIngressHostnames}'s own doc comment cites.
|
|
42
|
+
*/
|
|
43
|
+
function cmsHostnameFor(org) {
|
|
44
|
+
return `cms.${org.domain}`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* #145's "Decided" (2026-09-27): the calls service's own public hostname.
|
|
48
|
+
* `calls.usesDomain: false` (or no `domain`) is served flat, like
|
|
49
|
+
* `<slug>-stage.oikos.host` and `<slug>-pr-<n>.oikos.host` (`../slug.ts`) --
|
|
50
|
+
* the shared tunnel's wildcard `*.oikos.host` DNS record already reaches it,
|
|
51
|
+
* so nothing changes in `scripts/shared-ingress.mjs`. `calls.usesDomain: true`
|
|
52
|
+
* gets `calls.<domain>` instead, on its own per-org tunnel
|
|
53
|
+
* ({@link tunnelIngressHostnames}), the same subdomain-of-the-org's-own-domain
|
|
54
|
+
* shape {@link cmsHostnameFor} uses for `cms.<domain>`.
|
|
55
|
+
*/
|
|
56
|
+
function callsHostnameFor(org) {
|
|
57
|
+
return org.calls.usesDomain && org.domain
|
|
58
|
+
? `calls.${org.domain}`
|
|
59
|
+
: `${org.slug}-calls.oikos.host`;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The hostnames `cloudflareTunnel` routes to `COOLIFY_PROXY_ORIGIN`
|
|
63
|
+
* (https://github.com/wtfalch/foundry/issues/115). A site-enabled org
|
|
64
|
+
* (`org.ts`'s `SiteConfig.enabled`) has no single app hostname to reuse -- it
|
|
65
|
+
* needs both its public site at `org.domain` and its cms studio (the sign-in
|
|
66
|
+
* and editing surface, `@wtfalch/cms`) at `cms.<domain>`, so both are named
|
|
67
|
+
* here; `org.domain` is non-null in that branch, guaranteed by `org.ts`'s
|
|
68
|
+
* `validateSite` ("site.enabled requires domain"). Every other org --
|
|
69
|
+
* concretely a hosting org, whose one app hostname this served before #115
|
|
70
|
+
* ever existed -- keeps exactly the prior single-hostname behavior
|
|
71
|
+
* ({@link hostnameFor}), unchanged.
|
|
72
|
+
*
|
|
73
|
+
* #145: an own-domain calls service adds `calls.<domain>` on top of whichever
|
|
74
|
+
* of those a site/hosting org already gets, since calls can run alongside
|
|
75
|
+
* either (or neither).
|
|
76
|
+
*/
|
|
77
|
+
function tunnelIngressHostnames(org) {
|
|
78
|
+
const hostnames = org.site.enabled && org.domain ? [org.domain, `cms.${org.domain}`] : [hostnameFor(org)];
|
|
79
|
+
if (org.calls.enabled && org.calls.usesDomain && org.domain) {
|
|
80
|
+
hostnames.push(`calls.${org.domain}`);
|
|
81
|
+
}
|
|
82
|
+
return hostnames;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The health URLs an outside prober should call for this org (#43): one per
|
|
86
|
+
* app foundry runs for it. A hosting org's app answers at its own hostname,
|
|
87
|
+
* and a site org's site app and cms app both serve `/api/health`
|
|
88
|
+
* (https://github.com/wtfalch/cms/tree/main/apps). An org that runs neither
|
|
89
|
+
* has nothing to probe. Exported so archon, which owns uptime, never repeats
|
|
90
|
+
* this file's hostname rules.
|
|
91
|
+
*/
|
|
92
|
+
export function healthUrls(org) {
|
|
93
|
+
if (org.hosting.enabled)
|
|
94
|
+
return [`https://${hostnameFor(org)}/api/health`];
|
|
95
|
+
if (org.site.enabled && org.domain) {
|
|
96
|
+
return [`https://${org.domain}/api/health`, `https://${cmsHostnameFor(org)}/api/health`];
|
|
97
|
+
}
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The `OrgMailOrg` argument for `applyOrgMail`/`parkOrgMail`: an org with
|
|
102
|
+
* `mail.usesDomain` off has no domain of its own, so it is level 1 on
|
|
103
|
+
* `MAIL_APEX` and its mode is forced to `'hosted'` — `@wtfalch/email`'s own
|
|
104
|
+
* `resolveDomain` refuses any other mode for a domain-less org, and a
|
|
105
|
+
* level-1 `{slug}.{apex}` domain always has Stalwart's MX (foundry#7 "Mail").
|
|
106
|
+
*/
|
|
107
|
+
function mailOrgFor(org, usesDomain, mode) {
|
|
108
|
+
return usesDomain && org.domain
|
|
109
|
+
? { slug: org.slug, domain: org.domain, mode }
|
|
110
|
+
: { slug: org.slug, mode: 'hosted' };
|
|
111
|
+
}
|
|
39
112
|
/**
|
|
40
113
|
* Every app on the Coolify box shares one Traefik reverse proxy, and Coolify
|
|
41
114
|
* itself points that proxy's routing at whatever `domains` an application
|
|
@@ -47,6 +120,233 @@ function hostnameFor(org) {
|
|
|
47
120
|
* value.
|
|
48
121
|
*/
|
|
49
122
|
const COOLIFY_PROXY_ORIGIN = 'http://coolify-proxy:80';
|
|
123
|
+
/** The one Access policy foundry puts on its own Access app (#84). */
|
|
124
|
+
const ACCESS_POLICY_NAME = 'foundry: allowed emails';
|
|
125
|
+
/**
|
|
126
|
+
* The sign-in broker's own route mount (`@wtfalch/auth`'s `NextAuth`,
|
|
127
|
+
* `dist/next.d.ts`'s `handlers` doc comment: "`export const { GET, POST } =
|
|
128
|
+
* handlers` from app/auth/[...auth]/route.ts") -- every sign-in request an
|
|
129
|
+
* app built on `@wtfalch/auth` serves passes through `/auth/*`, which is
|
|
130
|
+
* what https://github.com/wtfalch/foundry/issues/43's "sign-in paths" names.
|
|
131
|
+
* `http.request.uri.path` (Cloudflare's "Path" field) is one of the two
|
|
132
|
+
* fields the Free plan allows in a rate limiting rule's expression (the
|
|
133
|
+
* other, "Verified Bot", is not needed here) -- checked against
|
|
134
|
+
* developers.cloudflare.com/waf/rate-limiting-rules/, 2026-09-26. Duplicated,
|
|
135
|
+
* not imported, in `scripts/shared-ingress.mjs`'s own constant of the same
|
|
136
|
+
* value: that file sits outside the pnpm workspace (its own top comment) and
|
|
137
|
+
* cannot import from this package.
|
|
138
|
+
*/
|
|
139
|
+
const SIGN_IN_PATH_EXPRESSION = 'starts_with(http.request.uri.path, "/auth/")';
|
|
140
|
+
/**
|
|
141
|
+
* The rate limiting rule's fixed `description`, found before create (like
|
|
142
|
+
* `cloudflareAccess`'s app name) so a re-run after a crash reuses it instead
|
|
143
|
+
* of trying to add a second rule -- which the Free plan's one-rule-per-zone
|
|
144
|
+
* ceiling would refuse anyway (#43).
|
|
145
|
+
*/
|
|
146
|
+
const RATE_LIMIT_RULE_DESCRIPTION = 'foundry: rate limit sign-in paths';
|
|
147
|
+
/**
|
|
148
|
+
* The Cloudflare Free plan's fixed values for a rate limiting rule's period
|
|
149
|
+
* and mitigation timeout -- the only values that plan accepts for either
|
|
150
|
+
* (developers.cloudflare.com/waf/rate-limiting-rules/, checked 2026-09-26).
|
|
151
|
+
* `characteristics: ['cf.colo.id', 'ip.src']`: the IP address is the Free
|
|
152
|
+
* plan's only counting characteristic, and the API requires `cf.colo.id`
|
|
153
|
+
* explicitly on every rule. The expression uses `starts_with`, since
|
|
154
|
+
* `matches` needs Business or above. `requests_per_period` is foundry's own
|
|
155
|
+
* choice within that ceiling, not a plan limit.
|
|
156
|
+
*/
|
|
157
|
+
const RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS = 10;
|
|
158
|
+
const RATE_LIMIT_REQUESTS_PER_PERIOD = 10;
|
|
159
|
+
/**
|
|
160
|
+
* The shared standard app a level-1 org with no `bespokeAppRepo` gets
|
|
161
|
+
* deployed (gap P1, `.plans/2026-09-26-level-1-org-map.md`): one repo, the
|
|
162
|
+
* same for every such org, decided by William 2026-09-26. `coolifyApp` below
|
|
163
|
+
* names it as the git source in exactly the shape `bespokeAppRepo` itself
|
|
164
|
+
* uses (`org.ts`'s `DeclaredOrg.bespokeAppRepo: string | null`) whenever an
|
|
165
|
+
* org declares none of its own — the repo may not exist yet at decision
|
|
166
|
+
* time, and Coolify's own GitHub App create call is what will fail loudly
|
|
167
|
+
* against it, the same as any other repo name that does not exist.
|
|
168
|
+
*
|
|
169
|
+
* Hestia moved into this repo's own `apps/hestia` (estate DECISIONS.md,
|
|
170
|
+
* 2026-09-28; the hestia repo archives once this move lands), so the
|
|
171
|
+
* standard app now builds from foundry's own repo, told apart from
|
|
172
|
+
* `packages/foundry` by `dockerfile_location` — the same one-repo,
|
|
173
|
+
* multiple-apps shape `cmsSite`/`CMS_APP_DOCKERFILE` already use below.
|
|
174
|
+
*/
|
|
175
|
+
const DEFAULT_APP_REPO = 'wtfalch/foundry';
|
|
176
|
+
const DEFAULT_APP_DOCKERFILE = 'apps/hestia/Dockerfile';
|
|
177
|
+
/**
|
|
178
|
+
* The one repo `cmsSite` builds both of its apps from
|
|
179
|
+
* (https://github.com/wtfalch/cms) -- the 2026-09-25 decision this campaign
|
|
180
|
+
* implements (`.plans/2026-09-25-org-sites.md`, that repo): foundry never
|
|
181
|
+
* calls a cms admin API for the org's site, it provisions two ordinary
|
|
182
|
+
* Coolify applications from this one repo, told apart by
|
|
183
|
+
* `dockerfile_location`. `CMS_APP_DOCKERFILE` is the repo's own root
|
|
184
|
+
* `Dockerfile` (`apps/host`, single-org mode); `CMS_SITE_APP_DOCKERFILE`
|
|
185
|
+
* is `apps/site`'s own Dockerfile (the reference renderer).
|
|
186
|
+
*/
|
|
187
|
+
const CMS_REPO = 'wtfalch/cms';
|
|
188
|
+
const CMS_APP_DOCKERFILE = 'Dockerfile';
|
|
189
|
+
const CMS_SITE_APP_DOCKERFILE = 'apps/site/Dockerfile';
|
|
190
|
+
/**
|
|
191
|
+
* Every app foundry deploys keeps its own logs across a redeploy, so a
|
|
192
|
+
* database outage does not also erase the only other copy of the evidence
|
|
193
|
+
* (#75). This is the exact mount path and `LOG_DIR` value lokessmie's own
|
|
194
|
+
* pilot uses (https://github.com/wtfalch/lokessmie/pull/521, verified live
|
|
195
|
+
* with Postgres stopped) — `diff.ts`'s `planApp` names this same path in its
|
|
196
|
+
* own step description, kept as a plain string there rather than importing
|
|
197
|
+
* it from this file, the same way `hostnameFor`'s hostname rule is
|
|
198
|
+
* restated rather than shared across the two files.
|
|
199
|
+
*/
|
|
200
|
+
const LOG_DIR = '/app/logs';
|
|
201
|
+
/**
|
|
202
|
+
* The one shared Stalwart apex and MX host every org's mail lives on (#7
|
|
203
|
+
* "Mail"), the same values `@wtfalch/email`'s own README examples use for
|
|
204
|
+
* `applyOrgMail`/`parkOrgMail`'s `OrgMailOptions`.
|
|
205
|
+
*/
|
|
206
|
+
const MAIL_APEX = 'wtfalch.dev';
|
|
207
|
+
const MAIL_HOST = 'mail.wtfalch.dev';
|
|
208
|
+
/**
|
|
209
|
+
* The recorded OIDC app's `clientId` (level-1 app-env campaign, gap P2) —
|
|
210
|
+
* not a `system:kind:key` resource id (that slot already holds the app's
|
|
211
|
+
* `appId`, `zitadelApp`'s own `resourceId`) and not a secret (`deliverSecret`
|
|
212
|
+
* is for that), so a flat scope id like `zitadel_org`/`zitadel_project`
|
|
213
|
+
* above. A level-1 org declares exactly one web `issuerApps` entry for its
|
|
214
|
+
* one standard app, so one flat key is enough; an org with more than one web
|
|
215
|
+
* app would need a richer key, not built here (Finding, this campaign's
|
|
216
|
+
* report).
|
|
217
|
+
*/
|
|
218
|
+
const APP_CLIENT_ID_SCOPE_KEY = 'zitadel_app_client_id';
|
|
219
|
+
/**
|
|
220
|
+
* #139: the `cms` app's own ZITADEL web client's `clientId`, recorded
|
|
221
|
+
* separately from {@link APP_CLIENT_ID_SCOPE_KEY} -- that key belongs to the
|
|
222
|
+
* org's own hosted app (`diff.ts`'s `planIssuerApps`), a different
|
|
223
|
+
* `zitadel:app` step from the fixed `cms` client `diff.ts`'s `planCmsClient`
|
|
224
|
+
* plans. `cmsSite` reads this key to push `AUTH_CLIENT_ID` onto the cms app,
|
|
225
|
+
* the same way `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` for the hosted
|
|
226
|
+
* app.
|
|
227
|
+
*/
|
|
228
|
+
const CMS_CLIENT_ID_SCOPE_KEY = 'cms_client_id';
|
|
229
|
+
/**
|
|
230
|
+
* `cmsSite` provisions two applications from one `cms:site` step, but
|
|
231
|
+
* `execute`'s own return value auto-records only one `resourceId`, under
|
|
232
|
+
* `cms:site:<orgId>` (`runSteps`'s own doc comment) -- the same one-step,
|
|
233
|
+
* two-resource shape `zitadelApp`/`ensureZitadelProject` already solve with
|
|
234
|
+
* a flat scope id (`zitadel_project`) alongside the per-key
|
|
235
|
+
* `zitadel:app:<key>` record. `cmsSite` returns the cms app's id as its
|
|
236
|
+
* `resourceId` (so it lands under `cms:site:<orgId>` same as any other
|
|
237
|
+
* step), and records both ids under these two flat keys instead of a
|
|
238
|
+
* `system:kind:key`-shaped one -- `recordScopeId`'s own doc comment requires
|
|
239
|
+
* a key with no `:`, precisely so a scope key can never collide with that
|
|
240
|
+
* form.
|
|
241
|
+
*/
|
|
242
|
+
const CMS_APP_SCOPE_KEY = 'coolify_cms_app';
|
|
243
|
+
const SITE_APP_SCOPE_KEY = 'coolify_site_app';
|
|
244
|
+
/**
|
|
245
|
+
* #117: the cms app's own Postgres database (`<org.slug>-cms-db`), found or
|
|
246
|
+
* created inside `cmsSite` the same way `coolifyDatabase` does for the org's
|
|
247
|
+
* own app -- but `cms:site`'s one step still auto-records only the cms app's
|
|
248
|
+
* id as its `resourceId` (see {@link CMS_APP_SCOPE_KEY}'s doc comment above),
|
|
249
|
+
* so this database's id has no `system:kind:key` slot of its own either.
|
|
250
|
+
* Recorded as a third flat scope id for the same reason: no `:`, so it can
|
|
251
|
+
* never collide with that form.
|
|
252
|
+
*/
|
|
253
|
+
const CMS_DATABASE_SCOPE_KEY = 'coolify_cms_database';
|
|
254
|
+
/**
|
|
255
|
+
* #138: the Resend domain's own id (`createDomain`/`findDomain`'s result,
|
|
256
|
+
* never a key or token) for this org's mail domain -- recorded so the app
|
|
257
|
+
* step (#140 rework) can scope its `<slug>-app@<domainId>` key to this domain without
|
|
258
|
+
* calling Resend a second time to look it up. No `system:kind:key` slot of
|
|
259
|
+
* its own for the same reason {@link CMS_DATABASE_SCOPE_KEY} has none:
|
|
260
|
+
* `coolify:mail`'s one step already auto-records `result.domain` as its
|
|
261
|
+
* `resourceId`.
|
|
262
|
+
*/
|
|
263
|
+
const RESEND_DOMAIN_ID_SCOPE_KEY = 'resend_domain_id';
|
|
264
|
+
/**
|
|
265
|
+
* #138/#140: the mail domain `registerResendDomain` verified with Resend --
|
|
266
|
+
* `org.domain` when the org uses its own domain, else `<slug>.<apex>` for a
|
|
267
|
+
* domainless org (2026-09-28: domainless orgs get their own Resend domain
|
|
268
|
+
* too). Recorded so the app step can derive `MAIL_FROM` without recomputing
|
|
269
|
+
* `mailOrgFor`'s own domain choice, the same way {@link APP_CLIENT_ID_SCOPE_KEY}
|
|
270
|
+
* carries a public value out.
|
|
271
|
+
*/
|
|
272
|
+
const RESEND_MAIL_DOMAIN_SCOPE_KEY = 'resend_mail_domain';
|
|
273
|
+
/**
|
|
274
|
+
* `coolifyCalls` provisions four resources from one `coolify:calls` step
|
|
275
|
+
* (#128) — the same one-step, several-resource shape `cmsSite` already solves
|
|
276
|
+
* with flat scope ids (`CMS_APP_SCOPE_KEY`/`SITE_APP_SCOPE_KEY`/
|
|
277
|
+
* `CMS_DATABASE_SCOPE_KEY`, that constant's own doc comment). This step's own
|
|
278
|
+
* auto-recorded `resourceId` (under `coolify:calls:<orgId>`) is the MAS app's
|
|
279
|
+
* id, the one every secret this stack mints lives on
|
|
280
|
+
* ({@link CALLS_SECRET_ENV_KEYS}); the other three get their own flat scope
|
|
281
|
+
* id here for the same reason CMS's site app and database do.
|
|
282
|
+
*/
|
|
283
|
+
const CALLS_SYNAPSE_SCOPE_KEY = 'coolify_calls_synapse';
|
|
284
|
+
const CALLS_MAS_SCOPE_KEY = 'coolify_calls_mas';
|
|
285
|
+
const CALLS_SYNAPSE_DB_SCOPE_KEY = 'coolify_calls_synapse_database';
|
|
286
|
+
const CALLS_MAS_DB_SCOPE_KEY = 'coolify_calls_mas_database';
|
|
287
|
+
/**
|
|
288
|
+
* Synapse refuses to start against a database whose `LC_COLLATE`/`LC_CTYPE`
|
|
289
|
+
* is not `C` (https://element-hq.github.io/synapse/latest/postgres.html);
|
|
290
|
+
* Coolify's official postgres image otherwise creates one with `en_US.utf8`.
|
|
291
|
+
* `postgres_initdb_args` is the only point this can be set -- it cannot be
|
|
292
|
+
* migrated onto an existing database. MAS has no such requirement, so this
|
|
293
|
+
* is passed for the Synapse database only. Not using Synapse's own
|
|
294
|
+
* `allow_unsafe_locale` escape hatch: that trades a loud startup refusal for
|
|
295
|
+
* silent index corruption after a future glibc upgrade.
|
|
296
|
+
*/
|
|
297
|
+
const CALLS_SYNAPSE_DB_INITDB_ARGS = '--locale=C --encoding=UTF8';
|
|
298
|
+
/**
|
|
299
|
+
* `CallsMatrixSecrets`' six fields, held nowhere else once minted
|
|
300
|
+
* (`calls-matrix.ts`'s own doc comment: no `ctx.outputs` state file exists
|
|
301
|
+
* here) — pushed onto the MAS app's own envs purely as memory for the next
|
|
302
|
+
* apply, never read by MAS itself (MAS reads only its `config.yaml` file
|
|
303
|
+
* storage). `multiline: true` on the two PEM keys, the same flag
|
|
304
|
+
* `CoolifyPort.bulkUpsertEnvs` already carries for exactly this shape of
|
|
305
|
+
* value.
|
|
306
|
+
*/
|
|
307
|
+
const CALLS_SECRET_ENV_KEYS = {
|
|
308
|
+
masSynapseSharedSecret: 'CALLS_MATRIX_MAS_SYNAPSE_SHARED_SECRET',
|
|
309
|
+
masClientId: 'CALLS_MATRIX_MAS_CLIENT_ID',
|
|
310
|
+
masClientSecret: 'CALLS_MATRIX_MAS_CLIENT_SECRET',
|
|
311
|
+
masEncryptionKey: 'CALLS_MATRIX_MAS_ENCRYPTION_KEY',
|
|
312
|
+
masRsaKeyPem: 'CALLS_MATRIX_MAS_RSA_KEY_PEM',
|
|
313
|
+
masEcKeyPem: 'CALLS_MATRIX_MAS_EC_KEY_PEM',
|
|
314
|
+
};
|
|
315
|
+
/** `FROM ${MAS_IMAGE}` plus the entry command its published image needs (chat's own `MAS_DOCKERFILE`, same reason: no arguments by default). */
|
|
316
|
+
const CALLS_MAS_DOCKERFILE = [
|
|
317
|
+
`FROM ${MAS_IMAGE}`,
|
|
318
|
+
'CMD ["server", "--config", "/config/config.yaml"]',
|
|
319
|
+
].join('\n');
|
|
320
|
+
/**
|
|
321
|
+
* `coolifyCallsService`'s own database (#145) -- no `system:kind:key` slot
|
|
322
|
+
* of its own, the same reason {@link CALLS_SYNAPSE_SCOPE_KEY}'s doc comment
|
|
323
|
+
* gives for the Matrix stack's two: this step's one auto-recorded
|
|
324
|
+
* `resourceId` (under `coolify:calls-service:<orgId>`) is the calls service
|
|
325
|
+
* app itself, not its database.
|
|
326
|
+
*/
|
|
327
|
+
const CALLS_SERVICE_DB_SCOPE_KEY = 'coolify_calls_service_database';
|
|
328
|
+
/** `wtfalch/calls`'s own repo -- `coolifyCallsService` deploys its `packages/service` (#145, part 2 of #128). */
|
|
329
|
+
const CALLS_SERVICE_REPO = 'wtfalch/calls';
|
|
330
|
+
/** `packages/service/Dockerfile`, built from the repo root (that Dockerfile's own top comment). */
|
|
331
|
+
const CALLS_SERVICE_DOCKERFILE = 'packages/service/Dockerfile';
|
|
332
|
+
/** The port `calls/packages/service/Dockerfile` documents (`EXPOSE 8080`) -- `config.mjs`'s own `PORT` has no default, so this must be pushed explicitly. */
|
|
333
|
+
const CALLS_SERVICE_PORT = '8080';
|
|
334
|
+
/**
|
|
335
|
+
* Whether an application's own Coolify `status` (`` `running:healthy`,
|
|
336
|
+
* `exited:unhealthy` `` -- `@wtfalch/providers`' own doc comment) means it is
|
|
337
|
+
* already up, or on its way there -- `findOrCreateCmsApp`'s own check of
|
|
338
|
+
* whether a found-but-parked app still needs `start`ing (#116 review round
|
|
339
|
+
* 3, Finding 1). Only `exited`/`stopped` (Coolify's own resting-off states;
|
|
340
|
+
* `stopped` is not one this codebase has verified against a live instance,
|
|
341
|
+
* included defensively) read as off; a missing `status` defaults to on, the
|
|
342
|
+
* same "nothing says it's off" convention `planHostingToggle` (`diff.ts`)
|
|
343
|
+
* uses for the org's own app.
|
|
344
|
+
*/
|
|
345
|
+
function isRunningOrStarting(status) {
|
|
346
|
+
if (!status)
|
|
347
|
+
return true;
|
|
348
|
+
return !status.startsWith('exited') && !status.startsWith('stopped');
|
|
349
|
+
}
|
|
50
350
|
// ---------------------------------------------------------------------------
|
|
51
351
|
// Executor
|
|
52
352
|
// ---------------------------------------------------------------------------
|
|
@@ -70,10 +370,18 @@ export class FoundryStepExecutor {
|
|
|
70
370
|
return this.coolifyApp(action, org, context);
|
|
71
371
|
case 'coolify:database':
|
|
72
372
|
return this.coolifyDatabase(action, org, context);
|
|
373
|
+
case 'coolify:calls':
|
|
374
|
+
return this.coolifyCalls(action, org, context);
|
|
375
|
+
case 'coolify:calls-service':
|
|
376
|
+
return this.coolifyCallsService(action, org, context);
|
|
73
377
|
case 'cloudflare:tunnel':
|
|
74
378
|
return this.cloudflareTunnel(action, org, context);
|
|
75
379
|
case 'cloudflare:zone':
|
|
76
|
-
return this.cloudflareZone(action);
|
|
380
|
+
return this.cloudflareZone(action, org);
|
|
381
|
+
case 'cloudflare:access':
|
|
382
|
+
return this.cloudflareAccess(org);
|
|
383
|
+
case 'cloudflare:ratelimit':
|
|
384
|
+
return this.cloudflareRateLimit(org);
|
|
77
385
|
case 'files:organisation':
|
|
78
386
|
return this.filesOrganisation(action, org);
|
|
79
387
|
case 'files:storage':
|
|
@@ -83,15 +391,106 @@ export class FoundryStepExecutor {
|
|
|
83
391
|
case 'ai:budget':
|
|
84
392
|
return this.aiBudget(action, org);
|
|
85
393
|
case 'coolify:mail':
|
|
86
|
-
|
|
394
|
+
return this.coolifyMail(action, org, context);
|
|
87
395
|
case 'coolify:chat':
|
|
88
396
|
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
397
|
case 'cms:site':
|
|
90
|
-
|
|
398
|
+
return this.cmsSite(action, org, context);
|
|
91
399
|
default:
|
|
92
400
|
throw new Error(`FoundryStepExecutor has no dispatch for ${action.system}:${action.kind}`);
|
|
93
401
|
}
|
|
94
402
|
}
|
|
403
|
+
/**
|
|
404
|
+
* `plan/diff.ts`'s `planAccess` (#84): puts the org's app hostname behind a
|
|
405
|
+
* Cloudflare Access app that lets only `hosting.accessEmails` through.
|
|
406
|
+
* foundry names its Access app `foundry:<orgId>` and reuses only an app with
|
|
407
|
+
* that name, so a re-run after a crash does not duplicate it. It refuses,
|
|
408
|
+
* rather than adopt, when another Access app already holds the hostname:
|
|
409
|
+
* destroy would later delete it. It also refuses when its own app carries
|
|
410
|
+
* any policy but foundry's, since that policy could let others in.
|
|
411
|
+
*/
|
|
412
|
+
async cloudflareAccess(org) {
|
|
413
|
+
const domain = hostnameFor(org);
|
|
414
|
+
const name = `foundry:${org.id}`;
|
|
415
|
+
const cf = this.ports.cloudflare;
|
|
416
|
+
const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
|
|
417
|
+
const foreign = onDomain.find((a) => a.name !== name);
|
|
418
|
+
if (foreign) {
|
|
419
|
+
throw new Error(`cloudflare:access for ${org.slug}: Access app '${foreign.name}' already holds ${domain}; foundry does not adopt it`);
|
|
420
|
+
}
|
|
421
|
+
const app = onDomain[0] ?? (await cf.createAccessApp({ name, domain, type: 'self_hosted' }));
|
|
422
|
+
const policies = await cf.listAccessPolicies(app.id);
|
|
423
|
+
const other = policies.find((p) => p.name !== ACCESS_POLICY_NAME);
|
|
424
|
+
if (other) {
|
|
425
|
+
throw new Error(`cloudflare:access for ${org.slug}: Access app ${app.id} has policy '${other.name}' that foundry did not create`);
|
|
426
|
+
}
|
|
427
|
+
if (policies.length === 0) {
|
|
428
|
+
await cf.createAccessPolicy(app.id, {
|
|
429
|
+
name: ACCESS_POLICY_NAME,
|
|
430
|
+
decision: 'allow',
|
|
431
|
+
include: org.hosting.accessEmails.map((email) => ({ email: { email } })),
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
return { resourceId: app.id };
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* `plan/diff.ts`'s `planRateLimit` (#43): one Cloudflare rate limiting
|
|
438
|
+
* rule, scoped to `SIGN_IN_PATH_EXPRESSION`, on the org's own Cloudflare
|
|
439
|
+
* zone -- only ever called for an own-domain org (`planRateLimit`'s own
|
|
440
|
+
* gate), so `org.domain` is set and that zone is the org's alone, never
|
|
441
|
+
* shared with another org the way `oikos.host` is.
|
|
442
|
+
*
|
|
443
|
+
* Rate limiting rules attach to a zone's `http_ratelimit` phase entry
|
|
444
|
+
* point ruleset, one `GET`/`PUT` pair per zone (`@wtfalch/providers` 0.5.0
|
|
445
|
+
* has no typed method for either -- `CloudflarePort.request`'s own doc
|
|
446
|
+
* comment) -- so "find by name" here means reading that one ruleset's
|
|
447
|
+
* `rules[]` and matching `RATE_LIMIT_RULE_DESCRIPTION`, the same shape
|
|
448
|
+
* `cloudflareAccess` above finds its own Access app by name. The Free
|
|
449
|
+
* plan allows exactly one rule per zone, so a rule already there under a
|
|
450
|
+
* different description is refused, never adopted or replaced, mirroring
|
|
451
|
+
* `cloudflareAccess`'s own refusal when a foreign Access app already
|
|
452
|
+
* holds the hostname.
|
|
453
|
+
*/
|
|
454
|
+
async cloudflareRateLimit(org) {
|
|
455
|
+
if (!org.domain) {
|
|
456
|
+
throw new Error(`cloudflare:ratelimit for ${org.slug}: hosting.usesDomain is set but domain is null`);
|
|
457
|
+
}
|
|
458
|
+
const cf = this.ports.cloudflare;
|
|
459
|
+
const zone = await cf.findZoneByApex(org.domain);
|
|
460
|
+
if (!zone) {
|
|
461
|
+
throw new Error(`cloudflare:ratelimit for ${org.slug}: no Cloudflare zone found for ${org.domain} -- cloudflare:zone must provision it first`);
|
|
462
|
+
}
|
|
463
|
+
const path = `/zones/${zone.id}/rulesets/phases/http_ratelimit/entrypoint`;
|
|
464
|
+
const existing = await cf.request('GET', path, undefined, true);
|
|
465
|
+
const rules = existing?.rules ?? [];
|
|
466
|
+
const own = rules.find((r) => r.description === RATE_LIMIT_RULE_DESCRIPTION);
|
|
467
|
+
if (own)
|
|
468
|
+
return { resourceId: own.id };
|
|
469
|
+
const foreign = rules.find((r) => r.description !== RATE_LIMIT_RULE_DESCRIPTION);
|
|
470
|
+
if (foreign) {
|
|
471
|
+
throw new Error(`cloudflare:ratelimit for ${org.slug}: zone ${org.domain} already has a rate limiting rule ('${foreign.description ?? foreign.id}') foundry did not create; the Free plan allows only one rule per zone, so foundry does not adopt or replace it`);
|
|
472
|
+
}
|
|
473
|
+
const updated = await cf.request('PUT', path, {
|
|
474
|
+
rules: [
|
|
475
|
+
{
|
|
476
|
+
description: RATE_LIMIT_RULE_DESCRIPTION,
|
|
477
|
+
expression: SIGN_IN_PATH_EXPRESSION,
|
|
478
|
+
action: 'block',
|
|
479
|
+
ratelimit: {
|
|
480
|
+
characteristics: ['cf.colo.id', 'ip.src'],
|
|
481
|
+
period: RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS,
|
|
482
|
+
requests_per_period: RATE_LIMIT_REQUESTS_PER_PERIOD,
|
|
483
|
+
mitigation_timeout: RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS,
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
],
|
|
487
|
+
});
|
|
488
|
+
const created = updated.rules?.[0];
|
|
489
|
+
if (!created) {
|
|
490
|
+
throw new Error(`cloudflare:ratelimit for ${org.slug}: PUT ${path} did not return the created rule`);
|
|
491
|
+
}
|
|
492
|
+
return { resourceId: created.id };
|
|
493
|
+
}
|
|
95
494
|
// ---- zitadel --------------------------------------------------------
|
|
96
495
|
async zitadelIssuer(action, org, context) {
|
|
97
496
|
if (action.desired.provision !== true) {
|
|
@@ -153,10 +552,39 @@ export class FoundryStepExecutor {
|
|
|
153
552
|
const projectId = await this.ensureZitadelProject(org, zitadelOrgId, context);
|
|
154
553
|
// A kind change deletes the recorded app then recreates it (task
|
|
155
554
|
// brief); the first-ever create for this key has nothing recorded, so
|
|
156
|
-
// this is a no-op then.
|
|
555
|
+
// this is a no-op then. A 404 here (already gone) is tolerated the same
|
|
556
|
+
// way `destroy` tolerates one -- a retry after the delete half already
|
|
557
|
+
// succeeded but the recreate half failed must find an already-deleted
|
|
558
|
+
// app a no-op, not a new error that wedges the apply forever.
|
|
157
559
|
const priorAppId = recorded[`zitadel:app:${action.key}`];
|
|
158
560
|
if (priorAppId) {
|
|
159
|
-
|
|
561
|
+
try {
|
|
562
|
+
await this.ports.zitadel.deleteApp(zitadelOrgId, projectId, priorAppId);
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
if (!isNotFoundError(error))
|
|
566
|
+
throw error;
|
|
567
|
+
}
|
|
568
|
+
// The recreate half is retried on its own failure (the delete above
|
|
569
|
+
// already tolerates the resulting 404), so a retry can also land here
|
|
570
|
+
// after an earlier attempt's create call actually succeeded against
|
|
571
|
+
// ZITADEL but whose response this executor never saw (a dropped
|
|
572
|
+
// connection, a killed process). That app is deleted, not reused: the
|
|
573
|
+
// lost response also lost its clientSecret and clientId, which
|
|
574
|
+
// `listApps` cannot return, so reusing it would leave the secret
|
|
575
|
+
// undelivered and the clientId unrecorded forever. The create below
|
|
576
|
+
// then runs every side effect. A first-ever create (no `priorAppId`)
|
|
577
|
+
// has nothing to find here and never pays this read.
|
|
578
|
+
const orphan = await this.findRecreatedApp(zitadelOrgId, projectId, action.key, appType);
|
|
579
|
+
if (orphan) {
|
|
580
|
+
try {
|
|
581
|
+
await this.ports.zitadel.deleteApp(zitadelOrgId, projectId, orphan.id);
|
|
582
|
+
}
|
|
583
|
+
catch (error) {
|
|
584
|
+
if (!isNotFoundError(error))
|
|
585
|
+
throw error;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
160
588
|
}
|
|
161
589
|
if (appType === 'api') {
|
|
162
590
|
const created = await this.ports.zitadel.createApiApp(zitadelOrgId, projectId, {
|
|
@@ -168,7 +596,13 @@ export class FoundryStepExecutor {
|
|
|
168
596
|
await this.ports.deliverSecret(`zitadel:app:${org.slug}:${action.key}`, created.clientSecret);
|
|
169
597
|
return { resourceId: created.appId };
|
|
170
598
|
}
|
|
171
|
-
|
|
599
|
+
// #139: the fixed `cms` client (`diff.ts`'s `planCmsClient`) signs
|
|
600
|
+
// people into the cms studio at `cms.<domain>`, not the org's own app
|
|
601
|
+
// hostname -- `org.site.enabled` guarantees `org.domain` is set
|
|
602
|
+
// (`org.ts`'s `validateSite`), the same guarantee `cmsHostnameFor`'s own
|
|
603
|
+
// doc comment relies on.
|
|
604
|
+
const isCmsClient = action.key === 'cms' && org.site.enabled;
|
|
605
|
+
const hostname = isCmsClient ? cmsHostnameFor(org) : hostnameFor(org);
|
|
172
606
|
// Native apps sign in via device code, with no redirect of their own
|
|
173
607
|
// (auth/scripts/provision.mjs:475-497, prior art cited in the decisions
|
|
174
608
|
// file) -- empty arrays rather than a web app's callback/logout URLs.
|
|
@@ -178,8 +612,31 @@ export class FoundryStepExecutor {
|
|
|
178
612
|
redirectUris: appType === 'native' ? [] : [`https://${hostname}/auth/callback`],
|
|
179
613
|
postLogoutRedirectUris: appType === 'native' ? [] : [`https://${hostname}/`],
|
|
180
614
|
});
|
|
615
|
+
if (appType === 'web') {
|
|
616
|
+
// `coolifyApp`'s own `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` to
|
|
617
|
+
// push AUTH_CLIENT_ID (level-1 app-env campaign, gap P2); `cmsSite`
|
|
618
|
+
// reads `CMS_CLIENT_ID_SCOPE_KEY` for the same env on the cms app
|
|
619
|
+
// (#139) -- public, not a secret, so a plain scope id, not
|
|
620
|
+
// `deliverSecret`. A native app signs in via device code with no
|
|
621
|
+
// cookie session of its own, so its clientId is never something an
|
|
622
|
+
// app's env needs.
|
|
623
|
+
context.recordScopeId(isCmsClient ? CMS_CLIENT_ID_SCOPE_KEY : APP_CLIENT_ID_SCOPE_KEY, created.clientId);
|
|
624
|
+
}
|
|
181
625
|
return { resourceId: created.appId };
|
|
182
626
|
}
|
|
627
|
+
/**
|
|
628
|
+
* The app an earlier attempt's recreate already made under the same
|
|
629
|
+
* name, whose response this executor never saw -- deleted before the
|
|
630
|
+
* create so no duplicate is left behind. Matches on name and the
|
|
631
|
+
* desired kind, and skips a removed app -- ZITADEL's
|
|
632
|
+
* search can return one that has since been deleted (`isNotFoundError`'s
|
|
633
|
+
* own doc comment covers why a delete can be retried into a 404), and
|
|
634
|
+
* that is not the survivor this is looking for.
|
|
635
|
+
*/
|
|
636
|
+
async findRecreatedApp(zitadelOrgId, projectId, name, appType) {
|
|
637
|
+
const apps = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
|
|
638
|
+
return apps.find((app) => app.name === name && app.appType === appType && app.state !== 'APP_STATE_REMOVED');
|
|
639
|
+
}
|
|
183
640
|
async ensureZitadelProject(org, zitadelOrgId, context) {
|
|
184
641
|
const recorded = context.recordedIds();
|
|
185
642
|
const existing = recorded.zitadel_project;
|
|
@@ -218,8 +675,10 @@ export class FoundryStepExecutor {
|
|
|
218
675
|
await this.ports.coolify.raw('POST', `/applications/${appId}/stop`);
|
|
219
676
|
return { resourceId: appId };
|
|
220
677
|
}
|
|
221
|
-
|
|
222
|
-
|
|
678
|
+
// A level-1 org with no bespoke app of its own gets the shared standard
|
|
679
|
+
// app instead (gap P1, `.plans/2026-09-26-level-1-org-map.md`), in the
|
|
680
|
+
// same repo shape `bespokeAppRepo` itself uses.
|
|
681
|
+
const appRepo = org.bespokeAppRepo ?? DEFAULT_APP_REPO;
|
|
223
682
|
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
224
683
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
225
684
|
const existing = await this.ports.coolify.findApplicationByName(org.slug, {
|
|
@@ -228,6 +687,16 @@ export class FoundryStepExecutor {
|
|
|
228
687
|
let appId;
|
|
229
688
|
if (existing) {
|
|
230
689
|
appId = existing.uuid;
|
|
690
|
+
// Found by name (rather than just created) is started back up if not
|
|
691
|
+
// already running or on its way there (#130, `findOrCreateCmsApp`'s
|
|
692
|
+
// own analogous fix): parking (the `enabled === false` branch above)
|
|
693
|
+
// stops the app without deleting it, so re-enabling found it here,
|
|
694
|
+
// still stopped, and it stayed stopped forever despite `apply`
|
|
695
|
+
// reporting success.
|
|
696
|
+
const live = await this.ports.coolify.getApplication(existing.uuid);
|
|
697
|
+
if (!isRunningOrStarting(live.status)) {
|
|
698
|
+
await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
|
|
699
|
+
}
|
|
231
700
|
}
|
|
232
701
|
else {
|
|
233
702
|
const server = await this.resolveServer();
|
|
@@ -238,11 +707,14 @@ export class FoundryStepExecutor {
|
|
|
238
707
|
environment_name: environment.name,
|
|
239
708
|
environment_uuid: environment.uuid,
|
|
240
709
|
github_app_uuid: githubApp.uuid,
|
|
241
|
-
git_repository:
|
|
710
|
+
git_repository: appRepo,
|
|
242
711
|
git_branch: org.hosting.gitBranch,
|
|
243
712
|
build_pack: org.hosting.buildPack,
|
|
244
713
|
name: org.slug,
|
|
245
714
|
domains: hostnameFor(org),
|
|
715
|
+
// Only the shared standard app builds from this repo; a bespoke
|
|
716
|
+
// app's Dockerfile is at its own repo's root.
|
|
717
|
+
dockerfile_location: org.bespokeAppRepo ? undefined : DEFAULT_APP_DOCKERFILE,
|
|
246
718
|
});
|
|
247
719
|
appId = created.uuid;
|
|
248
720
|
}
|
|
@@ -256,8 +728,59 @@ export class FoundryStepExecutor {
|
|
|
256
728
|
if (dbId)
|
|
257
729
|
await this.pushDatabaseUrl(appId, dbId);
|
|
258
730
|
}
|
|
731
|
+
// Every app keeps its own logs across a redeploy (#75). Unconditional,
|
|
732
|
+
// like the env push below: found by mount path before create, so a
|
|
733
|
+
// re-run (or the drift step `diff.ts`'s `planApp` plans for an app that
|
|
734
|
+
// predates this feature) never mounts a second volume. `existing` tells
|
|
735
|
+
// `ensureLogVolume` whether this app was already running before this
|
|
736
|
+
// call -- see its own doc comment for why that decides whether to
|
|
737
|
+
// restart it.
|
|
738
|
+
await this.ensureLogVolume(appId, org, Boolean(existing));
|
|
739
|
+
// The app receives whatever it needs to sign in (gap P2). Unconditional,
|
|
740
|
+
// unlike the database push above: every app has a hostname, and an
|
|
741
|
+
// upsert of the same value on a later apply is a no-op, not a change.
|
|
742
|
+
await this.pushAppEnv(appId, org, context);
|
|
743
|
+
// #140 rework: the hosted app's own Resend sending key and `MAIL_FROM`
|
|
744
|
+
// (only the app started by this same step, per the design's "only apps
|
|
745
|
+
// that send mail get it" -- cms and calls do not). A no-op until
|
|
746
|
+
// `coolify:mail` has actually registered a Resend domain for this org.
|
|
747
|
+
await this.ensureAppResendKey(appId, org, context, Boolean(existing));
|
|
259
748
|
return { resourceId: appId };
|
|
260
749
|
}
|
|
750
|
+
/**
|
|
751
|
+
* #75: mounts a persistent volume at {@link LOG_DIR} on the app, unless one
|
|
752
|
+
* is already there. Found by `mount_path`, not a recorded id — no
|
|
753
|
+
* `serviceIds` entry exists for a storage the way one does for the app or
|
|
754
|
+
* database itself, and Coolify's own listing is cheap enough to check
|
|
755
|
+
* first every time, the same way `coolifyDatabase` finds its database by
|
|
756
|
+
* name before creating one. Never called from the `enabled === false`
|
|
757
|
+
* (park) branch above, so parking an app never touches its volume either.
|
|
758
|
+
*
|
|
759
|
+
* Mounting a volume on an application already running does not attach it
|
|
760
|
+
* to the live container -- Coolify only binds a storage in at the
|
|
761
|
+
* container's next start (Finding, refuted against the first version of
|
|
762
|
+
* this method: it created the storage and let a passing `listStorages`
|
|
763
|
+
* read report `hasLogVolume: true` on the very next plan, with the
|
|
764
|
+
* container itself still writing nowhere). `wasAlreadyRunning` tells this
|
|
765
|
+
* method whether `appId` names an app `coolifyApp` found already there
|
|
766
|
+
* (restart it once the volume is created) or one it just created fresh
|
|
767
|
+
* (already about to get its first deploy/start from
|
|
768
|
+
* `createGithubAppApplication`'s own instant-deploy path, so a restart
|
|
769
|
+
* here would only be a redundant extra one).
|
|
770
|
+
*/
|
|
771
|
+
async ensureLogVolume(appId, org, wasAlreadyRunning) {
|
|
772
|
+
const storages = await this.ports.coolify.listStorages(appId);
|
|
773
|
+
const already = (storages.persistent_storages ?? []).some((s) => s.mount_path === LOG_DIR);
|
|
774
|
+
if (already)
|
|
775
|
+
return;
|
|
776
|
+
await this.ports.coolify.createPersistentStorage(appId, {
|
|
777
|
+
name: `${org.slug}-logs`,
|
|
778
|
+
mount_path: LOG_DIR,
|
|
779
|
+
});
|
|
780
|
+
if (wasAlreadyRunning) {
|
|
781
|
+
await this.ports.coolify.restart(appId);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
261
784
|
async coolifyDatabase(action, org, context) {
|
|
262
785
|
if (action.desired.enabled === false) {
|
|
263
786
|
const dbId = context.recordedIds()[`coolify:database:${action.key}`];
|
|
@@ -333,6 +856,811 @@ export class FoundryStepExecutor {
|
|
|
333
856
|
{ key: 'DATABASE_URL', value: db.internal_db_url },
|
|
334
857
|
]);
|
|
335
858
|
}
|
|
859
|
+
/**
|
|
860
|
+
* Pushes what the org's app needs to sign a user in through ZITADEL (gap
|
|
861
|
+
* P2, `.plans/2026-09-26-level-1-org-map.md`) -- a second `bulkUpsertEnvs`
|
|
862
|
+
* call rather than folding into `pushDatabaseUrl`'s, since this one runs
|
|
863
|
+
* unconditionally (`coolifyApp`) where that one runs only once
|
|
864
|
+
* `hosting.database` is declared and provisioned.
|
|
865
|
+
*
|
|
866
|
+
* `APP_URL` and `LOG_DIR` (#75) are pushed unconditionally; every other
|
|
867
|
+
* value is pushed only once its own source step has actually recorded it
|
|
868
|
+
* (`context.recordedIds()`) -- on a first-ever apply,
|
|
869
|
+
* run one step at a time in `diff.ts`'s `computePlanDetail` order, that is
|
|
870
|
+
* `zitadel:issuer` (`AUTH_ORGANIZATION_ID`), `zitadel:owner`
|
|
871
|
+
* (`OWNER_USER_ID`, gap P3) and every `zitadel:app` step
|
|
872
|
+
* (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
|
|
873
|
+
* reason. AUTH_APP_KEY (waits on an auth broker registration API) and
|
|
874
|
+
* DATABASE_URL_OWNER (a runtime-role split not built yet, `pushDatabaseUrl`'s
|
|
875
|
+
* own doc comment) are out of scope here (task brief).
|
|
876
|
+
*/
|
|
877
|
+
async pushAppEnv(appId, org, context) {
|
|
878
|
+
const recorded = context.recordedIds();
|
|
879
|
+
const envs = [
|
|
880
|
+
{ key: 'APP_URL', value: `https://${hostnameFor(org)}` },
|
|
881
|
+
// #75: read back by the package-side pino sink (not this repo's job,
|
|
882
|
+
// #75 "What belongs where") once `ensureLogVolume` has mounted the
|
|
883
|
+
// matching volume.
|
|
884
|
+
{ key: 'LOG_DIR', value: LOG_DIR },
|
|
885
|
+
];
|
|
886
|
+
const zitadelOrgId = recorded.zitadel_org;
|
|
887
|
+
if (zitadelOrgId)
|
|
888
|
+
envs.push({ key: 'AUTH_ORGANIZATION_ID', value: zitadelOrgId });
|
|
889
|
+
const clientId = recorded[APP_CLIENT_ID_SCOPE_KEY];
|
|
890
|
+
if (clientId)
|
|
891
|
+
envs.push({ key: 'AUTH_CLIENT_ID', value: clientId });
|
|
892
|
+
// The invited owner's ZITADEL user id (gap P3): the app uses it to make
|
|
893
|
+
// them its own owner in authz on first sign-in. `runSteps` records this
|
|
894
|
+
// under the same `system:kind:key` shape every step's `resourceId` uses
|
|
895
|
+
// (`zitadelOwner`'s own doc comment), not a scope id.
|
|
896
|
+
const ownerUserId = recorded[`zitadel:owner:${org.id}`];
|
|
897
|
+
if (ownerUserId)
|
|
898
|
+
envs.push({ key: 'OWNER_USER_ID', value: ownerUserId });
|
|
899
|
+
// Idempotent the same way `pushDatabaseUrl`'s credential is treated:
|
|
900
|
+
// generated once, handed straight to `bulkUpsertEnvs` and nowhere else in
|
|
901
|
+
// this class -- never returned, recorded, logged or interpolated into a
|
|
902
|
+
// thrown error.
|
|
903
|
+
const cookieSecret = await this.ensureAuthCookieSecret(appId);
|
|
904
|
+
if (cookieSecret)
|
|
905
|
+
envs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
|
|
906
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, envs);
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
|
|
910
|
+
* only the first time (task brief), never regenerated or overwritten, so a
|
|
911
|
+
* later apply's `bulkUpsertEnvs` call simply omits the key rather than
|
|
912
|
+
* upsert a fresh value over it.
|
|
913
|
+
*/
|
|
914
|
+
async ensureAuthCookieSecret(appId) {
|
|
915
|
+
const envs = await this.ports.coolify.listEnvs(appId);
|
|
916
|
+
const already = envs.some((e) => e.key === 'AUTH_COOKIE_SECRET' && !e.is_preview);
|
|
917
|
+
if (already)
|
|
918
|
+
return null;
|
|
919
|
+
return randomBytes(32).toString('base64');
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* #140 rework: the hosted app's own Resend sending key, pushed as
|
|
923
|
+
* `RESEND_API_KEY` straight into the app's env, the one place its token
|
|
924
|
+
* ever lives (Resend shows a key's token once, at creation). A no-op until
|
|
925
|
+
* `coolify:mail` has recorded a {@link RESEND_DOMAIN_ID_SCOPE_KEY} for this
|
|
926
|
+
* org; the next apply fills it in once `coolify:mail` runs first
|
|
927
|
+
* (`diff.ts`'s `computePlanDetail` order).
|
|
928
|
+
*
|
|
929
|
+
* The key is named `<slug>-app@<resendDomainId>`, so a key limited to a
|
|
930
|
+
* mail domain the org no longer uses (a `usesDomain` flip moves it between
|
|
931
|
+
* `org.domain` and `<slug>.<apex>`) never passes for the current one.
|
|
932
|
+
* `RESEND_API_KEY_ID` sits beside the token in the env -- an id, not a
|
|
933
|
+
* secret -- so the env can say which key its token belongs to.
|
|
934
|
+
*
|
|
935
|
+
* The mint rule (`.plans/2026-09-27-org-mail-resend-design.md` "One rule
|
|
936
|
+
* for minting"): healthy when the env holds a token and its
|
|
937
|
+
* `RESEND_API_KEY_ID` is the one key Resend lists under the current name.
|
|
938
|
+
* Otherwise one fresh key is minted and written to the env first, and only
|
|
939
|
+
* then is every other `<slug>-app@` key deleted -- a lost mint response, a
|
|
940
|
+
* duplicate, a hand-made key, and a previous domain's key all end the same
|
|
941
|
+
* way. An app that was already running is restarted before that delete,
|
|
942
|
+
* since a container reads its env only at start (`running`, the same
|
|
943
|
+
* reason {@link ensureLogVolume} restarts one). The restart is not
|
|
944
|
+
* awaited to completion, so the old container can briefly outlive its key.
|
|
945
|
+
*
|
|
946
|
+
* `MAIL_FROM` is upserted every time: it depends only on the mail domain
|
|
947
|
+
* `coolify:mail` resolved (`RESEND_MAIL_DOMAIN_SCOPE_KEY`).
|
|
948
|
+
*
|
|
949
|
+
* The token goes straight to `bulkUpsertEnvs` and nowhere else: never
|
|
950
|
+
* returned, never recorded in `serviceIds` (`recordScopeId`'s own doc
|
|
951
|
+
* comment), never logged, never interpolated into a thrown error -- the
|
|
952
|
+
* same discipline `pushDatabaseUrl`'s doc comment states for a database's
|
|
953
|
+
* own connection string.
|
|
954
|
+
*/
|
|
955
|
+
async ensureAppResendKey(appId, org, context, running) {
|
|
956
|
+
const recorded = context.recordedIds();
|
|
957
|
+
const domainId = recorded[RESEND_DOMAIN_ID_SCOPE_KEY];
|
|
958
|
+
if (!domainId)
|
|
959
|
+
return;
|
|
960
|
+
const prefix = `${org.slug}-app@`;
|
|
961
|
+
const keyName = `${prefix}${domainId}`;
|
|
962
|
+
const [ours, envs] = await Promise.all([
|
|
963
|
+
this.ports.resend.listApiKeys().then((keys) => keys.filter((k) => k.name.startsWith(prefix))),
|
|
964
|
+
this.ports.coolify.listEnvs(appId),
|
|
965
|
+
]);
|
|
966
|
+
const envValue = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
|
|
967
|
+
const current = ours.filter((k) => k.name === keyName);
|
|
968
|
+
const held = current.length === 1 ? current[0] : undefined;
|
|
969
|
+
const healthy = held !== undefined &&
|
|
970
|
+
Boolean(envValue('RESEND_API_KEY')) &&
|
|
971
|
+
envValue('RESEND_API_KEY_ID') === held.id;
|
|
972
|
+
const envUpdates = [];
|
|
973
|
+
let keep = healthy ? held?.id : undefined;
|
|
974
|
+
if (!healthy) {
|
|
975
|
+
const created = await this.ports.resend.createApiKey({
|
|
976
|
+
name: keyName,
|
|
977
|
+
permission: 'sending_access',
|
|
978
|
+
domainId,
|
|
979
|
+
});
|
|
980
|
+
keep = created.id;
|
|
981
|
+
envUpdates.push({ key: 'RESEND_API_KEY', value: created.token }, { key: 'RESEND_API_KEY_ID', value: created.id });
|
|
982
|
+
}
|
|
983
|
+
const mailDomain = recorded[RESEND_MAIL_DOMAIN_SCOPE_KEY];
|
|
984
|
+
if (mailDomain)
|
|
985
|
+
envUpdates.push({ key: 'MAIL_FROM', value: `no-reply@${mailDomain}` });
|
|
986
|
+
if (envUpdates.length > 0)
|
|
987
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, envUpdates);
|
|
988
|
+
// A running container read its env at start and still holds the old
|
|
989
|
+
// token, so a fresh key only reaches it through a restart -- before the
|
|
990
|
+
// old key is deleted below. A just-created app starts with the new env.
|
|
991
|
+
if (!healthy && running)
|
|
992
|
+
await this.ports.coolify.restart(appId);
|
|
993
|
+
for (const key of ours) {
|
|
994
|
+
if (key.id !== keep)
|
|
995
|
+
await this.ports.resend.deleteApiKey(key.id);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* `plan/diff.ts`'s `planSite` (#116, slicing #99's "provision the cms app,
|
|
1000
|
+
* database and site app per org"): the 2026-09-25 decision
|
|
1001
|
+
* (https://github.com/wtfalch/cms/blob/main/.plans/2026-09-25-org-sites.md)
|
|
1002
|
+
* means foundry never calls a cms admin API for the org's site -- it
|
|
1003
|
+
* provisions two ordinary Coolify applications from the one
|
|
1004
|
+
* https://github.com/wtfalch/cms repo, the same way `coolifyApp` above
|
|
1005
|
+
* provisions the org's own hosted app: a site app (`apps/site`, the
|
|
1006
|
+
* reference renderer) at `org.domain`, built from
|
|
1007
|
+
* `CMS_SITE_APP_DOCKERFILE`, and a cms app (`apps/host`, single-org mode,
|
|
1008
|
+
* https://github.com/wtfalch/cms/blob/main/docs/adr/0013-one-cms-per-org.md)
|
|
1009
|
+
* at {@link cmsHostnameFor}, built from the repo's own root
|
|
1010
|
+
* `CMS_APP_DOCKERFILE` -- one repo, two applications, told apart by
|
|
1011
|
+
* `dockerfile_location`.
|
|
1012
|
+
*
|
|
1013
|
+
* `action.key` is `org.id` (`planSite`'s own singleton key), so `execute`'s
|
|
1014
|
+
* own auto-record has only one `resourceId` slot for this step
|
|
1015
|
+
* (`cms:site:<orgId>`) -- returned here as the cms app's id, the resource
|
|
1016
|
+
* this step is named for. The site app's id has no such slot, so both ids
|
|
1017
|
+
* are also recorded as scope ids ({@link CMS_APP_SCOPE_KEY}/
|
|
1018
|
+
* {@link SITE_APP_SCOPE_KEY}), read back by the park branch below on a
|
|
1019
|
+
* later apply, when nothing gets provisioned to return one -- read
|
|
1020
|
+
* independently of each other there, since a crash partway through a
|
|
1021
|
+
* first-ever provision can leave only one of the two actually recorded.
|
|
1022
|
+
*
|
|
1023
|
+
* #117: this step also finds or creates the cms app's own Postgres
|
|
1024
|
+
* database ({@link ensureCmsDatabase}) and pushes its owner and runtime
|
|
1025
|
+
* connection strings ({@link ensureCmsRuntimeUrl}) onto the cms app, in the
|
|
1026
|
+
* same `bulkUpsertEnvs` call as `APP_URL`/`CMS_ORGANISATION_ID`/
|
|
1027
|
+
* `CMS_SITE_URL` below. The database's id is recorded as a third flat scope
|
|
1028
|
+
* id ({@link CMS_DATABASE_SCOPE_KEY}), for the same reason the two app ids
|
|
1029
|
+
* are: no `system:kind:key` slot of its own. Never touched from the park
|
|
1030
|
+
* branch above -- parking stops the two apps only, same as #116's own
|
|
1031
|
+
* destroy note; this step never parks or destroys the database.
|
|
1032
|
+
*/
|
|
1033
|
+
async cmsSite(action, org, context) {
|
|
1034
|
+
if (action.desired.enabled === false) {
|
|
1035
|
+
const recorded = context.recordedIds();
|
|
1036
|
+
const cmsAppId = recorded[CMS_APP_SCOPE_KEY];
|
|
1037
|
+
const siteAppId = recorded[SITE_APP_SCOPE_KEY];
|
|
1038
|
+
if (!cmsAppId && !siteAppId) {
|
|
1039
|
+
throw new Error(`cms:site for ${org.slug}: no live cms/site app recorded, nothing to park`);
|
|
1040
|
+
}
|
|
1041
|
+
// Stops whichever of the two is recorded rather than refusing the
|
|
1042
|
+
// whole step over the other -- a crash between recording the site
|
|
1043
|
+
// app's id and the cms app's own (`cmsSite`'s provisioning half,
|
|
1044
|
+
// above) must not leave a live app stuck running forever because park
|
|
1045
|
+
// always threw first.
|
|
1046
|
+
if (cmsAppId)
|
|
1047
|
+
await this.ports.coolify.raw('POST', `/applications/${cmsAppId}/stop`);
|
|
1048
|
+
if (siteAppId)
|
|
1049
|
+
await this.ports.coolify.raw('POST', `/applications/${siteAppId}/stop`);
|
|
1050
|
+
return { resourceId: cmsAppId ?? siteAppId };
|
|
1051
|
+
}
|
|
1052
|
+
// `validateSite` ("site.enabled requires domain") guarantees this
|
|
1053
|
+
// whenever this step runs -- checked anyway, the same way
|
|
1054
|
+
// `cloudflareRateLimit` above guards its own `org.domain` read, since
|
|
1055
|
+
// TypeScript's `string | null` gives no static guarantee of it.
|
|
1056
|
+
if (!org.domain) {
|
|
1057
|
+
throw new Error(`cms:site for ${org.slug}: site.enabled is set but domain is null`);
|
|
1058
|
+
}
|
|
1059
|
+
const domain = org.domain;
|
|
1060
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
1061
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
1062
|
+
const site = await this.findOrCreateCmsApp({
|
|
1063
|
+
projectUuid,
|
|
1064
|
+
environment,
|
|
1065
|
+
name: `${org.slug}-site`,
|
|
1066
|
+
dockerfileLocation: CMS_SITE_APP_DOCKERFILE,
|
|
1067
|
+
domain,
|
|
1068
|
+
});
|
|
1069
|
+
const cms = await this.findOrCreateCmsApp({
|
|
1070
|
+
projectUuid,
|
|
1071
|
+
environment,
|
|
1072
|
+
name: `${org.slug}-cms`,
|
|
1073
|
+
dockerfileLocation: CMS_APP_DOCKERFILE,
|
|
1074
|
+
domain: cmsHostnameFor(org),
|
|
1075
|
+
});
|
|
1076
|
+
// Every app foundry deploys keeps its own logs (#75) -- reused as-is for
|
|
1077
|
+
// both of these two, the same unconditional call `coolifyApp` makes for
|
|
1078
|
+
// its own one app.
|
|
1079
|
+
await this.ensureLogVolume(site.appId, org, site.existed);
|
|
1080
|
+
await this.ensureLogVolume(cms.appId, org, cms.existed);
|
|
1081
|
+
context.recordScopeId(SITE_APP_SCOPE_KEY, site.appId);
|
|
1082
|
+
context.recordScopeId(CMS_APP_SCOPE_KEY, cms.appId);
|
|
1083
|
+
// `@wtfalch/cms`'s own env contracts
|
|
1084
|
+
// (https://github.com/wtfalch/cms/blob/main/apps/host/.env.example and
|
|
1085
|
+
// .../apps/site/.env.example): the cms app runs in single-org mode
|
|
1086
|
+
// (`CMS_ORGANISATION_ID`) and needs the site's own URL to link back to;
|
|
1087
|
+
// the site app needs only the cms app's URL to read content from.
|
|
1088
|
+
//
|
|
1089
|
+
// Pushed here, before the database work below (#117 review round 2),
|
|
1090
|
+
// rather than folded into one later `bulkUpsertEnvs` call:
|
|
1091
|
+
// `ensureCmsDatabase`/`getDatabase` are deliberately left free to throw
|
|
1092
|
+
// (see that section's own comment) instead of caught, so a database
|
|
1093
|
+
// failure must not also cost the app these two calls already have
|
|
1094
|
+
// everything they need for.
|
|
1095
|
+
//
|
|
1096
|
+
// #139: `@wtfalch/cms`'s `AUTH_*` contract
|
|
1097
|
+
// (https://github.com/wtfalch/cms/blob/main/apps/host/.env.example),
|
|
1098
|
+
// pushed in this same call. `AUTH_ORGANIZATION_ID` and `AUTH_CLIENT_ID`
|
|
1099
|
+
// are pushed only once their own source step has recorded them --
|
|
1100
|
+
// `zitadel:issuer` and this org's `cms` `zitadel:app` step
|
|
1101
|
+
// (`diff.ts`'s `planCmsClient`), both ordered before `cms:site` for
|
|
1102
|
+
// exactly this reason, the same rule `pushAppEnv` follows for the
|
|
1103
|
+
// hosted app. `AUTH_COOKIE_SECRET` is generated once and never rotated
|
|
1104
|
+
// (`ensureAuthCookieSecret`'s own doc comment).
|
|
1105
|
+
const recorded = context.recordedIds();
|
|
1106
|
+
const cmsEnvs = [
|
|
1107
|
+
{ key: 'APP_URL', value: `https://${cmsHostnameFor(org)}` },
|
|
1108
|
+
{ key: 'CMS_ORGANISATION_ID', value: org.id },
|
|
1109
|
+
{ key: 'CMS_SITE_URL', value: `https://${domain}` },
|
|
1110
|
+
];
|
|
1111
|
+
if (recorded.zitadel_org) {
|
|
1112
|
+
cmsEnvs.push({ key: 'AUTH_ORGANIZATION_ID', value: recorded.zitadel_org });
|
|
1113
|
+
}
|
|
1114
|
+
const cmsClientId = recorded[CMS_CLIENT_ID_SCOPE_KEY];
|
|
1115
|
+
if (cmsClientId)
|
|
1116
|
+
cmsEnvs.push({ key: 'AUTH_CLIENT_ID', value: cmsClientId });
|
|
1117
|
+
const cookieSecret = await this.ensureAuthCookieSecret(cms.appId);
|
|
1118
|
+
if (cookieSecret)
|
|
1119
|
+
cmsEnvs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
|
|
1120
|
+
await this.ports.coolify.bulkUpsertEnvs(cms.appId, cmsEnvs);
|
|
1121
|
+
await this.ports.coolify.bulkUpsertEnvs(site.appId, [
|
|
1122
|
+
{ key: 'CMS_URL', value: `https://${cmsHostnameFor(org)}` },
|
|
1123
|
+
]);
|
|
1124
|
+
// #117: the cms app's own database, split into a migration-only
|
|
1125
|
+
// superuser role (`DATABASE_URL_OWNER`) and a restricted `cms_rt`
|
|
1126
|
+
// runtime role (`DATABASE_URL`) -- `ensureCmsDatabase`'s and
|
|
1127
|
+
// `ensureCmsRuntimeUrl`'s own doc comments. Neither call below is
|
|
1128
|
+
// wrapped in a try/catch: a failure here (Coolify down, a bad response)
|
|
1129
|
+
// must still fail this step, so it is visible in the apply's own output
|
|
1130
|
+
// and retried on the next one, rather than silently skipped -- the two
|
|
1131
|
+
// `bulkUpsertEnvs` calls above have already landed by the time either
|
|
1132
|
+
// could throw.
|
|
1133
|
+
const dbId = await this.ensureCmsDatabase(org, context, projectUuid, environment);
|
|
1134
|
+
context.recordScopeId(CMS_DATABASE_SCOPE_KEY, dbId);
|
|
1135
|
+
// Read back from Coolify's own record (`pushDatabaseUrl`'s own doc
|
|
1136
|
+
// comment on why: live state, not `createPostgresDatabase`'s return
|
|
1137
|
+
// value, since this run may have just found the database rather than
|
|
1138
|
+
// created it) -- both values are live credentials, handed straight to
|
|
1139
|
+
// `bulkUpsertEnvs` below and nowhere else in this class: never returned,
|
|
1140
|
+
// recorded, logged, or interpolated into a thrown error. Its own call,
|
|
1141
|
+
// separate from the two app-env calls above, since it has nothing to
|
|
1142
|
+
// push once `internal_db_url` is absent.
|
|
1143
|
+
const db = await this.ports.coolify.getDatabase(dbId);
|
|
1144
|
+
if (db.internal_db_url) {
|
|
1145
|
+
const dbEnvs = [
|
|
1146
|
+
{ key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
|
|
1147
|
+
];
|
|
1148
|
+
const runtimeUrl = await this.ensureCmsRuntimeUrl(cms.appId, db.internal_db_url);
|
|
1149
|
+
if (runtimeUrl)
|
|
1150
|
+
dbEnvs.push({ key: 'DATABASE_URL', value: runtimeUrl });
|
|
1151
|
+
await this.ports.coolify.bulkUpsertEnvs(cms.appId, dbEnvs);
|
|
1152
|
+
}
|
|
1153
|
+
return { resourceId: cms.appId };
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* `cmsSite`'s own find-or-create of a Postgres database for the cms app,
|
|
1157
|
+
* modeled on `coolifyDatabase` above: `ensureCoolifyProject` (already run
|
|
1158
|
+
* by `cmsSite` before this is called) and `findOrCreateEnvironment` give
|
|
1159
|
+
* the project/environment this reuses, then `listDatabases` finds one
|
|
1160
|
+
* already created by name before `createPostgresDatabase` makes one.
|
|
1161
|
+
* Named `<org.slug>-cms-db`, distinct from `coolifyDatabase`'s own
|
|
1162
|
+
* `<org.slug>-db`, so the two never collide in the same project for an org
|
|
1163
|
+
* that has both.
|
|
1164
|
+
*
|
|
1165
|
+
* `context.recordedIds()` is checked first, the same short-circuit
|
|
1166
|
+
* `coolifyDatabase` itself does not have (that step's own `resourceId` slot
|
|
1167
|
+
* already covers it) -- here it is load-bearing, since this database has
|
|
1168
|
+
* no `system:kind:key` slot of its own ({@link CMS_DATABASE_SCOPE_KEY}'s
|
|
1169
|
+
* doc comment) and `cms:site` runs on every apply once `org.site.enabled`,
|
|
1170
|
+
* not just the first.
|
|
1171
|
+
*
|
|
1172
|
+
* Destroying this database is out of scope here (#117, same as #116's own
|
|
1173
|
+
* destroy note for the cms/site apps).
|
|
1174
|
+
*/
|
|
1175
|
+
async ensureCmsDatabase(org, context, projectUuid, environment) {
|
|
1176
|
+
const existing = context.recordedIds()[CMS_DATABASE_SCOPE_KEY];
|
|
1177
|
+
if (existing)
|
|
1178
|
+
return existing;
|
|
1179
|
+
const name = `${org.slug}-cms-db`;
|
|
1180
|
+
const found = (await this.ports.coolify.listDatabases()).find((d) => d.name === name && d.environment_id === environment.id);
|
|
1181
|
+
if (found)
|
|
1182
|
+
return found.uuid;
|
|
1183
|
+
const server = await this.resolveServer();
|
|
1184
|
+
const dbName = name.replace(/-/g, '_');
|
|
1185
|
+
const created = await this.ports.coolify.createPostgresDatabase({
|
|
1186
|
+
project_uuid: projectUuid,
|
|
1187
|
+
server_uuid: server.uuid,
|
|
1188
|
+
environment_name: environment.name,
|
|
1189
|
+
environment_uuid: environment.uuid,
|
|
1190
|
+
name,
|
|
1191
|
+
postgres_db: dbName,
|
|
1192
|
+
postgres_user: dbName,
|
|
1193
|
+
});
|
|
1194
|
+
return created.uuid;
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* `null` once the cms app already has a `DATABASE_URL` -- never rotated,
|
|
1198
|
+
* the same rule `ensureAuthCookieSecret` follows for `AUTH_COOKIE_SECRET`,
|
|
1199
|
+
* and for the same kind of reason: `@wtfalch/cms`'s own
|
|
1200
|
+
* `apps/host/scripts/migrate.mjs` (`ensureRuntimeRole`) creates or updates
|
|
1201
|
+
* the live `cms_rt` Postgres role from exactly the username/password this
|
|
1202
|
+
* method mints, once, at container boot -- overwriting `DATABASE_URL` on a
|
|
1203
|
+
* later apply would leave that already-created role's real password out of
|
|
1204
|
+
* sync with whatever foundry hands the app next.
|
|
1205
|
+
*
|
|
1206
|
+
* The password is minted with `randomBytes` (the same import
|
|
1207
|
+
* `ensureAuthCookieSecret` already uses) and encoded `base64url` -- `+`/`/`
|
|
1208
|
+
* swapped for `-`/`_`, no `=` padding -- so it is already URL-safe in a
|
|
1209
|
+
* `postgres://` URL's userinfo segment with no further percent-encoding
|
|
1210
|
+
* needed. `dbInternalUrl` (`getDatabase`'s own `internal_db_url`) is parsed
|
|
1211
|
+
* with `URL` for its host, path and query string; its own superuser
|
|
1212
|
+
* username and password are discarded -- this mints a distinct, restricted
|
|
1213
|
+
* role's own credential, never reuses the superuser's. The query string
|
|
1214
|
+
* (`search`) is kept as-is and appended unchanged (#117 review round 2):
|
|
1215
|
+
* a parameter there (e.g. `?sslmode=require`) is a connection requirement
|
|
1216
|
+
* Coolify itself put on this database, not something specific to the
|
|
1217
|
+
* superuser role, so the migration (`DATABASE_URL_OWNER`) and runtime
|
|
1218
|
+
* (`DATABASE_URL`) connections must carry the same one.
|
|
1219
|
+
*
|
|
1220
|
+
* The password is a live credential: built here and handed straight to
|
|
1221
|
+
* `cmsSite`'s own `bulkUpsertEnvs` call and nowhere else in this class --
|
|
1222
|
+
* never returned, recorded, logged, or interpolated into a thrown error
|
|
1223
|
+
* (the same rule `pushDatabaseUrl`'s doc comment states). That rule is why
|
|
1224
|
+
* `URL`'s own parse failure is caught below rather than left to propagate:
|
|
1225
|
+
* nothing here states or controls the superuser password's alphabet
|
|
1226
|
+
* (Coolify's own, unlike the `cms_rt` one minted just below it), and
|
|
1227
|
+
* Node's `TypeError [ERR_INVALID_URL]` on an unescaped `/`, `#` or `?` in
|
|
1228
|
+
* it carries the *whole* input -- credential included -- on its own
|
|
1229
|
+
* `.input` property (never in `.message`, but still reachable by a logger
|
|
1230
|
+
* that inspects the error object, not just its message). Caught and
|
|
1231
|
+
* turned into "skip `DATABASE_URL` for this apply" instead: `cmsSite`
|
|
1232
|
+
* still pushes `DATABASE_URL_OWNER` and its other envs, and a later apply
|
|
1233
|
+
* retries this once `listEnvs` still shows no `DATABASE_URL`.
|
|
1234
|
+
*/
|
|
1235
|
+
async ensureCmsRuntimeUrl(appId, dbInternalUrl) {
|
|
1236
|
+
const envs = await this.ports.coolify.listEnvs(appId);
|
|
1237
|
+
const already = envs.some((e) => e.key === 'DATABASE_URL' && !e.is_preview);
|
|
1238
|
+
if (already)
|
|
1239
|
+
return null;
|
|
1240
|
+
let host;
|
|
1241
|
+
let pathname;
|
|
1242
|
+
let search;
|
|
1243
|
+
try {
|
|
1244
|
+
({ host, pathname, search } = new URL(dbInternalUrl));
|
|
1245
|
+
}
|
|
1246
|
+
catch {
|
|
1247
|
+
return null;
|
|
1248
|
+
}
|
|
1249
|
+
const password = randomBytes(24).toString('base64url');
|
|
1250
|
+
return `postgres://cms_rt:${password}@${host}${pathname}${search}`;
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* `cmsSite`'s own find-by-name-then-create flow for one of its two
|
|
1254
|
+
* applications -- the same shape `coolifyApp` above inlines for its own,
|
|
1255
|
+
* single, application, extracted here since `cmsSite` needs it twice in
|
|
1256
|
+
* one step. Both of `cmsSite`'s apps share `CMS_REPO`'s own default branch
|
|
1257
|
+
* (`main`) -- there is no per-org `gitBranch`/`buildPack` for this repo
|
|
1258
|
+
* the way `org.hosting` carries for the org's *own* app, since the cms
|
|
1259
|
+
* site is not that app.
|
|
1260
|
+
*
|
|
1261
|
+
* An app found by name (rather than just created) is started back up if
|
|
1262
|
+
* it is not already running or on its way there (#116 review round 3,
|
|
1263
|
+
* Finding 1): parking (`cmsSite`'s own `enabled: false` branch) stops both
|
|
1264
|
+
* apps without deleting them, so re-enabling finds them here, still
|
|
1265
|
+
* stopped, and `ProvidersCmsReader` would otherwise keep reporting
|
|
1266
|
+
* `enabled: false` forever -- the plan never converging back to on.
|
|
1267
|
+
* `coolifyApp`'s own analogous gap (it never restarted a found-but-stopped
|
|
1268
|
+
* app either) has since closed the same way, inline in that method (#130);
|
|
1269
|
+
* this method is `cmsSite`'s only caller.
|
|
1270
|
+
*/
|
|
1271
|
+
async findOrCreateCmsApp(input) {
|
|
1272
|
+
const existing = await this.ports.coolify.findApplicationByName(input.name, {
|
|
1273
|
+
environmentIds: [input.environment.id],
|
|
1274
|
+
});
|
|
1275
|
+
if (existing) {
|
|
1276
|
+
const live = await this.ports.coolify.getApplication(existing.uuid);
|
|
1277
|
+
if (!isRunningOrStarting(live.status)) {
|
|
1278
|
+
await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
|
|
1279
|
+
}
|
|
1280
|
+
return { appId: existing.uuid, existed: true };
|
|
1281
|
+
}
|
|
1282
|
+
const server = await this.resolveServer();
|
|
1283
|
+
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
1284
|
+
const created = await this.ports.coolify.createGithubAppApplication({
|
|
1285
|
+
project_uuid: input.projectUuid,
|
|
1286
|
+
server_uuid: server.uuid,
|
|
1287
|
+
environment_name: input.environment.name,
|
|
1288
|
+
environment_uuid: input.environment.uuid,
|
|
1289
|
+
github_app_uuid: githubApp.uuid,
|
|
1290
|
+
git_repository: CMS_REPO,
|
|
1291
|
+
git_branch: 'main',
|
|
1292
|
+
build_pack: 'dockerfile',
|
|
1293
|
+
name: input.name,
|
|
1294
|
+
domains: input.domain,
|
|
1295
|
+
dockerfile_location: input.dockerfileLocation,
|
|
1296
|
+
});
|
|
1297
|
+
return { appId: created.uuid, existed: false };
|
|
1298
|
+
}
|
|
1299
|
+
// ---- calls (#128) ------------------------------------------------------
|
|
1300
|
+
/**
|
|
1301
|
+
* `plan/diff.ts`'s `planCalls`: the org's own calls Matrix stack, entirely
|
|
1302
|
+
* separate from chat's (`calls-matrix.ts`'s own top doc comment) -- two
|
|
1303
|
+
* Coolify-managed Postgres databases, a Synapse app and an MAS app, no
|
|
1304
|
+
* public hostname of its own (neither app is reachable from outside
|
|
1305
|
+
* Coolify's own Docker network) -- `coolifyCallsService` below (#145, part 2
|
|
1306
|
+
* of #128) is the one that gets a public hostname, since it is the one a
|
|
1307
|
+
* browser actually talks to. Park stops Synapse and MAS only, the same
|
|
1308
|
+
* park-not-delete rule `coolifyApp`/`cmsSite` already follow (#7
|
|
1309
|
+
* corrections 1) -- their databases are never touched from that branch, the
|
|
1310
|
+
* same way `cmsSite`'s own database never is.
|
|
1311
|
+
*/
|
|
1312
|
+
async coolifyCalls(action, org, context) {
|
|
1313
|
+
if (action.desired.enabled === false) {
|
|
1314
|
+
const recorded = context.recordedIds();
|
|
1315
|
+
const synapseId = recorded[CALLS_SYNAPSE_SCOPE_KEY];
|
|
1316
|
+
const masId = recorded[CALLS_MAS_SCOPE_KEY];
|
|
1317
|
+
if (!synapseId && !masId) {
|
|
1318
|
+
throw new Error(`coolify:calls for ${org.slug}: no live calls stack recorded, nothing to park`);
|
|
1319
|
+
}
|
|
1320
|
+
if (synapseId)
|
|
1321
|
+
await this.ports.coolify.raw('POST', `/applications/${synapseId}/stop`);
|
|
1322
|
+
if (masId)
|
|
1323
|
+
await this.ports.coolify.raw('POST', `/applications/${masId}/stop`);
|
|
1324
|
+
return { resourceId: masId ?? synapseId };
|
|
1325
|
+
}
|
|
1326
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
1327
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
1328
|
+
const server = await this.resolveServer();
|
|
1329
|
+
const names = {
|
|
1330
|
+
synapse: `${org.slug}-calls-synapse`,
|
|
1331
|
+
mas: `${org.slug}-calls-mas`,
|
|
1332
|
+
synapseDb: `${org.slug}-calls-synapse-db`,
|
|
1333
|
+
masDb: `${org.slug}-calls-mas-db`,
|
|
1334
|
+
};
|
|
1335
|
+
const synapseDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.synapseDb, 'synapse', CALLS_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
|
|
1336
|
+
context.recordScopeId(CALLS_SYNAPSE_DB_SCOPE_KEY, synapseDbId);
|
|
1337
|
+
const masDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.masDb, 'mas', CALLS_MAS_DB_SCOPE_KEY);
|
|
1338
|
+
context.recordScopeId(CALLS_MAS_DB_SCOPE_KEY, masDbId);
|
|
1339
|
+
const [synapseDbRow, masDbRow] = await Promise.all([
|
|
1340
|
+
this.ports.coolify.getDatabase(synapseDbId),
|
|
1341
|
+
this.ports.coolify.getDatabase(masDbId),
|
|
1342
|
+
]);
|
|
1343
|
+
const synapseDb = synapseDbRow.internal_db_url
|
|
1344
|
+
? parseCallsDbUrl(synapseDbRow.internal_db_url)
|
|
1345
|
+
: null;
|
|
1346
|
+
const masDb = masDbRow.internal_db_url ? parseCallsDbUrl(masDbRow.internal_db_url) : null;
|
|
1347
|
+
if (!synapseDb || !masDb) {
|
|
1348
|
+
// Static config check before the two apps below are ever touched
|
|
1349
|
+
// (issue #128's "static config checks run before the first Coolify
|
|
1350
|
+
// call", carried from PR 344's review) -- Coolify can take a moment to
|
|
1351
|
+
// report a freshly created database's `internal_db_url`; the next
|
|
1352
|
+
// apply retries this from a clean, unstarted state rather than half
|
|
1353
|
+
// deploying Synapse or MAS against a connection string it does not have
|
|
1354
|
+
// yet.
|
|
1355
|
+
throw new Error(`coolify:calls for ${org.slug}: a database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
1356
|
+
}
|
|
1357
|
+
const synapse = await this.findOrCreateCallsApp(names.synapse, environment.id, () => this.ports.coolify.createDockerImageApplication({
|
|
1358
|
+
project_uuid: projectUuid,
|
|
1359
|
+
server_uuid: server.uuid,
|
|
1360
|
+
environment_name: environment.name,
|
|
1361
|
+
environment_uuid: environment.uuid,
|
|
1362
|
+
docker_registry_image_name: SYNAPSE_IMAGE,
|
|
1363
|
+
docker_registry_image_tag: SYNAPSE_TAG,
|
|
1364
|
+
ports_exposes: '8008',
|
|
1365
|
+
name: names.synapse,
|
|
1366
|
+
description: `Synapse for ${org.slug}'s calls stack (${SYNAPSE_IMAGE}:${SYNAPSE_TAG})`,
|
|
1367
|
+
instant_deploy: false,
|
|
1368
|
+
}));
|
|
1369
|
+
if (!synapse.existed) {
|
|
1370
|
+
await this.ports.coolify.updateApplication(synapse.appId, {
|
|
1371
|
+
health_check_enabled: false,
|
|
1372
|
+
custom_internal_name: names.synapse,
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
context.recordScopeId(CALLS_SYNAPSE_SCOPE_KEY, synapse.appId);
|
|
1376
|
+
const mas = await this.findOrCreateCallsApp(names.mas, environment.id, () => this.ports.coolify.createDockerfileApplication({
|
|
1377
|
+
project_uuid: projectUuid,
|
|
1378
|
+
server_uuid: server.uuid,
|
|
1379
|
+
environment_name: environment.name,
|
|
1380
|
+
environment_uuid: environment.uuid,
|
|
1381
|
+
dockerfile: CALLS_MAS_DOCKERFILE,
|
|
1382
|
+
name: names.mas,
|
|
1383
|
+
description: `matrix-authentication-service for ${org.slug}'s calls stack`,
|
|
1384
|
+
instant_deploy: false,
|
|
1385
|
+
}));
|
|
1386
|
+
if (!mas.existed) {
|
|
1387
|
+
await this.ports.coolify.updateApplication(mas.appId, {
|
|
1388
|
+
health_check_enabled: false,
|
|
1389
|
+
custom_internal_name: names.mas,
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
context.recordScopeId(CALLS_MAS_SCOPE_KEY, mas.appId);
|
|
1393
|
+
// Synapse's own signing key, media store and pid file (#75's own
|
|
1394
|
+
// durable-volume convention, reused here for a different reason: this
|
|
1395
|
+
// volume is Synapse's own state, not a log).
|
|
1396
|
+
const synapseStorages = await this.ports.coolify.listStorages(synapse.appId);
|
|
1397
|
+
if (!(synapseStorages.persistent_storages ?? []).some((s) => s.mount_path === '/data')) {
|
|
1398
|
+
await this.ports.coolify.createPersistentStorage(synapse.appId, {
|
|
1399
|
+
name: `${names.synapse}-data`,
|
|
1400
|
+
mount_path: '/data',
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
const secrets = await this.ensureCallsMatrixSecrets(mas.appId);
|
|
1404
|
+
await this.ensureCallsFileStorage(synapse.appId, '/data/homeserver.yaml', callsSynapseHomeserverYaml({
|
|
1405
|
+
orgSlug: org.slug,
|
|
1406
|
+
db: synapseDb,
|
|
1407
|
+
masHost: names.mas,
|
|
1408
|
+
masSharedSecret: secrets.value.masSynapseSharedSecret,
|
|
1409
|
+
}));
|
|
1410
|
+
// Synapse's official image reads `/data/homeserver.yaml` by default, but
|
|
1411
|
+
// it also needs write access to the mounted volume it does not own by
|
|
1412
|
+
// default (`calls/proof/provider/scripts/guest-stack.mjs`'s own working
|
|
1413
|
+
// config carries the same three envs).
|
|
1414
|
+
await this.ports.coolify.bulkUpsertEnvs(synapse.appId, [
|
|
1415
|
+
{ key: 'SYNAPSE_CONFIG_PATH', value: '/data/homeserver.yaml' },
|
|
1416
|
+
{ key: 'UID', value: '0' },
|
|
1417
|
+
{ key: 'GID', value: '0' },
|
|
1418
|
+
]);
|
|
1419
|
+
await this.ensureCallsFileStorage(mas.appId, '/config/config.yaml', callsMasConfigYaml({
|
|
1420
|
+
orgSlug: org.slug,
|
|
1421
|
+
db: masDb,
|
|
1422
|
+
synapseHost: names.synapse,
|
|
1423
|
+
sharedSecret: secrets.value.masSynapseSharedSecret,
|
|
1424
|
+
clientId: secrets.value.masClientId,
|
|
1425
|
+
clientSecret: secrets.value.masClientSecret,
|
|
1426
|
+
encryptionKey: secrets.value.masEncryptionKey,
|
|
1427
|
+
rsaKeyPem: secrets.value.masRsaKeyPem,
|
|
1428
|
+
ecKeyPem: secrets.value.masEcKeyPem,
|
|
1429
|
+
}));
|
|
1430
|
+
// Issue #128's "a running app is redeployed when its config changed"
|
|
1431
|
+
// (PR 344's review fix): a file-storage write alone never reaches a
|
|
1432
|
+
// running container until its next deploy, but redeploying an unchanged,
|
|
1433
|
+
// already-running app on every apply would mean this step never
|
|
1434
|
+
// converges. A brand new app or a freshly minted secret set are the only
|
|
1435
|
+
// two reasons the file just written could differ from what the container
|
|
1436
|
+
// already has -- reusing existing secrets means byte-identical content.
|
|
1437
|
+
if (!synapse.existed || secrets.changed)
|
|
1438
|
+
await this.ports.coolify.deploy(synapse.appId);
|
|
1439
|
+
if (!mas.existed || secrets.changed)
|
|
1440
|
+
await this.ports.coolify.deploy(mas.appId);
|
|
1441
|
+
return { resourceId: mas.appId };
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* `coolifyCalls`'s own find-or-create for Synapse/MAS, restarting a
|
|
1445
|
+
* found-but-stopped app (#116 review round 3, Finding 1's same fix). Reused
|
|
1446
|
+
* by `coolifyCallsService` (#145) for the calls service app itself -- the
|
|
1447
|
+
* same find-by-name-then-start-or-create shape applies unchanged.
|
|
1448
|
+
*/
|
|
1449
|
+
async findOrCreateCallsApp(name, environmentId, create) {
|
|
1450
|
+
const existing = await this.ports.coolify.findApplicationByName(name, {
|
|
1451
|
+
environmentIds: [environmentId],
|
|
1452
|
+
});
|
|
1453
|
+
if (existing) {
|
|
1454
|
+
const live = await this.ports.coolify.getApplication(existing.uuid);
|
|
1455
|
+
if (!isRunningOrStarting(live.status)) {
|
|
1456
|
+
await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
|
|
1457
|
+
}
|
|
1458
|
+
return { appId: existing.uuid, existed: true };
|
|
1459
|
+
}
|
|
1460
|
+
const created = await create();
|
|
1461
|
+
return { appId: created.uuid, existed: false };
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* `coolifyCalls`'s own database find-or-create, modeled on
|
|
1465
|
+
* `ensureCmsDatabase`: `context.recordedIds()` is checked first since
|
|
1466
|
+
* neither database has a `system:kind:key` slot of its own (this step's one
|
|
1467
|
+
* auto-recorded `resourceId` is the MAS app, not either database --
|
|
1468
|
+
* {@link CALLS_SYNAPSE_SCOPE_KEY}'s own doc comment), then `listDatabases`
|
|
1469
|
+
* finds one already created by name before `createPostgresDatabase` makes
|
|
1470
|
+
* one. Reused by `coolifyCallsService` (#145) for the calls service's own
|
|
1471
|
+
* database, whose {@link CALLS_SERVICE_DB_SCOPE_KEY} has no
|
|
1472
|
+
* `system:kind:key` slot of its own for the same reason.
|
|
1473
|
+
*/
|
|
1474
|
+
async ensureCallsDatabase(context, projectUuid, environment, name, postgresDb, scopeKey, postgresInitdbArgs) {
|
|
1475
|
+
const existing = context.recordedIds()[scopeKey];
|
|
1476
|
+
if (existing)
|
|
1477
|
+
return existing;
|
|
1478
|
+
const found = (await this.ports.coolify.listDatabases()).find((d) => d.name === name && d.environment_id === environment.id);
|
|
1479
|
+
if (found)
|
|
1480
|
+
return found.uuid;
|
|
1481
|
+
const server = await this.resolveServer();
|
|
1482
|
+
const created = await this.ports.coolify.createPostgresDatabase({
|
|
1483
|
+
project_uuid: projectUuid,
|
|
1484
|
+
server_uuid: server.uuid,
|
|
1485
|
+
environment_name: environment.name,
|
|
1486
|
+
environment_uuid: environment.uuid,
|
|
1487
|
+
name,
|
|
1488
|
+
postgres_db: postgresDb,
|
|
1489
|
+
postgres_user: postgresDb,
|
|
1490
|
+
...(postgresInitdbArgs ? { postgres_initdb_args: postgresInitdbArgs } : {}),
|
|
1491
|
+
});
|
|
1492
|
+
return created.uuid;
|
|
1493
|
+
}
|
|
1494
|
+
/**
|
|
1495
|
+
* Reads back {@link CALLS_SECRET_ENV_KEYS} from the MAS app's own envs
|
|
1496
|
+
* before minting fresh ones -- this stack's only memory across a re-run
|
|
1497
|
+
* (`calls-matrix.ts`'s `CallsMatrixSecrets` doc comment). `changed: true`
|
|
1498
|
+
* only when nothing usable was found: a partial set (a crash mid-mint) is
|
|
1499
|
+
* still treated as "mint fresh", since MAS's shared secret and Synapse's
|
|
1500
|
+
* copy of it must always agree.
|
|
1501
|
+
*/
|
|
1502
|
+
async ensureCallsMatrixSecrets(masAppId) {
|
|
1503
|
+
const envs = await this.ports.coolify.listEnvs(masAppId);
|
|
1504
|
+
const read = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
|
|
1505
|
+
const fields = Object.entries(CALLS_SECRET_ENV_KEYS);
|
|
1506
|
+
const existing = {};
|
|
1507
|
+
for (const [field, envKey] of fields) {
|
|
1508
|
+
const value = read(envKey);
|
|
1509
|
+
if (value !== undefined)
|
|
1510
|
+
existing[field] = value;
|
|
1511
|
+
}
|
|
1512
|
+
const hasAll = fields.every(([field]) => existing[field] !== undefined);
|
|
1513
|
+
if (hasAll)
|
|
1514
|
+
return { value: existing, changed: false };
|
|
1515
|
+
const minted = mintCallsMatrixSecrets();
|
|
1516
|
+
await this.ports.coolify.bulkUpsertEnvs(masAppId, fields.map(([field, envKey]) => ({
|
|
1517
|
+
key: envKey,
|
|
1518
|
+
value: minted[field],
|
|
1519
|
+
multiline: field === 'masRsaKeyPem' || field === 'masEcKeyPem',
|
|
1520
|
+
})));
|
|
1521
|
+
return { value: minted, changed: true };
|
|
1522
|
+
}
|
|
1523
|
+
/**
|
|
1524
|
+
* Upserts a file storage's content, creating it if the application has none
|
|
1525
|
+
* at that mount path yet -- there is no way to read a file storage's
|
|
1526
|
+
* current content back from Coolify to diff it, the same limit
|
|
1527
|
+
* `archon/bootstrap/steps/chat-instance.ts`'s own `ensureFileStorage` notes,
|
|
1528
|
+
* so an existing one is always re-written.
|
|
1529
|
+
*/
|
|
1530
|
+
async ensureCallsFileStorage(applicationUuid, mountPath, content) {
|
|
1531
|
+
const storages = await this.ports.coolify.listStorages(applicationUuid);
|
|
1532
|
+
const found = storages.file_storages?.find((s) => s.mount_path === mountPath);
|
|
1533
|
+
if (found) {
|
|
1534
|
+
await this.ports.coolify.updateFileStorage(applicationUuid, found.uuid, content);
|
|
1535
|
+
}
|
|
1536
|
+
else {
|
|
1537
|
+
await this.ports.coolify.createFileStorage(applicationUuid, {
|
|
1538
|
+
mount_path: mountPath,
|
|
1539
|
+
content,
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
// ---- calls service (#145, part 2 of #128) -------------------------------
|
|
1544
|
+
/**
|
|
1545
|
+
* `plan/diff.ts`'s `planCallsService`: the org's own calls service --
|
|
1546
|
+
* `wtfalch/calls`'s `packages/service`, wired to the calls Matrix stack
|
|
1547
|
+
* {@link coolifyCalls} provisions and the shared LiveKit SFU (#122/#127).
|
|
1548
|
+
* Static config checks run first, before any Coolify call (#128's own
|
|
1549
|
+
* carried-over PR 344 review fix, restated in #145's own issue body): the
|
|
1550
|
+
* calls Matrix stack must already be recorded, its MAS app must already
|
|
1551
|
+
* carry its client id/secret, and the shared SFU's LiveKit credentials must
|
|
1552
|
+
* already be resolvable -- none of the three is minted or guessed here.
|
|
1553
|
+
* Park stops the app only, the same park-not-delete rule
|
|
1554
|
+
* {@link coolifyCalls}/{@link coolifyApp} already follow (#7 corrections
|
|
1555
|
+
* 1); its own database is never touched from that branch.
|
|
1556
|
+
*/
|
|
1557
|
+
async coolifyCallsService(action, org, context) {
|
|
1558
|
+
if (action.desired.enabled === false) {
|
|
1559
|
+
const appId = context.recordedIds()[`coolify:calls-service:${action.key}`];
|
|
1560
|
+
if (!appId) {
|
|
1561
|
+
throw new Error(`coolify:calls-service for ${org.slug}: no live app recorded, nothing to park`);
|
|
1562
|
+
}
|
|
1563
|
+
await this.ports.coolify.raw('POST', `/applications/${appId}/stop`);
|
|
1564
|
+
return { resourceId: appId };
|
|
1565
|
+
}
|
|
1566
|
+
const recorded = context.recordedIds();
|
|
1567
|
+
const synapseId = recorded[CALLS_SYNAPSE_SCOPE_KEY];
|
|
1568
|
+
const masId = recorded[CALLS_MAS_SCOPE_KEY];
|
|
1569
|
+
if (!synapseId || !masId) {
|
|
1570
|
+
throw new Error(`coolify:calls-service for ${org.slug}: the calls Matrix stack has not been provisioned yet -- coolify:calls must run first`);
|
|
1571
|
+
}
|
|
1572
|
+
const masCredentials = await this.readCallsMasClientCredentials(masId);
|
|
1573
|
+
if (!masCredentials) {
|
|
1574
|
+
throw new Error(`coolify:calls-service for ${org.slug}: the calls Matrix stack's MAS client credentials are not on the MAS app yet -- retry this apply once coolify:calls has minted them`);
|
|
1575
|
+
}
|
|
1576
|
+
const sfu = await this.ports.callsSfu.resolveCredentials();
|
|
1577
|
+
if (!sfu) {
|
|
1578
|
+
throw new Error(`coolify:calls-service for ${org.slug}: the shared calls SFU has no LiveKit credentials configured yet -- see https://github.com/wtfalch/foundry/issues/122`);
|
|
1579
|
+
}
|
|
1580
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
1581
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
1582
|
+
const name = `${org.slug}-calls`;
|
|
1583
|
+
const synapseHost = `${org.slug}-calls-synapse`;
|
|
1584
|
+
const masHost = `${org.slug}-calls-mas`;
|
|
1585
|
+
const dbName = `${name}-db`;
|
|
1586
|
+
const dbId = await this.ensureCallsDatabase(context, projectUuid, environment, dbName, dbName.replace(/-/g, '_'), CALLS_SERVICE_DB_SCOPE_KEY);
|
|
1587
|
+
context.recordScopeId(CALLS_SERVICE_DB_SCOPE_KEY, dbId);
|
|
1588
|
+
const db = await this.ports.coolify.getDatabase(dbId);
|
|
1589
|
+
if (!db.internal_db_url) {
|
|
1590
|
+
throw new Error(`coolify:calls-service for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
1591
|
+
}
|
|
1592
|
+
const server = await this.resolveServer();
|
|
1593
|
+
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
1594
|
+
const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
1595
|
+
project_uuid: projectUuid,
|
|
1596
|
+
server_uuid: server.uuid,
|
|
1597
|
+
environment_name: environment.name,
|
|
1598
|
+
environment_uuid: environment.uuid,
|
|
1599
|
+
github_app_uuid: githubApp.uuid,
|
|
1600
|
+
git_repository: CALLS_SERVICE_REPO,
|
|
1601
|
+
git_branch: 'main',
|
|
1602
|
+
build_pack: 'dockerfile',
|
|
1603
|
+
name,
|
|
1604
|
+
domains: callsHostnameFor(org),
|
|
1605
|
+
dockerfile_location: CALLS_SERVICE_DOCKERFILE,
|
|
1606
|
+
}));
|
|
1607
|
+
// Every app foundry deploys keeps its own logs (#75), reused as-is here.
|
|
1608
|
+
await this.ensureLogVolume(app.appId, org, app.existed);
|
|
1609
|
+
const bindingSecret = await this.ensureCallsBindingSecret(app.appId);
|
|
1610
|
+
const envs = [
|
|
1611
|
+
{ key: 'DATABASE_URL', value: db.internal_db_url },
|
|
1612
|
+
{ key: 'LIVEKIT_URL', value: CALLS_SFU_PRIVATE_URL },
|
|
1613
|
+
{ key: 'LIVEKIT_PUBLIC_URL', value: CALLS_SFU_PUBLIC_URL },
|
|
1614
|
+
{ key: 'LIVEKIT_API_KEY', value: sfu.apiKey },
|
|
1615
|
+
{ key: 'LIVEKIT_API_SECRET', value: sfu.apiSecret },
|
|
1616
|
+
{ key: 'MAS_URL', value: `http://${masHost}:8080/` },
|
|
1617
|
+
{ key: 'MAS_HOMESERVER', value: callsServerName(org.slug) },
|
|
1618
|
+
{ key: 'MAS_CLIENT_ID', value: masCredentials.clientId },
|
|
1619
|
+
{ key: 'MAS_CLIENT_SECRET', value: masCredentials.clientSecret },
|
|
1620
|
+
{ key: 'SYNAPSE_URL', value: `http://${synapseHost}:8008` },
|
|
1621
|
+
{ key: 'CALLS_ORIGIN', value: `https://${callsHostnameFor(org)}` },
|
|
1622
|
+
{ key: 'CALLS_HOST_ADAPTER', value: 'production' },
|
|
1623
|
+
{ key: 'CALLS_TRUSTED_IP_HEADER', value: 'cf-connecting-ip' },
|
|
1624
|
+
{ key: 'PORT', value: CALLS_SERVICE_PORT },
|
|
1625
|
+
];
|
|
1626
|
+
if (bindingSecret)
|
|
1627
|
+
envs.push({ key: 'CALLS_BINDING_SECRET', value: bindingSecret });
|
|
1628
|
+
await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
|
|
1629
|
+
return { resourceId: app.appId };
|
|
1630
|
+
}
|
|
1631
|
+
/**
|
|
1632
|
+
* Reads back the calls Matrix stack's MAS client id/secret from the MAS
|
|
1633
|
+
* app's own envs ({@link CALLS_SECRET_ENV_KEYS}) -- never mints: minting
|
|
1634
|
+
* either is {@link ensureCallsMatrixSecrets}'s own job, and a missing value
|
|
1635
|
+
* here means that stack has not finished provisioning yet, not something
|
|
1636
|
+
* this step can fix on its own.
|
|
1637
|
+
*/
|
|
1638
|
+
async readCallsMasClientCredentials(masAppId) {
|
|
1639
|
+
const envs = await this.ports.coolify.listEnvs(masAppId);
|
|
1640
|
+
const read = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
|
|
1641
|
+
const clientId = read(CALLS_SECRET_ENV_KEYS.masClientId);
|
|
1642
|
+
const clientSecret = read(CALLS_SECRET_ENV_KEYS.masClientSecret);
|
|
1643
|
+
if (!clientId || !clientSecret)
|
|
1644
|
+
return null;
|
|
1645
|
+
return { clientId, clientSecret };
|
|
1646
|
+
}
|
|
1647
|
+
/**
|
|
1648
|
+
* `null` once the calls service app already has a `CALLS_BINDING_SECRET` --
|
|
1649
|
+
* never rotated, the same rule {@link ensureAuthCookieSecret} follows for
|
|
1650
|
+
* `AUTH_COOKIE_SECRET`: it wraps the provider-token ciphertext already
|
|
1651
|
+
* stored under any binding minted so far
|
|
1652
|
+
* (`calls/packages/service/src/config.mjs`'s own doc comment on
|
|
1653
|
+
* `CALLS_BINDING_SECRET`), so overwriting it would strand every binding
|
|
1654
|
+
* minted under the old value. 32 raw bytes, hex-encoded -- the exact shape
|
|
1655
|
+
* `config.mjs`'s own regex requires.
|
|
1656
|
+
*/
|
|
1657
|
+
async ensureCallsBindingSecret(appId) {
|
|
1658
|
+
const envs = await this.ports.coolify.listEnvs(appId);
|
|
1659
|
+
const already = envs.some((e) => e.key === 'CALLS_BINDING_SECRET' && !e.is_preview);
|
|
1660
|
+
if (already)
|
|
1661
|
+
return null;
|
|
1662
|
+
return randomBytes(32).toString('hex');
|
|
1663
|
+
}
|
|
336
1664
|
// ---- cloudflare --------------------------------------------------------
|
|
337
1665
|
async cloudflareTunnel(action, org, context) {
|
|
338
1666
|
if (action.desired.enabled === false) {
|
|
@@ -350,11 +1678,11 @@ export class FoundryStepExecutor {
|
|
|
350
1678
|
const tunnelName = `${org.slug}-tunnel`;
|
|
351
1679
|
const existing = await this.ports.cloudflare.findTunnelByName(tunnelName);
|
|
352
1680
|
const tunnel = existing ?? (await this.ports.cloudflare.createTunnel(tunnelName));
|
|
353
|
-
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
1681
|
+
// Routes each of the org's hostnames (`tunnelIngressHostnames`) to
|
|
1682
|
+
// Coolify's own Traefik reverse proxy, not a per-app container address --
|
|
1683
|
+
// `COOLIFY_PROXY_ORIGIN`, the same constant name and value
|
|
1684
|
+
// `wtfalch/app-template`'s prior art used (`bootstrap/steps/cf-tunnel.ts`,
|
|
1685
|
+
// offline.tf's bootstrap, cited by
|
|
358
1686
|
// https://github.com/wtfalch/foundry/issues/71): every app on the
|
|
359
1687
|
// Coolify box already gets a Traefik route from the `domains` its
|
|
360
1688
|
// `coolify:app` step declared (`hostnameFor(org)`, this file's
|
|
@@ -363,20 +1691,195 @@ export class FoundryStepExecutor {
|
|
|
363
1691
|
// the connector never needs to know the app's own container name or
|
|
364
1692
|
// uuid. Verified evidence, not a guess (see this campaign's report);
|
|
365
1693
|
// `http://<slug>:3000`, the placeholder this replaces, named a container
|
|
366
|
-
// and port no `coolify:app` step here ever configures.
|
|
1694
|
+
// and port no `coolify:app` step here ever configures. A site-enabled org
|
|
1695
|
+
// (#115) gets one ingress entry per hostname `tunnelIngressHostnames`
|
|
1696
|
+
// returns, all routed to the same proxy -- Traefik itself, not this list,
|
|
1697
|
+
// is what tells the public site apart from the cms studio.
|
|
1698
|
+
const ingress = tunnelIngressHostnames(org).map((hostname) => ({
|
|
1699
|
+
hostname,
|
|
1700
|
+
service: COOLIFY_PROXY_ORIGIN,
|
|
1701
|
+
}));
|
|
367
1702
|
await this.ports.cloudflare.putTunnelConfig(tunnel.id, {
|
|
368
|
-
ingress: [
|
|
1703
|
+
ingress: [...ingress, { service: 'http_status:404' }],
|
|
369
1704
|
});
|
|
370
1705
|
return { resourceId: tunnel.id };
|
|
371
1706
|
}
|
|
372
|
-
|
|
1707
|
+
/**
|
|
1708
|
+
* `plan/diff.ts`'s `planZone`: finds or creates the Cloudflare zone for the
|
|
1709
|
+
* org's own domain. Refuses to adopt a zone the store already records
|
|
1710
|
+
* under a *different* org's applied state (#131), the same
|
|
1711
|
+
* do-not-adopt-a-foreign-resource rule `cloudflareAccess` above enforces --
|
|
1712
|
+
* but where that method tells "foundry's own" from "foreign" by reading
|
|
1713
|
+
* Cloudflare's own Access-app name live, a Cloudflare zone carries no
|
|
1714
|
+
* org-identifying field of its own, so this asks the store instead. That
|
|
1715
|
+
* matters because two different, DB-unique `domain` values (`org.ts`'s
|
|
1716
|
+
* `validateDomain` plus its unique index) can still resolve to the same
|
|
1717
|
+
* Cloudflare zone -- a zone is keyed by apex, and one org's domain can be a
|
|
1718
|
+
* subdomain of another org's -- which is exactly the shared-zone,
|
|
1719
|
+
* shared-hostname collision this ticket exists to refuse. An org re-running
|
|
1720
|
+
* its own already-recorded zone is unaffected: the store then names this
|
|
1721
|
+
* same org as the owner.
|
|
1722
|
+
*/
|
|
1723
|
+
async cloudflareZone(action, org) {
|
|
373
1724
|
const domain = action.key;
|
|
374
|
-
const
|
|
375
|
-
|
|
1725
|
+
const cf = this.ports.cloudflare;
|
|
1726
|
+
const existing = await cf.findZoneByApex(domain);
|
|
1727
|
+
if (existing) {
|
|
1728
|
+
const owner = await this.ports.declaredOrgStore.findOrgIdForZone(existing.id);
|
|
1729
|
+
if (owner !== null && owner !== org.id) {
|
|
1730
|
+
throw new Error(`cloudflare:zone for ${org.slug}: zone ${existing.id} (${domain}) is already recorded under org ${owner}; foundry does not adopt it`);
|
|
1731
|
+
}
|
|
376
1732
|
return { resourceId: existing.id };
|
|
377
|
-
|
|
1733
|
+
}
|
|
1734
|
+
const created = await cf.createZone(domain);
|
|
378
1735
|
return { resourceId: created.id };
|
|
379
1736
|
}
|
|
1737
|
+
// ---- mail -----------------------------------------------------------
|
|
1738
|
+
/**
|
|
1739
|
+
* `plan/diff.ts`'s `planMail`: gives an org mail, moves it between modes,
|
|
1740
|
+
* or parks it on the one shared Stalwart instance (`this.ports.mail`,
|
|
1741
|
+
* `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` bound), then
|
|
1742
|
+
* publishes whatever DNS records the call returns, then registers the same
|
|
1743
|
+
* mail domain with Resend (#138) -- the shared Stalwart relays every
|
|
1744
|
+
* outbound message through Resend, and Resend refuses mail from a domain
|
|
1745
|
+
* it has not verified. No step here ever deletes the app's Resend key:
|
|
1746
|
+
* `diff.ts`'s `planMail` plans nothing for mode `'off'`, so turning mail
|
|
1747
|
+
* off leaves the key live, like every other resource foundry stops
|
|
1748
|
+
* managing (#10). Switching the mail domain replaces the key instead
|
|
1749
|
+
* (`ensureAppResendKey`).
|
|
1750
|
+
*/
|
|
1751
|
+
async coolifyMail(action, org, context) {
|
|
1752
|
+
const mode = action.desired.mode;
|
|
1753
|
+
const usesDomain = action.desired.usesDomain;
|
|
1754
|
+
const options = { apex: MAIL_APEX, mailHost: MAIL_HOST };
|
|
1755
|
+
if (mode === 'off') {
|
|
1756
|
+
await this.ports.mail.parkOrgMail(mailOrgFor(org, usesDomain, 'hosted'), options);
|
|
1757
|
+
return undefined;
|
|
1758
|
+
}
|
|
1759
|
+
const result = await this.ports.mail.applyOrgMail(mailOrgFor(org, usesDomain, mode), options);
|
|
1760
|
+
const zoneId = await this.publishMailDnsRecords(org, result);
|
|
1761
|
+
await this.registerResendDomain(result.domain, zoneId, context);
|
|
1762
|
+
return { resourceId: result.domain };
|
|
1763
|
+
}
|
|
1764
|
+
/**
|
|
1765
|
+
* Upserts each DNS record `applyOrgMail` says foundry must publish, found
|
|
1766
|
+
* by an exact name+type match (`CloudflarePort.findDnsRecord`'s own doc
|
|
1767
|
+
* comment) so update never mistakes a stale record of a different type for
|
|
1768
|
+
* a match. A `DKIM` record can be absent on the first call, since Stalwart
|
|
1769
|
+
* mints the key on a background task; the next apply publishes it. Returns
|
|
1770
|
+
* the zone id so `registerResendDomain` publishes Resend's own records on
|
|
1771
|
+
* the same zone without a second lookup.
|
|
1772
|
+
*/
|
|
1773
|
+
async publishMailDnsRecords(org, result) {
|
|
1774
|
+
const cf = this.ports.cloudflare;
|
|
1775
|
+
const zone = await cf.findZoneByApex(result.domain);
|
|
1776
|
+
if (!zone) {
|
|
1777
|
+
throw new Error(`coolify:mail for ${org.slug}: no Cloudflare zone found for ${result.domain}`);
|
|
1778
|
+
}
|
|
1779
|
+
for (const record of result.records) {
|
|
1780
|
+
const [existing] = await cf.findDnsRecord(zone.id, record.name, record.type);
|
|
1781
|
+
const rec = { type: record.type, name: record.name, content: record.value };
|
|
1782
|
+
if (existing) {
|
|
1783
|
+
await cf.updateDnsRecord(zone.id, existing.id, rec);
|
|
1784
|
+
}
|
|
1785
|
+
else {
|
|
1786
|
+
await cf.createDnsRecord(zone.id, rec);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
return zone.id;
|
|
1790
|
+
}
|
|
1791
|
+
/**
|
|
1792
|
+
* #138: registers `domain` with Resend -- the custom domain, or
|
|
1793
|
+
* `<slug>.<apex>` for a level-1 org, exactly what `publishMailDnsRecords`
|
|
1794
|
+
* just published the Stalwart records for. Idempotent by name, the same way
|
|
1795
|
+
* `applyOrgMail` itself is: found by `findDomain` rather than any id this
|
|
1796
|
+
* class recorded, so a crash between here and the next apply just repeats
|
|
1797
|
+
* the same find-or-create. Never blocks on DNS propagation (task brief):
|
|
1798
|
+
* `verifyDomain` asks Resend to re-check once, and its returned status is
|
|
1799
|
+
* neither polled nor awaited -- an unverified domain here is exactly what
|
|
1800
|
+
* the next apply corrects.
|
|
1801
|
+
*
|
|
1802
|
+
* #140 rework: mints no key of its own any more. It records the domain's
|
|
1803
|
+
* own id and the resolved mail domain as scope ids so `ensureAppResendKey`
|
|
1804
|
+
* (`coolifyApp`) can mint the app's own `<slug>-app@<domainId>` key once the app
|
|
1805
|
+
* exists, without this step or the app step calling Resend to look the
|
|
1806
|
+
* domain back up.
|
|
1807
|
+
*/
|
|
1808
|
+
async registerResendDomain(domain, zoneId, context) {
|
|
1809
|
+
const existing = await this.ports.resend.findDomain(domain);
|
|
1810
|
+
const resendDomain = existing
|
|
1811
|
+
? await this.ports.resend.getDomain(existing.id)
|
|
1812
|
+
: await this.ports.resend.createDomain(domain);
|
|
1813
|
+
await this.publishResendDnsRecords(zoneId, resendDomain.records ?? []);
|
|
1814
|
+
await this.ports.resend.verifyDomain(resendDomain.id);
|
|
1815
|
+
context.recordScopeId(RESEND_DOMAIN_ID_SCOPE_KEY, resendDomain.id);
|
|
1816
|
+
context.recordScopeId(RESEND_MAIL_DOMAIN_SCOPE_KEY, domain);
|
|
1817
|
+
}
|
|
1818
|
+
/**
|
|
1819
|
+
* Upserts each DNS record Resend's domain names, the same find-then-upsert
|
|
1820
|
+
* `publishMailDnsRecords` above uses for Stalwart's own records -- except
|
|
1821
|
+
* SPF is merged into whatever record that call just published rather than
|
|
1822
|
+
* created a second time (RFC 7208 treats two `v=spf1` TXT records on one
|
|
1823
|
+
* name as a permerror and silently breaks SPF for every sender on the
|
|
1824
|
+
* domain), and an existing `_dmarc` record is never overwritten. Prior art:
|
|
1825
|
+
* `wtfalch/app-template`'s `bootstrap/steps/resend-email.ts`.
|
|
1826
|
+
*/
|
|
1827
|
+
async publishResendDnsRecords(zoneId, records) {
|
|
1828
|
+
const cf = this.ports.cloudflare;
|
|
1829
|
+
for (const record of records) {
|
|
1830
|
+
if (record.type === 'TXT' && record.value.toLowerCase().startsWith('v=spf1')) {
|
|
1831
|
+
await this.mergeResendSpfRecord(cf, zoneId, record);
|
|
1832
|
+
continue;
|
|
1833
|
+
}
|
|
1834
|
+
if (record.type === 'TXT' && record.value.toLowerCase().startsWith('v=dmarc1')) {
|
|
1835
|
+
await this.ensureResendDmarcRecord(cf, zoneId, record);
|
|
1836
|
+
continue;
|
|
1837
|
+
}
|
|
1838
|
+
const [existing] = await cf.findDnsRecord(zoneId, record.name, record.type);
|
|
1839
|
+
const rec = { type: record.type, name: record.name, content: record.value };
|
|
1840
|
+
if (existing) {
|
|
1841
|
+
await cf.updateDnsRecord(zoneId, existing.id, rec);
|
|
1842
|
+
}
|
|
1843
|
+
else {
|
|
1844
|
+
await cf.createDnsRecord(zoneId, rec);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
/**
|
|
1849
|
+
* Inserts Resend's `include:` token into whatever SPF record
|
|
1850
|
+
* `publishMailDnsRecords` already published for this name, instead of
|
|
1851
|
+
* creating a second `v=spf1` TXT record -- the classic foot-gun that makes
|
|
1852
|
+
* every receiver treat SPF as a permerror and skip it entirely.
|
|
1853
|
+
*/
|
|
1854
|
+
async mergeResendSpfRecord(cf, zoneId, record) {
|
|
1855
|
+
const includeToken = record.value.match(/include:\S+/i)?.[0] ?? 'include:_spf.resend.com';
|
|
1856
|
+
const existingSpf = (await cf.findDnsRecord(zoneId, record.name, 'TXT')).find((r) => r.content.toLowerCase().startsWith('v=spf1'));
|
|
1857
|
+
if (!existingSpf) {
|
|
1858
|
+
await cf.createDnsRecord(zoneId, { type: 'TXT', name: record.name, content: record.value });
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
if (existingSpf.content.toLowerCase().includes(includeToken.toLowerCase()))
|
|
1862
|
+
return;
|
|
1863
|
+
const merged = /\s+[~\-?+]all\s*$/.test(existingSpf.content)
|
|
1864
|
+
? existingSpf.content.replace(/\s+([~\-?+])all\s*$/, ` ${includeToken} $1all`)
|
|
1865
|
+
: `${existingSpf.content.trim()} ${includeToken}`;
|
|
1866
|
+
await cf.updateDnsRecord(zoneId, existingSpf.id, {
|
|
1867
|
+
type: 'TXT',
|
|
1868
|
+
name: record.name,
|
|
1869
|
+
content: merged,
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
/**
|
|
1873
|
+
* Never overwrites an existing `_dmarc` record -- a stricter policy an org
|
|
1874
|
+
* already set (`p=quarantine`/`p=reject`) should not be relaxed back to
|
|
1875
|
+
* Resend's own `p=none` default.
|
|
1876
|
+
*/
|
|
1877
|
+
async ensureResendDmarcRecord(cf, zoneId, record) {
|
|
1878
|
+
const hasDmarc = (await cf.findDnsRecord(zoneId, record.name, 'TXT')).some((r) => r.content.toLowerCase().startsWith('v=dmarc1'));
|
|
1879
|
+
if (hasDmarc)
|
|
1880
|
+
return;
|
|
1881
|
+
await cf.createDnsRecord(zoneId, { type: 'TXT', name: record.name, content: record.value });
|
|
1882
|
+
}
|
|
380
1883
|
// ---- files / ai --------------------------------------------------------
|
|
381
1884
|
/**
|
|
382
1885
|
* #80: provisions the org's own organisation on Files, before
|
|
@@ -441,6 +1944,9 @@ export class FoundryStepExecutor {
|
|
|
441
1944
|
case 'cloudflare:tunnel':
|
|
442
1945
|
await this.ports.cloudflare.deleteTunnel(target.id);
|
|
443
1946
|
return;
|
|
1947
|
+
case 'cloudflare:access':
|
|
1948
|
+
await this.ports.cloudflare.deleteAccessApp(target.id);
|
|
1949
|
+
return;
|
|
444
1950
|
case 'coolify:app':
|
|
445
1951
|
await this.ports.coolify.deleteApplication(target.id);
|
|
446
1952
|
return;
|