@wtfalch/foundry 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/apply.d.ts +14 -0
- package/dist/calls-sfu-deploy-support.d.ts +57 -0
- package/dist/calls-sfu-deploy-support.js +82 -0
- package/dist/calls-sfu.d.ts +175 -0
- package/dist/calls-sfu.js +239 -0
- package/dist/index.d.ts +11 -6
- package/dist/index.js +7 -4
- package/dist/migrate.d.ts +38 -0
- package/dist/migrate.js +47 -0
- package/dist/migrations/0008_hosting.sql +24 -0
- package/dist/migrations/0009_hosting_git_branch_build_pack.sql +14 -0
- package/dist/migrations/0010_owner.sql +13 -0
- package/dist/migrations/0011_test_org.sql +12 -0
- package/dist/migrations/0012_audit.sql +21 -0
- package/dist/migrations/0013_access_emails.sql +6 -0
- package/dist/org.d.ts +130 -0
- package/dist/org.js +102 -0
- package/dist/plan/diff.d.ts +20 -5
- package/dist/plan/diff.js +533 -9
- package/dist/plan/executor.d.ts +823 -0
- package/dist/plan/executor.js +1242 -0
- package/dist/plan/index.d.ts +6 -4
- package/dist/plan/index.js +3 -2
- package/dist/plan/live.d.ts +8 -4
- package/dist/plan/plan-apply.d.ts +114 -4
- package/dist/plan/plan-apply.js +266 -14
- package/dist/plan/providers-readers.d.ts +102 -14
- package/dist/plan/providers-readers.js +245 -16
- package/dist/provision.d.ts +56 -0
- package/dist/provision.js +69 -0
- package/dist/slug.d.ts +12 -18
- package/dist/slug.js +66 -0
- package/dist/state.d.ts +26 -5
- package/dist/store/audit.d.ts +8 -0
- package/dist/store/audit.js +43 -0
- package/dist/store/postgres.d.ts +3 -1
- package/dist/store/postgres.js +84 -2
- package/dist/store/schema.d.ts +378 -0
- package/dist/store/schema.js +32 -1
- package/dist/store.d.ts +12 -0
- package/package.json +15 -11
|
@@ -3,7 +3,7 @@ import type { CoolifyClient } from '@wtfalch/providers/coolify';
|
|
|
3
3
|
import type { ZitadelClient } from '@wtfalch/providers/zitadel';
|
|
4
4
|
import type { OrgId } from '../org.js';
|
|
5
5
|
import type { DeclaredOrgStore } from '../store.js';
|
|
6
|
-
import type { CloudflareReader, CoolifyReader, LiveResource, ZitadelReader } from './live.js';
|
|
6
|
+
import type { CloudflareReader, CmsReader, CoolifyReader, LiveResource, ZitadelReader } from './live.js';
|
|
7
7
|
/**
|
|
8
8
|
* Real `CoolifyReader`, `CloudflareReader` and `ZitadelReader` implementations
|
|
9
9
|
* (`live.ts:31-45`), backed by `@wtfalch/providers`' HTTP clients instead of
|
|
@@ -22,6 +22,13 @@ import type { CloudflareReader, CoolifyReader, LiveResource, ZitadelReader } fro
|
|
|
22
22
|
*
|
|
23
23
|
* `packages/foundry/package.json` had zero `@wtfalch/*` dependencies before
|
|
24
24
|
* this file; `@wtfalch/providers` is its first.
|
|
25
|
+
*
|
|
26
|
+
* Each reader also matches a live provider resource back onto the foundry
|
|
27
|
+
* resource it was created for, via {@link RecordedIds} /
|
|
28
|
+
* {@link storeRecordedIds} below, so `computePlanDetail` (`diff.ts`) treats
|
|
29
|
+
* what foundry itself provisioned as matched instead of unmanaged (#10) --
|
|
30
|
+
* see the campaign design doc,
|
|
31
|
+
* `.claude/campaign/2026-09-24-keys-and-memberships.md`, "Design for (1)".
|
|
25
32
|
*/
|
|
26
33
|
/**
|
|
27
34
|
* Resolves an estate {@link OrgId} to one provider's own scope id — a
|
|
@@ -38,19 +45,44 @@ import type { CloudflareReader, CoolifyReader, LiveResource, ZitadelReader } fro
|
|
|
38
45
|
*/
|
|
39
46
|
export type ScopeResolver = (orgId: OrgId) => Promise<string | null>;
|
|
40
47
|
/**
|
|
41
|
-
* A {@link ScopeResolver} backed by `AppliedState.serviceIds`
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* org today, through the same `DeclaredOrgStore` seam #18 built.
|
|
48
|
+
* A {@link ScopeResolver} backed by `AppliedState.serviceIds` (`../state.ts`)
|
|
49
|
+
* — the one place foundry records a provider's own id for an org today,
|
|
50
|
+
* through the same `DeclaredOrgStore` seam #18 built.
|
|
45
51
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* `
|
|
51
|
-
*
|
|
52
|
+
* `runSteps` (`plan-apply.ts`) now writes one `serviceIds` entry per
|
|
53
|
+
* resource it creates, keyed `system:kind:key` ({@link storeRecordedIds}
|
|
54
|
+
* below reads those back), and #20's `FoundryStepExecutor` (`executor.ts`)
|
|
55
|
+
* now also calls `StepExecutionContext.recordScopeId` for `coolify_project`,
|
|
56
|
+
* `zitadel_org` and `zitadel_project`, so a resolver built with one of those
|
|
57
|
+
* three `serviceKey`s returns the real id once that step has run. A
|
|
58
|
+
* Cloudflare zone is the one scope id nothing writes yet: `executor.ts`'s
|
|
59
|
+
* `cloudflareZone` records its result only under `cloudflare:zone:<domain>`
|
|
60
|
+
* (a resource id, read back by {@link storeRecordedIds}), not under a plain
|
|
61
|
+
* `cloudflare_zone` scope key — a resolver built with that `serviceKey`
|
|
62
|
+
* still returns `null` for every org. That is the correct "nothing live yet"
|
|
63
|
+
* answer: a reader passed `null` returns `[]`, which `computePlanDetail`
|
|
64
|
+
* (`diff.ts`) already reads as "no live resource".
|
|
52
65
|
*/
|
|
53
66
|
export declare function storeScopeResolver(store: DeclaredOrgStore, serviceKey: string): ScopeResolver;
|
|
67
|
+
/**
|
|
68
|
+
* Resolves an estate {@link OrgId} to the provider ids this org's applied
|
|
69
|
+
* state has recorded for one reader's `system`, inverted so a reader can
|
|
70
|
+
* look a live resource's own id up directly: the provider's id -> the
|
|
71
|
+
* foundry `kind:key` it was recorded under.
|
|
72
|
+
*
|
|
73
|
+
* Backed by the same `AppliedState.serviceIds` {@link storeScopeResolver}
|
|
74
|
+
* reads, but a different slice of it: `plan-apply.ts`'s `runSteps` records a
|
|
75
|
+
* completed step's `resourceId` under `serviceIds[`${system}:${kind}:${key}`]`
|
|
76
|
+
* (`../state.ts`'s doc comment; design:
|
|
77
|
+
* `.claude/campaign/2026-09-24-keys-and-memberships.md`, "Design for (1)").
|
|
78
|
+
* Only entries in that three-part form, for this `system`, are kept — a
|
|
79
|
+
* scope id (`coolify_project`, etc.) has no `:` in it and is filtered out
|
|
80
|
+
* rather than mistaken for a resource id. `key` is rejoined with `:` rather
|
|
81
|
+
* than taken as a single segment because it is not always the org id: a
|
|
82
|
+
* declared ZITADEL app is keyed by its own name (#71), not `orgId`.
|
|
83
|
+
*/
|
|
84
|
+
export type RecordedIds = (orgId: OrgId) => Promise<Record<string, string>>;
|
|
85
|
+
export declare function storeRecordedIds(store: DeclaredOrgStore, system: LiveResource['system']): RecordedIds;
|
|
54
86
|
/**
|
|
55
87
|
* Backed by `@wtfalch/providers/coolify`'s `CoolifyClient.listResources`: a
|
|
56
88
|
* Coolify project's environments and in-scope applications (v1 scope — see
|
|
@@ -59,7 +91,8 @@ export declare function storeScopeResolver(store: DeclaredOrgStore, serviceKey:
|
|
|
59
91
|
export declare class ProvidersCoolifyReader implements CoolifyReader {
|
|
60
92
|
private readonly client;
|
|
61
93
|
private readonly resolveScope;
|
|
62
|
-
|
|
94
|
+
private readonly resolveRecordedIds;
|
|
95
|
+
constructor(client: CoolifyClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds);
|
|
63
96
|
listResources(orgId: OrgId): Promise<LiveResource[]>;
|
|
64
97
|
}
|
|
65
98
|
/**
|
|
@@ -70,7 +103,8 @@ export declare class ProvidersCoolifyReader implements CoolifyReader {
|
|
|
70
103
|
export declare class ProvidersCloudflareReader implements CloudflareReader {
|
|
71
104
|
private readonly client;
|
|
72
105
|
private readonly resolveScope;
|
|
73
|
-
|
|
106
|
+
private readonly resolveRecordedIds;
|
|
107
|
+
constructor(client: CloudflareClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds);
|
|
74
108
|
listResources(orgId: OrgId): Promise<LiveResource[]>;
|
|
75
109
|
}
|
|
76
110
|
/**
|
|
@@ -81,6 +115,60 @@ export declare class ProvidersCloudflareReader implements CloudflareReader {
|
|
|
81
115
|
export declare class ProvidersZitadelReader implements ZitadelReader {
|
|
82
116
|
private readonly client;
|
|
83
117
|
private readonly resolveScope;
|
|
84
|
-
|
|
118
|
+
private readonly resolveRecordedIds;
|
|
119
|
+
constructor(client: ZitadelClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds);
|
|
120
|
+
listResources(orgId: OrgId): Promise<LiveResource[]>;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Backed by the same `@wtfalch/providers/coolify`'s `CoolifyClient.listResources`
|
|
124
|
+
* call {@link ProvidersCoolifyReader} makes, not a real cms admin API --
|
|
125
|
+
* `executor.ts`'s `cmsSite` (#116) provisions the org's cms app and site app
|
|
126
|
+
* as two ordinary Coolify applications in the *same* Coolify project the
|
|
127
|
+
* org's own hosted app would use (`ensureCoolifyProject`), so this reader
|
|
128
|
+
* resolves that project's scope the same way, then finds the two
|
|
129
|
+
* applications this org recorded there -- by the flat scope ids `cmsSite`
|
|
130
|
+
* records them under (`coolify_cms_app`/`coolify_site_app`, `executor.ts`'s
|
|
131
|
+
* `CMS_APP_SCOPE_KEY`/`SITE_APP_SCOPE_KEY` -- restated here rather than
|
|
132
|
+
* imported, the same way `hostnameFor`'s hostname rule is restated across
|
|
133
|
+
* files rather than shared), matched by id the same way every other reader
|
|
134
|
+
* in this file matches a recorded resource, never by name.
|
|
135
|
+
*
|
|
136
|
+
* Without this reader nothing ever reports a live `cms:site:<orgId>`
|
|
137
|
+
* resource, so `diff.ts`'s `planSite` sees `!live` forever and re-plans
|
|
138
|
+
* "provision a cms site" on every apply even after one has already
|
|
139
|
+
* succeeded -- #116 review Finding 1, breaking #14's "repeat until plan
|
|
140
|
+
* returns empty" invariant.
|
|
141
|
+
*
|
|
142
|
+
* `planSite` reads this resource's `config.enabled`/`config.usesDomain` with
|
|
143
|
+
* plain truthiness (`Boolean(live.config.enabled)`), unlike
|
|
144
|
+
* `planHostingToggle`'s own `!== false` convention for the org's *own* app
|
|
145
|
+
* -- so, unlike {@link ProvidersCoolifyReader}, which only ever passes
|
|
146
|
+
* Coolify's raw fields through untouched, this reader has to synthesize an
|
|
147
|
+
* explicit `enabled` boolean itself, or the plan would never converge in
|
|
148
|
+
* either direction. Both apps must exist and count as up, or on their way
|
|
149
|
+
* there ({@link isRunningApplication}), for `enabled` to be `true`; not
|
|
150
|
+
* found, or found but actually stopped, reads as `false`, which is what
|
|
151
|
+
* lets a later park step's own live check (`!live.config.enabled`) settle
|
|
152
|
+
* once both apps have actually stopped.
|
|
153
|
+
* `usesDomain` is always `true`: `validateSite` ("site.enabled requires
|
|
154
|
+
* site.usesDomain") guarantees it whenever this resource can exist at all.
|
|
155
|
+
*
|
|
156
|
+
* Nothing recorded yet (no Coolify project scope, or either app id still
|
|
157
|
+
* unrecorded -- `cmsSite`'s own doc comment: a crash partway through a first
|
|
158
|
+
* apply can leave only one recorded) or either application missing from
|
|
159
|
+
* Coolify's own listing reads as "nothing live" (`[]`), the same "not
|
|
160
|
+
* provisioned yet" answer `computePlanDetail` already knows how to plan
|
|
161
|
+
* against. A failing `client.listResources` call is not caught here, the
|
|
162
|
+
* same way it is not caught in `ProvidersCoolifyReader`/
|
|
163
|
+
* `ProvidersCloudflareReader`/`ProvidersZitadelReader` either -- only a
|
|
164
|
+
* bolt-on *extra* read (`withLogVolumeConfig`'s own doc comment) gets a
|
|
165
|
+
* try/catch, and this call is this reader's entire job, not an extra.
|
|
166
|
+
*/
|
|
167
|
+
export declare class ProvidersCmsReader implements CmsReader {
|
|
168
|
+
private readonly client;
|
|
169
|
+
private readonly resolveProjectScope;
|
|
170
|
+
private readonly resolveCmsAppId;
|
|
171
|
+
private readonly resolveSiteAppId;
|
|
172
|
+
constructor(client: CoolifyClient, resolveProjectScope: ScopeResolver, resolveCmsAppId: ScopeResolver, resolveSiteAppId: ScopeResolver);
|
|
85
173
|
listResources(orgId: OrgId): Promise<LiveResource[]>;
|
|
86
174
|
}
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A {@link ScopeResolver} backed by `AppliedState.serviceIds`
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* org today, through the same `DeclaredOrgStore` seam #18 built.
|
|
2
|
+
* A {@link ScopeResolver} backed by `AppliedState.serviceIds` (`../state.ts`)
|
|
3
|
+
* — the one place foundry records a provider's own id for an org today,
|
|
4
|
+
* through the same `DeclaredOrgStore` seam #18 built.
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
6
|
+
* `runSteps` (`plan-apply.ts`) now writes one `serviceIds` entry per
|
|
7
|
+
* resource it creates, keyed `system:kind:key` ({@link storeRecordedIds}
|
|
8
|
+
* below reads those back), and #20's `FoundryStepExecutor` (`executor.ts`)
|
|
9
|
+
* now also calls `StepExecutionContext.recordScopeId` for `coolify_project`,
|
|
10
|
+
* `zitadel_org` and `zitadel_project`, so a resolver built with one of those
|
|
11
|
+
* three `serviceKey`s returns the real id once that step has run. A
|
|
12
|
+
* Cloudflare zone is the one scope id nothing writes yet: `executor.ts`'s
|
|
13
|
+
* `cloudflareZone` records its result only under `cloudflare:zone:<domain>`
|
|
14
|
+
* (a resource id, read back by {@link storeRecordedIds}), not under a plain
|
|
15
|
+
* `cloudflare_zone` scope key — a resolver built with that `serviceKey`
|
|
16
|
+
* still returns `null` for every org. That is the correct "nothing live yet"
|
|
17
|
+
* answer: a reader passed `null` returns `[]`, which `computePlanDetail`
|
|
18
|
+
* (`diff.ts`) already reads as "no live resource".
|
|
13
19
|
*/
|
|
14
20
|
export function storeScopeResolver(store, serviceKey) {
|
|
15
21
|
return async (orgId) => {
|
|
@@ -20,6 +26,110 @@ export function storeScopeResolver(store, serviceKey) {
|
|
|
20
26
|
return applied?.serviceIds[serviceKey] || null;
|
|
21
27
|
};
|
|
22
28
|
}
|
|
29
|
+
export function storeRecordedIds(store, system) {
|
|
30
|
+
return async (orgId) => {
|
|
31
|
+
const applied = await store.getAppliedState(orgId);
|
|
32
|
+
const recorded = {};
|
|
33
|
+
if (!applied)
|
|
34
|
+
return recorded;
|
|
35
|
+
for (const [resourceKey, providerId] of Object.entries(applied.serviceIds)) {
|
|
36
|
+
const [keySystem, kind, ...rest] = resourceKey.split(':');
|
|
37
|
+
if (keySystem !== system || kind === undefined || rest.length === 0)
|
|
38
|
+
continue;
|
|
39
|
+
recorded[providerId] = `${kind}:${rest.join(':')}`;
|
|
40
|
+
}
|
|
41
|
+
return recorded;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Maps each live resource whose own id is one `recorded` names onto the
|
|
46
|
+
* foundry `kind`/`key` it was recorded under, keeping `config` exactly as
|
|
47
|
+
* the provider gave it (design doc, "Design for (1)": "the reader then maps
|
|
48
|
+
* each provider resource whose `key` equals a recorded provider id onto the
|
|
49
|
+
* recorded foundry `kind` and `key`"). A resource nothing recorded passes
|
|
50
|
+
* through unchanged, so `computePlanDetail` (`diff.ts`) still reports it
|
|
51
|
+
* unmanaged (#10) rather than this boundary guessing at a match.
|
|
52
|
+
*
|
|
53
|
+
* `dropUnrecordedKinds` names kinds that are dropped outright instead of
|
|
54
|
+
* passed through when unrecorded. Cloudflare's `tunnel` is the one case: a
|
|
55
|
+
* tunnel is account-wide, so an unrecorded one belongs to some other org,
|
|
56
|
+
* not this one, and every org's plan would otherwise report the same
|
|
57
|
+
* foreign tunnel as unmanaged.
|
|
58
|
+
*/
|
|
59
|
+
function applyRecordedIds(resources, recorded, dropUnrecordedKinds = new Set()) {
|
|
60
|
+
const mapped = [];
|
|
61
|
+
for (const resource of resources) {
|
|
62
|
+
const foundryKey = recorded[resource.key];
|
|
63
|
+
if (!foundryKey) {
|
|
64
|
+
if (!dropUnrecordedKinds.has(resource.kind))
|
|
65
|
+
mapped.push(resource);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const separator = foundryKey.indexOf(':');
|
|
69
|
+
mapped.push({
|
|
70
|
+
...resource,
|
|
71
|
+
kind: foundryKey.slice(0, separator),
|
|
72
|
+
key: foundryKey.slice(separator + 1),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return mapped;
|
|
76
|
+
}
|
|
77
|
+
/** Cloudflare tunnels this org has not recorded are dropped — see {@link applyRecordedIds}. */
|
|
78
|
+
const CLOUDFLARE_DROP_UNRECORDED = new Set(['tunnel']);
|
|
79
|
+
/**
|
|
80
|
+
* The persistent-storage mount path #75 requires on every app foundry
|
|
81
|
+
* deploys, and the exact string `executor.ts`'s own `LOG_DIR` constant holds
|
|
82
|
+
* -- restated here rather than imported the same way `executor.ts` restates
|
|
83
|
+
* `diff.ts`'s hostname rule, since neither file in `plan/` imports another's
|
|
84
|
+
* private constants.
|
|
85
|
+
*/
|
|
86
|
+
const LOG_DIR = '/app/logs';
|
|
87
|
+
/**
|
|
88
|
+
* #75: whether the org's own recorded app already carries the durable log
|
|
89
|
+
* directory `executor.ts`'s `ensureLogVolume` creates -- an extra
|
|
90
|
+
* `listStorages` read, by the application's own provider uuid
|
|
91
|
+
* (`resource.key`, still the raw Coolify uuid at this point -- called
|
|
92
|
+
* *before* {@link applyRecordedIds} below overwrites `key` with the foundry
|
|
93
|
+
* key it was recorded under, or `listStorages` would be asked for a
|
|
94
|
+
* "storages" listing of the org id instead of the application). Gated on
|
|
95
|
+
* `recorded` mapping this exact uuid onto foundry's `app` kind (the same map
|
|
96
|
+
* {@link applyRecordedIds} itself reads), not just `resource.kind ===
|
|
97
|
+
* 'application'`, so this never fires for some other application in the same
|
|
98
|
+
* project, nor for the project's own database uuid.
|
|
99
|
+
*
|
|
100
|
+
* This lets `diff.ts`'s `planApp` notice a live app that predates this
|
|
101
|
+
* feature and plan the missing volume, the same way `LiveResource.config`
|
|
102
|
+
* (`live.ts`) already carries whatever a planner needs to diff against.
|
|
103
|
+
* `hasLogDir`'s env var itself is not read back here: `executor.ts`'s
|
|
104
|
+
* `coolifyApp` always pushes `LOG_DIR` in the same step that mounts the
|
|
105
|
+
* volume, so the volume's own presence is the one signal this reader needs
|
|
106
|
+
* (Decision: reading `listEnvs` too would only catch a volume manually
|
|
107
|
+
* removed after the env was pushed by hand, which #75's own "no app is
|
|
108
|
+
* configured by hand" rules out).
|
|
109
|
+
*
|
|
110
|
+
* `listStorages` is one extra, unrelated-to-the-plan's-real-purpose read
|
|
111
|
+
* bolted onto every org's plan (#75), so a failure of this one call --
|
|
112
|
+
* Coolify briefly unreachable, a 5xx, anything `CoolifyClient` throws on a
|
|
113
|
+
* non-2xx -- must not abort the whole org's plan the way an unhandled
|
|
114
|
+
* rejection here would (Finding, refuted against the first version of this
|
|
115
|
+
* function: it had no try/catch at all). Caught and treated the same as
|
|
116
|
+
* "nothing has checked this app yet": the resource passes through
|
|
117
|
+
* unchanged, `hasLogVolume` stays absent, and `diff.ts`'s `planApp` already
|
|
118
|
+
* reads a missing `hasLogVolume` as compliant rather than drift.
|
|
119
|
+
*/
|
|
120
|
+
async function withLogVolumeConfig(client, resource, recorded) {
|
|
121
|
+
if (resource.kind !== 'application' || !recorded[resource.key]?.startsWith('app:')) {
|
|
122
|
+
return resource;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const storages = await client.listStorages(resource.key);
|
|
126
|
+
const hasLogVolume = (storages.persistent_storages ?? []).some((s) => s.mount_path === LOG_DIR);
|
|
127
|
+
return { ...resource, config: { ...resource.config, hasLogVolume } };
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return resource;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
23
133
|
/** `@wtfalch/providers`' own `LiveResource` (`resource.ts`) has no `system` field — foundry's adds it at this boundary. */
|
|
24
134
|
function toFoundryResources(system, resources) {
|
|
25
135
|
return resources.map((r) => ({ system, kind: r.kind, key: r.key, config: r.config }));
|
|
@@ -32,15 +142,22 @@ function toFoundryResources(system, resources) {
|
|
|
32
142
|
export class ProvidersCoolifyReader {
|
|
33
143
|
client;
|
|
34
144
|
resolveScope;
|
|
35
|
-
|
|
145
|
+
resolveRecordedIds;
|
|
146
|
+
constructor(client, resolveScope, resolveRecordedIds) {
|
|
36
147
|
this.client = client;
|
|
37
148
|
this.resolveScope = resolveScope;
|
|
149
|
+
this.resolveRecordedIds = resolveRecordedIds;
|
|
38
150
|
}
|
|
39
151
|
async listResources(orgId) {
|
|
40
152
|
const projectUuid = await this.resolveScope(orgId);
|
|
41
153
|
if (!projectUuid)
|
|
42
154
|
return [];
|
|
43
|
-
|
|
155
|
+
const [resources, recorded] = await Promise.all([
|
|
156
|
+
this.client.listResources(projectUuid),
|
|
157
|
+
this.resolveRecordedIds(orgId),
|
|
158
|
+
]);
|
|
159
|
+
const withLogConfig = await Promise.all(toFoundryResources('coolify', resources).map((r) => withLogVolumeConfig(this.client, r, recorded)));
|
|
160
|
+
return applyRecordedIds(withLogConfig, recorded);
|
|
44
161
|
}
|
|
45
162
|
}
|
|
46
163
|
/**
|
|
@@ -51,15 +168,24 @@ export class ProvidersCoolifyReader {
|
|
|
51
168
|
export class ProvidersCloudflareReader {
|
|
52
169
|
client;
|
|
53
170
|
resolveScope;
|
|
54
|
-
|
|
171
|
+
resolveRecordedIds;
|
|
172
|
+
constructor(client, resolveScope, resolveRecordedIds) {
|
|
55
173
|
this.client = client;
|
|
56
174
|
this.resolveScope = resolveScope;
|
|
175
|
+
this.resolveRecordedIds = resolveRecordedIds;
|
|
57
176
|
}
|
|
58
177
|
async listResources(orgId) {
|
|
59
178
|
const zoneId = await this.resolveScope(orgId);
|
|
60
179
|
if (!zoneId)
|
|
61
180
|
return [];
|
|
62
|
-
|
|
181
|
+
const [resources, recorded] = await Promise.all([
|
|
182
|
+
this.client.listResources(zoneId),
|
|
183
|
+
this.resolveRecordedIds(orgId),
|
|
184
|
+
]);
|
|
185
|
+
// A zone needs no mapping: its key is already the zone name, which is
|
|
186
|
+
// foundry's own zone key (`diff.ts`'s `planZone`), so it never matches a
|
|
187
|
+
// recorded provider id and always passes through unchanged.
|
|
188
|
+
return applyRecordedIds(toFoundryResources('cloudflare', resources), recorded, CLOUDFLARE_DROP_UNRECORDED);
|
|
63
189
|
}
|
|
64
190
|
}
|
|
65
191
|
/**
|
|
@@ -70,14 +196,117 @@ export class ProvidersCloudflareReader {
|
|
|
70
196
|
export class ProvidersZitadelReader {
|
|
71
197
|
client;
|
|
72
198
|
resolveScope;
|
|
73
|
-
|
|
199
|
+
resolveRecordedIds;
|
|
200
|
+
constructor(client, resolveScope, resolveRecordedIds) {
|
|
74
201
|
this.client = client;
|
|
75
202
|
this.resolveScope = resolveScope;
|
|
203
|
+
this.resolveRecordedIds = resolveRecordedIds;
|
|
76
204
|
}
|
|
77
205
|
async listResources(orgId) {
|
|
78
206
|
const zitadelOrgId = await this.resolveScope(orgId);
|
|
79
207
|
if (!zitadelOrgId)
|
|
80
208
|
return [];
|
|
81
|
-
|
|
209
|
+
const [resources, recorded] = await Promise.all([
|
|
210
|
+
this.client.listResources(zitadelOrgId),
|
|
211
|
+
this.resolveRecordedIds(orgId),
|
|
212
|
+
]);
|
|
213
|
+
return applyRecordedIds(toFoundryResources('zitadel', resources), recorded);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Whether an application counts as up, or on its way there, from its own
|
|
218
|
+
* Coolify `status` (`` `running:healthy`, `running:unknown`,
|
|
219
|
+
* `exited:unhealthy` `` -- `CoolifyClient`'s own `CoolifyApplication.status`
|
|
220
|
+
* doc comment). Anything except `exited`/`stopped` (Coolify's own
|
|
221
|
+
* resting-off states; `stopped` is not one this codebase has verified
|
|
222
|
+
* against a live instance, included defensively) reads as still on --
|
|
223
|
+
* concretely, `starting:*`/`restarting:*` right after `cmsSite` (re)creates
|
|
224
|
+
* or starts an app both count as on too, not just `running:*`, or a status
|
|
225
|
+
* read moments after that call would misreport `enabled: false` and cause a
|
|
226
|
+
* spurious re-plan (#116 review round 3, Finding 2, mirroring
|
|
227
|
+
* `executor.ts`'s own `isRunningOrStarting`, restated here rather than
|
|
228
|
+
* imported the same way `hostnameFor`'s hostname rule is). A missing
|
|
229
|
+
* `status` defaults to on too, the same "nothing says it's off" convention
|
|
230
|
+
* `planHostingToggle` (`diff.ts`) uses for the org's own app.
|
|
231
|
+
*/
|
|
232
|
+
function isRunningApplication(resource) {
|
|
233
|
+
const status = resource.config.status;
|
|
234
|
+
if (!status)
|
|
235
|
+
return true;
|
|
236
|
+
return (typeof status === 'string' && !status.startsWith('exited') && !status.startsWith('stopped'));
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Backed by the same `@wtfalch/providers/coolify`'s `CoolifyClient.listResources`
|
|
240
|
+
* call {@link ProvidersCoolifyReader} makes, not a real cms admin API --
|
|
241
|
+
* `executor.ts`'s `cmsSite` (#116) provisions the org's cms app and site app
|
|
242
|
+
* as two ordinary Coolify applications in the *same* Coolify project the
|
|
243
|
+
* org's own hosted app would use (`ensureCoolifyProject`), so this reader
|
|
244
|
+
* resolves that project's scope the same way, then finds the two
|
|
245
|
+
* applications this org recorded there -- by the flat scope ids `cmsSite`
|
|
246
|
+
* records them under (`coolify_cms_app`/`coolify_site_app`, `executor.ts`'s
|
|
247
|
+
* `CMS_APP_SCOPE_KEY`/`SITE_APP_SCOPE_KEY` -- restated here rather than
|
|
248
|
+
* imported, the same way `hostnameFor`'s hostname rule is restated across
|
|
249
|
+
* files rather than shared), matched by id the same way every other reader
|
|
250
|
+
* in this file matches a recorded resource, never by name.
|
|
251
|
+
*
|
|
252
|
+
* Without this reader nothing ever reports a live `cms:site:<orgId>`
|
|
253
|
+
* resource, so `diff.ts`'s `planSite` sees `!live` forever and re-plans
|
|
254
|
+
* "provision a cms site" on every apply even after one has already
|
|
255
|
+
* succeeded -- #116 review Finding 1, breaking #14's "repeat until plan
|
|
256
|
+
* returns empty" invariant.
|
|
257
|
+
*
|
|
258
|
+
* `planSite` reads this resource's `config.enabled`/`config.usesDomain` with
|
|
259
|
+
* plain truthiness (`Boolean(live.config.enabled)`), unlike
|
|
260
|
+
* `planHostingToggle`'s own `!== false` convention for the org's *own* app
|
|
261
|
+
* -- so, unlike {@link ProvidersCoolifyReader}, which only ever passes
|
|
262
|
+
* Coolify's raw fields through untouched, this reader has to synthesize an
|
|
263
|
+
* explicit `enabled` boolean itself, or the plan would never converge in
|
|
264
|
+
* either direction. Both apps must exist and count as up, or on their way
|
|
265
|
+
* there ({@link isRunningApplication}), for `enabled` to be `true`; not
|
|
266
|
+
* found, or found but actually stopped, reads as `false`, which is what
|
|
267
|
+
* lets a later park step's own live check (`!live.config.enabled`) settle
|
|
268
|
+
* once both apps have actually stopped.
|
|
269
|
+
* `usesDomain` is always `true`: `validateSite` ("site.enabled requires
|
|
270
|
+
* site.usesDomain") guarantees it whenever this resource can exist at all.
|
|
271
|
+
*
|
|
272
|
+
* Nothing recorded yet (no Coolify project scope, or either app id still
|
|
273
|
+
* unrecorded -- `cmsSite`'s own doc comment: a crash partway through a first
|
|
274
|
+
* apply can leave only one recorded) or either application missing from
|
|
275
|
+
* Coolify's own listing reads as "nothing live" (`[]`), the same "not
|
|
276
|
+
* provisioned yet" answer `computePlanDetail` already knows how to plan
|
|
277
|
+
* against. A failing `client.listResources` call is not caught here, the
|
|
278
|
+
* same way it is not caught in `ProvidersCoolifyReader`/
|
|
279
|
+
* `ProvidersCloudflareReader`/`ProvidersZitadelReader` either -- only a
|
|
280
|
+
* bolt-on *extra* read (`withLogVolumeConfig`'s own doc comment) gets a
|
|
281
|
+
* try/catch, and this call is this reader's entire job, not an extra.
|
|
282
|
+
*/
|
|
283
|
+
export class ProvidersCmsReader {
|
|
284
|
+
client;
|
|
285
|
+
resolveProjectScope;
|
|
286
|
+
resolveCmsAppId;
|
|
287
|
+
resolveSiteAppId;
|
|
288
|
+
constructor(client, resolveProjectScope, resolveCmsAppId, resolveSiteAppId) {
|
|
289
|
+
this.client = client;
|
|
290
|
+
this.resolveProjectScope = resolveProjectScope;
|
|
291
|
+
this.resolveCmsAppId = resolveCmsAppId;
|
|
292
|
+
this.resolveSiteAppId = resolveSiteAppId;
|
|
293
|
+
}
|
|
294
|
+
async listResources(orgId) {
|
|
295
|
+
const projectUuid = await this.resolveProjectScope(orgId);
|
|
296
|
+
if (!projectUuid)
|
|
297
|
+
return [];
|
|
298
|
+
const [cmsAppId, siteAppId] = await Promise.all([
|
|
299
|
+
this.resolveCmsAppId(orgId),
|
|
300
|
+
this.resolveSiteAppId(orgId),
|
|
301
|
+
]);
|
|
302
|
+
if (!cmsAppId || !siteAppId)
|
|
303
|
+
return [];
|
|
304
|
+
const resources = await this.client.listResources(projectUuid);
|
|
305
|
+
const cmsApp = resources.find((r) => r.kind === 'application' && r.key === cmsAppId);
|
|
306
|
+
const siteApp = resources.find((r) => r.kind === 'application' && r.key === siteAppId);
|
|
307
|
+
if (!cmsApp || !siteApp)
|
|
308
|
+
return [];
|
|
309
|
+
const enabled = isRunningApplication(cmsApp) && isRunningApplication(siteApp);
|
|
310
|
+
return [{ system: 'cms', kind: 'site', key: orgId, config: { enabled, usesDomain: true } }];
|
|
82
311
|
}
|
|
83
312
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { CoolifyPort } from './plan/executor.js';
|
|
2
|
+
export declare const PROJECT_NAME = "foundry";
|
|
3
|
+
export declare const ENVIRONMENT_NAME = "production";
|
|
4
|
+
export declare const DATABASE_NAME = "foundry";
|
|
5
|
+
/**
|
|
6
|
+
* The Coolify calls provisioning needs, on top of `plan/executor.ts`'s own
|
|
7
|
+
* `CoolifyPort` (reused by reference below rather than restated): `listEnvironments`,
|
|
8
|
+
* which `CoolifyPort` has no read-only equivalent for (`findOrCreateEnvironment`
|
|
9
|
+
* creates on a miss). A real `CoolifyClient` already has both, so this is
|
|
10
|
+
* still satisfied with no adapter.
|
|
11
|
+
*/
|
|
12
|
+
export interface ProvisionCoolifyPort extends Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'listDatabases' | 'createPostgresDatabase'> {
|
|
13
|
+
listEnvironments(projectUuid: string): Promise<Array<{
|
|
14
|
+
id: number;
|
|
15
|
+
uuid: string;
|
|
16
|
+
name: string;
|
|
17
|
+
}>>;
|
|
18
|
+
}
|
|
19
|
+
export interface ProvisionPlan {
|
|
20
|
+
projectExists: boolean;
|
|
21
|
+
project?: {
|
|
22
|
+
uuid: string;
|
|
23
|
+
};
|
|
24
|
+
environmentExists: boolean;
|
|
25
|
+
environment?: {
|
|
26
|
+
id: number;
|
|
27
|
+
uuid: string;
|
|
28
|
+
name: string;
|
|
29
|
+
};
|
|
30
|
+
databaseExists: boolean;
|
|
31
|
+
database?: {
|
|
32
|
+
uuid: string;
|
|
33
|
+
name: string;
|
|
34
|
+
environment_id?: number;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Read-only: works out what `applyProvision` would need to do to make the
|
|
39
|
+
* `PROJECT_NAME`/`ENVIRONMENT_NAME`/`DATABASE_NAME` database exist, without
|
|
40
|
+
* creating anything. Mirrors `plan/executor.ts`'s `coolifyDatabase` step --
|
|
41
|
+
* find the database by name scoped to the environment's numeric
|
|
42
|
+
* `environment_id`, the same care about environment scoping
|
|
43
|
+
* `findApplicationByName`'s doc comment explains (an unscoped name match
|
|
44
|
+
* risks a false positive against a same-named database in a different
|
|
45
|
+
* environment).
|
|
46
|
+
*/
|
|
47
|
+
export declare function planProvision(coolify: ProvisionCoolifyPort): Promise<ProvisionPlan>;
|
|
48
|
+
/**
|
|
49
|
+
* Makes `plan` true: creates whatever `planProvision` found missing.
|
|
50
|
+
* A no-op read (returns `plan.database`) when the database already exists,
|
|
51
|
+
* so calling this twice never creates a second one.
|
|
52
|
+
*/
|
|
53
|
+
export declare function applyProvision(coolify: ProvisionCoolifyPort, plan: ProvisionPlan): Promise<{
|
|
54
|
+
uuid: string;
|
|
55
|
+
name: string;
|
|
56
|
+
}>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// #R2 (map gap: foundry's own Postgres, two commands): find-or-create the
|
|
2
|
+
// Postgres database `scripts/foundry-db-provision.mjs` migrates into.
|
|
3
|
+
// Project, environment and database names are fixed here rather than taken
|
|
4
|
+
// as input -- one foundry install, one project -- so there is nothing to
|
|
5
|
+
// mistype on the command line.
|
|
6
|
+
export const PROJECT_NAME = 'foundry';
|
|
7
|
+
export const ENVIRONMENT_NAME = 'production';
|
|
8
|
+
export const DATABASE_NAME = 'foundry';
|
|
9
|
+
/**
|
|
10
|
+
* Read-only: works out what `applyProvision` would need to do to make the
|
|
11
|
+
* `PROJECT_NAME`/`ENVIRONMENT_NAME`/`DATABASE_NAME` database exist, without
|
|
12
|
+
* creating anything. Mirrors `plan/executor.ts`'s `coolifyDatabase` step --
|
|
13
|
+
* find the database by name scoped to the environment's numeric
|
|
14
|
+
* `environment_id`, the same care about environment scoping
|
|
15
|
+
* `findApplicationByName`'s doc comment explains (an unscoped name match
|
|
16
|
+
* risks a false positive against a same-named database in a different
|
|
17
|
+
* environment).
|
|
18
|
+
*/
|
|
19
|
+
export async function planProvision(coolify) {
|
|
20
|
+
const project = await coolify.findProjectByName(PROJECT_NAME);
|
|
21
|
+
if (!project) {
|
|
22
|
+
return { projectExists: false, environmentExists: false, databaseExists: false };
|
|
23
|
+
}
|
|
24
|
+
const environments = await coolify.listEnvironments(project.uuid);
|
|
25
|
+
const environment = environments.find((e) => e.name === ENVIRONMENT_NAME);
|
|
26
|
+
if (!environment) {
|
|
27
|
+
return { projectExists: true, project, environmentExists: false, databaseExists: false };
|
|
28
|
+
}
|
|
29
|
+
const databases = await coolify.listDatabases();
|
|
30
|
+
const database = databases.find((db) => db.name === DATABASE_NAME && db.environment_id === environment.id);
|
|
31
|
+
return {
|
|
32
|
+
projectExists: true,
|
|
33
|
+
project,
|
|
34
|
+
environmentExists: true,
|
|
35
|
+
environment,
|
|
36
|
+
databaseExists: Boolean(database),
|
|
37
|
+
database,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Makes `plan` true: creates whatever `planProvision` found missing.
|
|
42
|
+
* A no-op read (returns `plan.database`) when the database already exists,
|
|
43
|
+
* so calling this twice never creates a second one.
|
|
44
|
+
*/
|
|
45
|
+
export async function applyProvision(coolify, plan) {
|
|
46
|
+
if (plan.databaseExists && plan.database)
|
|
47
|
+
return plan.database;
|
|
48
|
+
const project = plan.projectExists && plan.project ? plan.project : await coolify.createProject(PROJECT_NAME);
|
|
49
|
+
const environment = plan.environmentExists && plan.environment
|
|
50
|
+
? plan.environment
|
|
51
|
+
: (await coolify.findOrCreateEnvironment(project.uuid, ENVIRONMENT_NAME)).environment;
|
|
52
|
+
const servers = await coolify.listServers();
|
|
53
|
+
const server = servers.find((s) => s.is_usable && s.is_reachable) ?? servers[0];
|
|
54
|
+
if (!server)
|
|
55
|
+
throw new Error('No usable Coolify server is registered on this instance.');
|
|
56
|
+
// `createPostgresDatabase` also returns `internal_db_url` -- a live
|
|
57
|
+
// credential -- which is deliberately never read off this result: only
|
|
58
|
+
// `uuid` (and the `name` we already know) leaves this function.
|
|
59
|
+
const created = await coolify.createPostgresDatabase({
|
|
60
|
+
project_uuid: project.uuid,
|
|
61
|
+
server_uuid: server.uuid,
|
|
62
|
+
environment_name: environment.name,
|
|
63
|
+
environment_uuid: environment.uuid,
|
|
64
|
+
name: DATABASE_NAME,
|
|
65
|
+
postgres_db: DATABASE_NAME,
|
|
66
|
+
postgres_user: DATABASE_NAME,
|
|
67
|
+
});
|
|
68
|
+
return { uuid: created.uuid, name: DATABASE_NAME };
|
|
69
|
+
}
|
package/dist/slug.d.ts
CHANGED
|
@@ -1,21 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The declared org's slug: a pure validator, no I/O, no store access.
|
|
3
|
-
*
|
|
4
|
-
* Decided in https://github.com/wtfalch/foundry/issues/28, forced by the
|
|
5
|
-
* level-1 apex `oikos.host` being live: Cloudflare's free-tier Universal SSL
|
|
6
|
-
* covers a single wildcard level, so every level-1 org's hostnames are flat
|
|
7
|
-
* (`acme.oikos.host`, `acme-stage.oikos.host`, `acme-pr-12.oikos.host`).
|
|
8
|
-
* That makes the slug a live hostname from the moment the org exists — #7
|
|
9
|
-
* only froze it once the org had mail, which is too late: by then the
|
|
10
|
-
* hostname is already serving. This validator runs at creation, not at
|
|
11
|
-
* first use (#28 "Done when").
|
|
12
|
-
*
|
|
13
|
-
* Kept separate from `org.ts` and `store/postgres.ts` so it can be called
|
|
14
|
-
* from both without either depending on the other: `store/postgres.ts`
|
|
15
|
-
* calls it on `create` and `update` (#28), and anything that needs to
|
|
16
|
-
* pre-validate a slug before it ever reaches the store — the operator page,
|
|
17
|
-
* one day — can import it too.
|
|
18
|
-
*/
|
|
19
1
|
/** The flattened set {@link validateSlug} checks a candidate slug against. */
|
|
20
2
|
export declare const RESERVED_SLUGS: ReadonlySet<string>;
|
|
21
3
|
/**
|
|
@@ -36,3 +18,15 @@ export interface SlugRejection {
|
|
|
36
18
|
* needs one reason reported.
|
|
37
19
|
*/
|
|
38
20
|
export declare function validateSlug(slug: string): SlugRejection | null;
|
|
21
|
+
/**
|
|
22
|
+
* The slug for a new org, derived from its first display name (William,
|
|
23
|
+
* 2026-09-26): "Good Company" becomes `good-company`, and `good-company.oikos.host`
|
|
24
|
+
* is its address. When that slug is taken, reserved or has an environment
|
|
25
|
+
* suffix, a short random suffix is added: `bruh-7k3q`. The display name stays
|
|
26
|
+
* separate, so an org can rename itself without moving its address.
|
|
27
|
+
*
|
|
28
|
+
* An offensive name is refused outright, by the same check the sign-in broker
|
|
29
|
+
* runs on usernames (https://github.com/wtfalch/foundry/issues/103, N1b). A
|
|
30
|
+
* name that could pass as infra (`admin`, `help`) only costs the slug a suffix.
|
|
31
|
+
*/
|
|
32
|
+
export declare function slugFromName(name: string, slugTaken: (slug: string) => Promise<boolean>, suffix?: () => string): Promise<string>;
|