@wtfalch/foundry 0.9.0 → 0.11.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 +16 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -1
- package/dist/migrations/0030_hosting_entry_hostnames.sql +24 -0
- package/dist/migrations/0031_adoptions.sql +37 -0
- package/dist/org.d.ts +49 -4
- package/dist/org.js +134 -1
- package/dist/plan/adopt.d.ts +112 -0
- package/dist/plan/adopt.js +252 -0
- package/dist/plan/adoption-vault.d.ts +9 -0
- package/dist/plan/adoption-vault.js +1 -0
- package/dist/plan/agora.d.ts +7 -0
- package/dist/plan/agora.js +9 -0
- package/dist/plan/broker-entries.d.ts +108 -0
- package/dist/plan/broker-entries.js +214 -0
- package/dist/plan/cloudflared-connector.d.ts +10 -2
- package/dist/plan/cloudflared-connector.js +24 -16
- package/dist/plan/diff.d.ts +14 -0
- package/dist/plan/diff.js +488 -47
- package/dist/plan/executor.d.ts +141 -3
- package/dist/plan/executor.js +1100 -149
- package/dist/plan/hostnames.d.ts +24 -0
- package/dist/plan/hostnames.js +56 -0
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/locked-hostnames.d.ts +8 -0
- package/dist/plan/locked-hostnames.js +26 -0
- package/dist/plan/login-host.d.ts +1 -1
- package/dist/plan/login-host.js +5 -2
- package/dist/plan/plan-apply.d.ts +63 -3
- package/dist/plan/plan-apply.js +163 -5
- package/dist/plan/platform-diff.d.ts +66 -13
- package/dist/plan/platform-diff.js +117 -15
- package/dist/plan/platform-providers.d.ts +95 -2
- package/dist/plan/platform-providers.js +393 -27
- package/dist/plan/providers-readers.d.ts +8 -1
- package/dist/plan/providers-readers.js +58 -4
- package/dist/plan.d.ts +6 -0
- package/dist/store/postgres.d.ts +6 -1
- package/dist/store/postgres.js +80 -5
- package/dist/store/schema.d.ts +162 -0
- package/dist/store/schema.js +19 -1
- package/dist/store.d.ts +37 -0
- package/package.json +1 -1
package/dist/apply.d.ts
CHANGED
|
@@ -11,6 +11,13 @@ import type { AppliedState } from './state.js';
|
|
|
11
11
|
* `@wtfalch/audit` records every plan and apply — who, when, the plan, the
|
|
12
12
|
* result.
|
|
13
13
|
*/
|
|
14
|
+
/** What {@link PlanApply.restore} put back: resource keys and env names, never a value. */
|
|
15
|
+
export interface AdoptionRestored {
|
|
16
|
+
restored: string[];
|
|
17
|
+
envDeleted: string[];
|
|
18
|
+
/** Connector rows whose TUNNEL_TOKEN was left because it is not the one foundry wrote. */
|
|
19
|
+
tokenLeft?: string[];
|
|
20
|
+
}
|
|
14
21
|
export interface PlanApply {
|
|
15
22
|
/** Reads live state from Zitadel, Coolify and Cloudflare and diffs it
|
|
16
23
|
* against the declared org (#19 "Done when"). */
|
|
@@ -38,4 +45,13 @@ export interface PlanApply {
|
|
|
38
45
|
* `destroy` only tears down what's left.
|
|
39
46
|
*/
|
|
40
47
|
destroy(id: OrgId, confirmedOrgName: string): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Puts an adopted app back as the snapshot recorded it (#311): the settings
|
|
50
|
+
* it had, its old env values, and none of the env names foundry added. Marks
|
|
51
|
+
* each adoption `restored`. Refused unless `confirmedOrgName` matches the
|
|
52
|
+
* org's `displayName`, once the snapshot is `confirmed` (the first
|
|
53
|
+
* successful apply after the adopting one), and, before any write, when the
|
|
54
|
+
* snapshot's vault entry is missing.
|
|
55
|
+
*/
|
|
56
|
+
restore(id: OrgId, confirmedOrgName: string): Promise<AdoptionRestored>;
|
|
41
57
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, EnvValue, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, DeclaredOrg, } from './org.js';
|
|
1
|
+
export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, HostingEntryUses, EntryAuth, EnvValue, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, DeclaredOrg, } from './org.js';
|
|
2
2
|
export { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, appEntry, validateBuyerIdentity, validateHosting, validateMail, validateOwner, validateSite, } from './org.js';
|
|
3
3
|
export type { DeclaredPlatform, PlatformService, PlatformServiceKey } from './platform.js';
|
|
4
4
|
export { PLATFORM_SERVICE_KEYS, defaultPlatformHostname, validatePlatform, } from './platform.js';
|
|
@@ -8,9 +8,10 @@ export type { SlugRejectionReason, SlugRejection } from './slug.js';
|
|
|
8
8
|
export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
|
|
9
9
|
export type { Checkpoint, LastApplied, AppliedState } from './state.js';
|
|
10
10
|
export type { PlanStep, UnmanagedItem, Plan } from './plan.js';
|
|
11
|
-
export type { DeclaredOrgStore, PlatformStore, RequestedChangeStore } from './store.js';
|
|
11
|
+
export type { Adoption, AdoptionKind, AdoptionState, AdoptionStore, DeclaredOrgStore, PlatformStore, RequestedChangeStore, } from './store.js';
|
|
12
|
+
export type { AdoptionVault } from './plan/adoption-vault.js';
|
|
12
13
|
export type { PlanApply } from './apply.js';
|
|
13
|
-
export { createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
|
|
14
|
+
export { createPostgresAdoptionStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
|
|
14
15
|
export { createPostgresAuditLog } from './store/audit.js';
|
|
15
16
|
export type { FoundryStoreDb } from './store/postgres.js';
|
|
16
17
|
export { declaredOrgs, foundryAppliedState, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, type DeclaredPlatformRow, type NewDeclaredPlatformRow, type RequestedChangeRow, type NewRequestedChangeRow, } from './store/schema.js';
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@ export { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, appEntry, validateBuyerIdentity, vali
|
|
|
2
2
|
export { PLATFORM_SERVICE_KEYS, defaultPlatformHostname, validatePlatform, } from './platform.js';
|
|
3
3
|
export { REQUESTABLE_FIELDS, classifyChange, RequestedChangeError } from './requested-changes.js';
|
|
4
4
|
export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
|
|
5
|
-
export { createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
|
|
5
|
+
export { createPostgresAdoptionStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
|
|
6
6
|
export { createPostgresAuditLog } from './store/audit.js';
|
|
7
7
|
export { declaredOrgs, foundryAppliedState, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
|
|
8
8
|
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
-- @wtfalch/foundry: each hosting entry names where it is served (org.ts's
|
|
2
|
+
-- `HostingEntry.hostnames`). Mirrors `src/store/schema.ts`'s `hostingEntries`.
|
|
3
|
+
--
|
|
4
|
+
-- The `app` entry of an org with hosting.usesDomain on is served at the org's
|
|
5
|
+
-- domain, so it gets ['@']; every other entry, and the `app` entry of an org
|
|
6
|
+
-- with usesDomain off (served at <slug>.oikos.host), gets []. An org planned
|
|
7
|
+
-- before this migration therefore plans the same names after it.
|
|
8
|
+
--
|
|
9
|
+
-- Copies and never drops: no column changes, only entries without a
|
|
10
|
+
-- `hostnames` field gain one. Safe to run twice: an entry that has the field
|
|
11
|
+
-- is left as it is.
|
|
12
|
+
|
|
13
|
+
update foundry_declared_orgs o
|
|
14
|
+
set hosting_entries = (
|
|
15
|
+
select coalesce(jsonb_agg(
|
|
16
|
+
case when e ? 'hostnames' then e
|
|
17
|
+
else e || jsonb_build_object(
|
|
18
|
+
'hostnames',
|
|
19
|
+
case when e ->> 'key' = 'app' and o.hosting_uses_domain
|
|
20
|
+
then '["@"]'::jsonb else '[]'::jsonb end)
|
|
21
|
+
end order by t.ord), '[]'::jsonb)
|
|
22
|
+
from jsonb_array_elements(o.hosting_entries) with ordinality as t(e, ord))
|
|
23
|
+
where exists (
|
|
24
|
+
select 1 from jsonb_array_elements(o.hosting_entries) e where not (e ? 'hostnames'));
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
-- @wtfalch/foundry: what foundry recorded before it first changed a live
|
|
2
|
+
-- resource it took over in place (#311), and the org's declared live
|
|
3
|
+
-- database. Mirrors `src/store/schema.ts`'s `foundryAdoptions` and
|
|
4
|
+
-- `declaredOrgs.hostingAdopt`.
|
|
5
|
+
--
|
|
6
|
+
-- `settings` holds non-secret settings only. The secret part of the snapshot
|
|
7
|
+
-- (env values) lives in the host's vault under `vault_ref`; no env value, URL
|
|
8
|
+
-- or secret is ever written here. `kind` lists every kind the later slices add
|
|
9
|
+
-- (tunnel, connector, access) so none of them needs to change this check.
|
|
10
|
+
-- `state` moves adopted -> confirmed or restored or released. A row goes with
|
|
11
|
+
-- its org (`on delete cascade`), so destroying a test org needs no extra step.
|
|
12
|
+
--
|
|
13
|
+
-- `hosting_adopt` is `HostingConfig.adopt`, null when the org adopts nothing.
|
|
14
|
+
-- An entry's own `adopt` lives inside `hosting_entries` and needs no column.
|
|
15
|
+
--
|
|
16
|
+
-- Safe to run twice: nothing is dropped or rewritten.
|
|
17
|
+
|
|
18
|
+
create table if not exists foundry_adoptions (
|
|
19
|
+
org_id text not null,
|
|
20
|
+
resource_key text not null,
|
|
21
|
+
kind text not null,
|
|
22
|
+
taken_at timestamptz not null,
|
|
23
|
+
settings jsonb not null default '{}'::jsonb,
|
|
24
|
+
vault_ref text,
|
|
25
|
+
state text not null default 'adopted',
|
|
26
|
+
|
|
27
|
+
constraint foundry_adoptions_pkey primary key (org_id, resource_key),
|
|
28
|
+
constraint foundry_adoptions_org_fkey
|
|
29
|
+
foreign key (org_id) references foundry_declared_orgs (id) on delete cascade,
|
|
30
|
+
constraint foundry_adoptions_kind_check
|
|
31
|
+
check (kind in ('app', 'database', 'tunnel', 'connector', 'access')),
|
|
32
|
+
constraint foundry_adoptions_state_check
|
|
33
|
+
check (state in ('adopted', 'confirmed', 'restored', 'released'))
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
alter table foundry_declared_orgs
|
|
37
|
+
add column if not exists hosting_adopt jsonb;
|
package/dist/org.d.ts
CHANGED
|
@@ -196,9 +196,6 @@ export type HostingBuildPack = 'dockerfile' | 'nixpacks' | 'static' | 'dockercom
|
|
|
196
196
|
* its apps. Each app is one of `entries`. `tunnel` is meaningless without the
|
|
197
197
|
* apps running, so `validateHosting` below requires `enabled` before it can
|
|
198
198
|
* be true.
|
|
199
|
-
*
|
|
200
|
-
* Until the planner reads more than one entry, only the entry keyed
|
|
201
|
-
* {@link APP_ENTRY_KEY} is planned.
|
|
202
199
|
*/
|
|
203
200
|
export interface HostingConfig {
|
|
204
201
|
/** The org's apps run on Coolify. */
|
|
@@ -211,6 +208,15 @@ export interface HostingConfig {
|
|
|
211
208
|
issuerApps: IssuerApp[];
|
|
212
209
|
/** One entry per app the org runs. */
|
|
213
210
|
entries: HostingEntry[];
|
|
211
|
+
/** Live resources foundry takes over in place instead of creating. */
|
|
212
|
+
adopt?: {
|
|
213
|
+
/** The Coolify uuid of the org's live database, taken over as `<slug>-db`. */
|
|
214
|
+
database?: string;
|
|
215
|
+
/** The id of the org's live Cloudflare tunnel: its routes are merged, never overwritten (#312). Requires `tunnel`. */
|
|
216
|
+
tunnel?: string;
|
|
217
|
+
/** The Coolify uuid of the live `cloudflared` app that dials into `tunnel` (#312). Requires `tunnel`. */
|
|
218
|
+
connectorApp?: string;
|
|
219
|
+
};
|
|
214
220
|
}
|
|
215
221
|
/** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
|
|
216
222
|
export declare const APP_ENTRY_KEY = "app";
|
|
@@ -234,6 +240,15 @@ export interface HostingEntry {
|
|
|
234
240
|
port: number | null;
|
|
235
241
|
/** The path that answers 200 when the app is healthy. `null` is none. */
|
|
236
242
|
healthPath: string | null;
|
|
243
|
+
/**
|
|
244
|
+
* The names the entry is served at: `'@'` (the org's domain), a label under
|
|
245
|
+
* the domain, or `'*'` (reserved: a later change acts on it). Empty means an
|
|
246
|
+
* internal app, reached as `<slug>-<key>` on the Coolify network. Read only
|
|
247
|
+
* when `HostingConfig.usesDomain` is on and the org has a domain; otherwise
|
|
248
|
+
* the `app` entry is served at `<slug>.oikos.host` and no other entry may
|
|
249
|
+
* set any.
|
|
250
|
+
*/
|
|
251
|
+
hostnames: string[];
|
|
237
252
|
/** A Postgres database beside the app. At most one entry per org. */
|
|
238
253
|
database: boolean;
|
|
239
254
|
/** Deploy on every push to `branch`. Default: `true`. */
|
|
@@ -251,6 +266,27 @@ export interface HostingEntry {
|
|
|
251
266
|
* this map.
|
|
252
267
|
*/
|
|
253
268
|
env: Record<string, EnvValue>;
|
|
269
|
+
/** Takes over the live Coolify app with this uuid instead of creating one. */
|
|
270
|
+
adopt?: {
|
|
271
|
+
app: string;
|
|
272
|
+
};
|
|
273
|
+
/** What the entry signs in through. Absent means it uses nothing. */
|
|
274
|
+
uses?: HostingEntryUses;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Sign-in for one entry: one ZITADEL client and one broker entry, signing in
|
|
278
|
+
* ONE organisation. `staff` is the org's own ZITADEL org, `customers` the
|
|
279
|
+
* `<slug> customers` org. `registration` is the broker entry's sign-up switch.
|
|
280
|
+
* `replacesBrokerEntry` is the `name` of the hand-written broker `AUTH_APPS`
|
|
281
|
+
* entry this entry's own entry replaces; the first write removes it.
|
|
282
|
+
*/
|
|
283
|
+
export interface EntryAuth {
|
|
284
|
+
users: 'staff' | 'customers';
|
|
285
|
+
registration: 'open' | 'closed';
|
|
286
|
+
replacesBrokerEntry?: string;
|
|
287
|
+
}
|
|
288
|
+
export interface HostingEntryUses {
|
|
289
|
+
auth?: EntryAuth;
|
|
254
290
|
}
|
|
255
291
|
/**
|
|
256
292
|
* A plain string is pushed as it is, so `validateHosting` refuses one that
|
|
@@ -281,8 +317,17 @@ export declare const APP_ENV_SOURCES: ReadonlySet<AppEnvSource>;
|
|
|
281
317
|
* - Every `entries[]` entry follows {@link validateHostingEntry}; keys are
|
|
282
318
|
* unique, and at most one entry has a `database`, because the one
|
|
283
319
|
* `<slug>_rt` runtime role cannot serve two apps.
|
|
320
|
+
* - An adopted uuid (`entries[].adopt.app`, `adopt.database`) is non-empty
|
|
321
|
+
* and only lowercase letters and digits (it goes into a URL path), and no two
|
|
322
|
+
* entries adopt the same app. `adopt.database` needs an entry with `database`.
|
|
323
|
+
* - `adopt.tunnel` is a Cloudflare tunnel id (a UUID), `adopt.connectorApp` a
|
|
324
|
+
* Coolify uuid that no entry or database adopts. `connectorApp` needs
|
|
325
|
+
* `tunnel`, and both need `hosting.tunnel` (#312).
|
|
284
326
|
*/
|
|
285
|
-
export declare function validateHosting(hosting: HostingConfig
|
|
327
|
+
export declare function validateHosting(hosting: HostingConfig, names?: {
|
|
328
|
+
domain: string | null;
|
|
329
|
+
locked: ReadonlySet<string>;
|
|
330
|
+
}): string | null;
|
|
286
331
|
export declare function validateDomain(domain: string): string | null;
|
|
287
332
|
/**
|
|
288
333
|
* `null` when `site` is valid; otherwise the reason it is not (#101).
|
package/dist/org.js
CHANGED
|
@@ -13,6 +13,9 @@ export const APP_ENV_SOURCES = new Set([
|
|
|
13
13
|
'aiOrganisationId',
|
|
14
14
|
'mailDomain',
|
|
15
15
|
]);
|
|
16
|
+
const ADOPT_UUID = /^[a-z0-9]+$/;
|
|
17
|
+
/** A Cloudflare tunnel id: a lowercase UUID with hyphens (#312). */
|
|
18
|
+
const TUNNEL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
16
19
|
const ISSUER_APP_KINDS = new Set(['web', 'native', 'api']);
|
|
17
20
|
const HOSTING_BUILD_PACKS = new Set([
|
|
18
21
|
'dockerfile',
|
|
@@ -31,8 +34,14 @@ const HOSTING_BUILD_PACKS = new Set([
|
|
|
31
34
|
* - Every `entries[]` entry follows {@link validateHostingEntry}; keys are
|
|
32
35
|
* unique, and at most one entry has a `database`, because the one
|
|
33
36
|
* `<slug>_rt` runtime role cannot serve two apps.
|
|
37
|
+
* - An adopted uuid (`entries[].adopt.app`, `adopt.database`) is non-empty
|
|
38
|
+
* and only lowercase letters and digits (it goes into a URL path), and no two
|
|
39
|
+
* entries adopt the same app. `adopt.database` needs an entry with `database`.
|
|
40
|
+
* - `adopt.tunnel` is a Cloudflare tunnel id (a UUID), `adopt.connectorApp` a
|
|
41
|
+
* Coolify uuid that no entry or database adopts. `connectorApp` needs
|
|
42
|
+
* `tunnel`, and both need `hosting.tunnel` (#312).
|
|
34
43
|
*/
|
|
35
|
-
export function validateHosting(hosting) {
|
|
44
|
+
export function validateHosting(hosting, names) {
|
|
36
45
|
if (hosting.tunnel && !hosting.enabled) {
|
|
37
46
|
return 'hosting.tunnel requires hosting.enabled';
|
|
38
47
|
}
|
|
@@ -60,7 +69,27 @@ export function validateHosting(hosting) {
|
|
|
60
69
|
if (hosting.enabled && !appEntry(hosting)) {
|
|
61
70
|
return `hosting.enabled requires an entry keyed '${APP_ENTRY_KEY}'`;
|
|
62
71
|
}
|
|
72
|
+
if (hosting.adopt?.database !== undefined && !hosting.adopt.database) {
|
|
73
|
+
return 'hosting.adopt.database needs a non-empty uuid';
|
|
74
|
+
}
|
|
75
|
+
if (hosting.adopt?.database && !ADOPT_UUID.test(hosting.adopt.database)) {
|
|
76
|
+
return 'hosting.adopt.database must be lowercase letters and digits only';
|
|
77
|
+
}
|
|
78
|
+
const { tunnel: adoptedTunnel, connectorApp } = hosting.adopt ?? {};
|
|
79
|
+
if (adoptedTunnel !== undefined && !TUNNEL_ID.test(adoptedTunnel)) {
|
|
80
|
+
return 'hosting.adopt.tunnel must be a Cloudflare tunnel id (a lowercase UUID with hyphens)';
|
|
81
|
+
}
|
|
82
|
+
if (connectorApp !== undefined && !ADOPT_UUID.test(connectorApp)) {
|
|
83
|
+
return 'hosting.adopt.connectorApp must be a non-empty uuid of lowercase letters and digits only';
|
|
84
|
+
}
|
|
85
|
+
if ((connectorApp === undefined) !== (adoptedTunnel === undefined)) {
|
|
86
|
+
return 'hosting.adopt.tunnel and hosting.adopt.connectorApp must be declared together';
|
|
87
|
+
}
|
|
88
|
+
if ((adoptedTunnel !== undefined || connectorApp !== undefined) && !hosting.tunnel) {
|
|
89
|
+
return 'hosting.adopt.tunnel and hosting.adopt.connectorApp require hosting.tunnel';
|
|
90
|
+
}
|
|
63
91
|
const seenKeys = new Set();
|
|
92
|
+
const seenAdopted = new Set();
|
|
64
93
|
for (const entry of hosting.entries) {
|
|
65
94
|
if (seenKeys.has(entry.key)) {
|
|
66
95
|
return `hosting.entries key '${entry.key}' is not unique`;
|
|
@@ -69,12 +98,76 @@ export function validateHosting(hosting) {
|
|
|
69
98
|
const invalid = validateHostingEntry(entry, hosting.enabled);
|
|
70
99
|
if (invalid)
|
|
71
100
|
return invalid;
|
|
101
|
+
if (entry.adopt) {
|
|
102
|
+
if (!entry.adopt.app)
|
|
103
|
+
return `hosting entry '${entry.key}' adopt.app needs a non-empty uuid`;
|
|
104
|
+
if (!ADOPT_UUID.test(entry.adopt.app)) {
|
|
105
|
+
return `hosting entry '${entry.key}' adopt.app must be lowercase letters and digits only`;
|
|
106
|
+
}
|
|
107
|
+
if (seenAdopted.has(entry.adopt.app)) {
|
|
108
|
+
return `hosting entry '${entry.key}' adopts an app another entry already adopts`;
|
|
109
|
+
}
|
|
110
|
+
seenAdopted.add(entry.adopt.app);
|
|
111
|
+
if (entry.adopt.app === hosting.adopt?.database) {
|
|
112
|
+
return `hosting entry '${entry.key}' adopts the same uuid as hosting.adopt.database`;
|
|
113
|
+
}
|
|
114
|
+
if (entry.database && !hosting.adopt?.database) {
|
|
115
|
+
return `hosting entry '${entry.key}' adopts its app and sets database, so hosting.adopt.database must name the live database`;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const invalidAuth = validateEntryAuth(entry, hosting, names?.domain);
|
|
119
|
+
if (invalidAuth)
|
|
120
|
+
return invalidAuth;
|
|
121
|
+
}
|
|
122
|
+
if (hosting.adopt?.database && !hosting.entries.some((entry) => entry.database)) {
|
|
123
|
+
return 'hosting.adopt.database is set, so an entry must set database';
|
|
72
124
|
}
|
|
125
|
+
if (connectorApp && (seenAdopted.has(connectorApp) || connectorApp === hosting.adopt?.database)) {
|
|
126
|
+
return 'hosting.adopt.connectorApp is the uuid of an adopted app or database';
|
|
127
|
+
}
|
|
128
|
+
const invalidNames = validateEntryHostnames(hosting, names);
|
|
129
|
+
if (invalidNames)
|
|
130
|
+
return invalidNames;
|
|
73
131
|
if (hosting.entries.filter((entry) => entry.database).length > 1) {
|
|
74
132
|
return 'at most one hosting entry may set database: the one runtime database role cannot serve two apps';
|
|
75
133
|
}
|
|
76
134
|
return null;
|
|
77
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* The rules for the names of every entry: each is `@`, `*` or a DNS label, no
|
|
138
|
+
* name twice, none on a name foundry owns (`plan/locked-hostnames.ts`), and
|
|
139
|
+
* none at all on a non-`app` entry while `usesDomain` is off. `names` is what
|
|
140
|
+
* the caller knows of the org; without it only the checks that need no domain
|
|
141
|
+
* run.
|
|
142
|
+
*/
|
|
143
|
+
function validateEntryHostnames(hosting, names) {
|
|
144
|
+
const taken = new Set();
|
|
145
|
+
for (const entry of hosting.entries) {
|
|
146
|
+
const at = `hosting.entries '${entry.key}'`;
|
|
147
|
+
if (!hosting.usesDomain && entry.key !== APP_ENTRY_KEY && entry.hostnames.length > 0) {
|
|
148
|
+
return `${at} hostnames need hosting.usesDomain: without it only the '${APP_ENTRY_KEY}' entry is served, at its own address`;
|
|
149
|
+
}
|
|
150
|
+
const reads = hosting.usesDomain && names?.domain;
|
|
151
|
+
if (reads && entry.key === APP_ENTRY_KEY && !entry.hostnames.some((n) => n !== '*')) {
|
|
152
|
+
return `${at} needs a hostname ('@' or a label) while hosting.usesDomain is on: '*' alone names nothing, and the app would fall back to the bare domain`;
|
|
153
|
+
}
|
|
154
|
+
for (const name of entry.hostnames) {
|
|
155
|
+
if (name !== '@' && name !== '*' && !ENTRY_KEY.test(name)) {
|
|
156
|
+
return `${at} hostname '${name}' must be '@', '*' or a DNS label`;
|
|
157
|
+
}
|
|
158
|
+
if (taken.has(name))
|
|
159
|
+
return `hostname '${name}' is held by two entries or listed twice`;
|
|
160
|
+
taken.add(name);
|
|
161
|
+
if (names?.domain &&
|
|
162
|
+
name !== '@' &&
|
|
163
|
+
name !== '*' &&
|
|
164
|
+
names.locked.has(`${name}.${names.domain}`)) {
|
|
165
|
+
return `${at} hostname '${name}' is a name foundry owns: ${name}.${names.domain}`;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
78
171
|
const ENTRY_KEY = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
79
172
|
const REPO = /^wtfalch\/[A-Za-z0-9._-]+$/;
|
|
80
173
|
// The env names `pushAppEnv` and `pushDatabaseUrl` set themselves.
|
|
@@ -141,6 +234,46 @@ function validateHostingEntry(entry, enabled) {
|
|
|
141
234
|
}
|
|
142
235
|
return null;
|
|
143
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* `uses.auth`: its two switches are known values, sign-up is never open
|
|
239
|
+
* into the staff organisation, the entry has an exact
|
|
240
|
+
* hostname to sign in at (a `*` or an internal entry has none), and the
|
|
241
|
+
* client's name, which is the entry key, is not one another client holds.
|
|
242
|
+
*/
|
|
243
|
+
function validateEntryAuth(entry, hosting, domain) {
|
|
244
|
+
const auth = entry.uses?.auth;
|
|
245
|
+
if (!auth)
|
|
246
|
+
return null;
|
|
247
|
+
const at = `hosting.entries '${entry.key}' uses.auth`;
|
|
248
|
+
if (!hosting.enabled)
|
|
249
|
+
return `${at} requires hosting.enabled`;
|
|
250
|
+
if (auth.users !== 'staff' && auth.users !== 'customers') {
|
|
251
|
+
return `${at} users must be 'staff' or 'customers'`;
|
|
252
|
+
}
|
|
253
|
+
if (auth.registration !== 'open' && auth.registration !== 'closed') {
|
|
254
|
+
return `${at} registration must be 'open' or 'closed'`;
|
|
255
|
+
}
|
|
256
|
+
// The staff organisation holds the org's own people and its project.
|
|
257
|
+
if (auth.users === 'staff' && auth.registration === 'open') {
|
|
258
|
+
return `${at} registration 'open' needs users 'customers': nobody signs themselves up as staff`;
|
|
259
|
+
}
|
|
260
|
+
if (auth.replacesBrokerEntry !== undefined &&
|
|
261
|
+
(typeof auth.replacesBrokerEntry !== 'string' || auth.replacesBrokerEntry === '')) {
|
|
262
|
+
return `${at} replacesBrokerEntry must be the non-empty name of a broker entry`;
|
|
263
|
+
}
|
|
264
|
+
// With no domain the hostnames are not read (`entryHostnames`).
|
|
265
|
+
const hasName = hosting.usesDomain && domain !== null
|
|
266
|
+
? entry.hostnames.some((name) => name !== '*')
|
|
267
|
+
: entry.key === APP_ENTRY_KEY;
|
|
268
|
+
if (!hasName)
|
|
269
|
+
return `${at} needs an exact hostname to sign in at`;
|
|
270
|
+
if (entry.key === 'agora' ||
|
|
271
|
+
entry.key === 'cms' ||
|
|
272
|
+
hosting.issuerApps.some((app) => app.name === entry.key)) {
|
|
273
|
+
return `${at} client name '${entry.key}' is already held by another ZITADEL client`;
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
144
277
|
function urlHasPassword(value) {
|
|
145
278
|
try {
|
|
146
279
|
return new URL(value).password !== '';
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { DeclaredOrg, HostingEntry } from '../org.js';
|
|
2
|
+
import type { Adoption } from '../store.js';
|
|
3
|
+
/**
|
|
4
|
+
* Adoption (#311): foundry takes over a hand-made Coolify app or database
|
|
5
|
+
* where it is. Nothing in this file holds an env value: an env var is named,
|
|
6
|
+
* never quoted, in a detail line, an error or a blocked item (rule 6).
|
|
7
|
+
*/
|
|
8
|
+
interface AdoptedBase {
|
|
9
|
+
/** The `foundry_adoptions.resource_key`, and the `coolify:adopt` step's key. */
|
|
10
|
+
resourceKey: string;
|
|
11
|
+
uuid: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* One app (with the entry that adopts it) or database an org declares by uuid,
|
|
15
|
+
* or (#312) its Cloudflare tunnel (`uuid` is the tunnel id) or the Coolify app
|
|
16
|
+
* that runs `cloudflared` for it.
|
|
17
|
+
*/
|
|
18
|
+
export type AdoptedResource = (AdoptedBase & {
|
|
19
|
+
kind: 'database';
|
|
20
|
+
}) | (AdoptedBase & {
|
|
21
|
+
kind: 'app';
|
|
22
|
+
entry: HostingEntry;
|
|
23
|
+
}) | (AdoptedBase & {
|
|
24
|
+
kind: 'tunnel';
|
|
25
|
+
}) | (AdoptedBase & {
|
|
26
|
+
kind: 'connector';
|
|
27
|
+
});
|
|
28
|
+
/** The `foundry_adoptions.resource_key` of the org's adopted tunnel: also its `serviceIds` key (#312). */
|
|
29
|
+
export declare const adoptedTunnelKey: (org: DeclaredOrg) => string;
|
|
30
|
+
/** The `foundry_adoptions.resource_key` of the org's adopted connector app (#312). */
|
|
31
|
+
export declare const adoptedConnectorKey: (org: DeclaredOrg) => string;
|
|
32
|
+
/** Every resource the org adopts, the database first, then the apps, the tunnel and its connector. */
|
|
33
|
+
export declare function adoptedResources(org: DeclaredOrg): AdoptedResource[];
|
|
34
|
+
/**
|
|
35
|
+
* Every uuid the org declares as adopted, whether or not hosting is enabled
|
|
36
|
+
* now: a uuid in this set is never stopped or deleted by foundry (#311).
|
|
37
|
+
*/
|
|
38
|
+
export declare function declaredAdoptUuids(org: DeclaredOrg): Set<string>;
|
|
39
|
+
/**
|
|
40
|
+
* A snapshot is ACTIVE when its row is `adopted` or `confirmed` and the uuid
|
|
41
|
+
* it recorded is the uuid the org declares now (#311). Only then may foundry
|
|
42
|
+
* write to the live resource.
|
|
43
|
+
*/
|
|
44
|
+
export declare function isActiveSnapshot(row: Adoption | null | undefined, uuid: string): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* A row in `adopted`, `confirmed` or `restored` protects its key: foundry never
|
|
47
|
+
* manages, stops or deletes that resource as its own. Only `released` lets go (#311).
|
|
48
|
+
*/
|
|
49
|
+
export declare function protectsKey(row: Adoption | null | undefined): boolean;
|
|
50
|
+
/** Where the secret part of a snapshot lives in the `AdoptionVault`. */
|
|
51
|
+
export declare function adoptionVaultRef(org: DeclaredOrg, resource: AdoptedResource): string;
|
|
52
|
+
export type LiveApp = Record<string, unknown>;
|
|
53
|
+
export declare function snapshotAppSettings(live: LiveApp): Record<string, unknown>;
|
|
54
|
+
export declare const bareName: (name: string) => string;
|
|
55
|
+
export declare const liveNames: (live: LiveApp) => string[];
|
|
56
|
+
/**
|
|
57
|
+
* What an adopted entry changes on its live app: only a setting it declares
|
|
58
|
+
* that differs from live. The entry cannot tell a declared `branch`,
|
|
59
|
+
* `buildPack` or `autoDeploy` from its default, so those stay as they are; a
|
|
60
|
+
* `null` folder or port and an empty name list declare nothing. Never
|
|
61
|
+
* `custom_network_aliases`.
|
|
62
|
+
*/
|
|
63
|
+
export declare function adoptedAppPatch(entry: HostingEntry, names: readonly string[], live: LiveApp): Record<string, unknown>;
|
|
64
|
+
/** One line per setting the patch changes, old value beside new. */
|
|
65
|
+
export declare function adoptedPatchDetails(patch: Record<string, unknown>, live: LiveApp): string[];
|
|
66
|
+
/**
|
|
67
|
+
* The PATCH body that puts an app's snapshot settings back. A field is sent
|
|
68
|
+
* only when the entry's declaration would have changed it from the snapshot
|
|
69
|
+
* (`adoptedAppPatch`), so a setting foundry never touched, or a person edited
|
|
70
|
+
* later, stays. With no entry, a field is sent when `live` differs from the
|
|
71
|
+
* snapshot. Coolify reads `fqdn` but takes `domains`. A field the snapshot had
|
|
72
|
+
* as null is cleared the way the plain app path clears it: a folder back to
|
|
73
|
+
* `/`, domains to the empty list. A port is never cleared, as on that path.
|
|
74
|
+
*/
|
|
75
|
+
export declare function restoreAppPatch(entry: HostingEntry | undefined, names: readonly string[], settings: Record<string, unknown>, live: LiveApp): Record<string, unknown>;
|
|
76
|
+
/** One route of a tunnel's ingress list. Other fields (`originRequest`, `path`) ride along untouched. */
|
|
77
|
+
export interface IngressRoute {
|
|
78
|
+
hostname?: string;
|
|
79
|
+
service: string;
|
|
80
|
+
[field: string]: unknown;
|
|
81
|
+
}
|
|
82
|
+
/** A hostname as compared: lower case, no trailing dot (DNS names ignore both). */
|
|
83
|
+
export declare const normalizeHostname: (hostname: string) => string;
|
|
84
|
+
/**
|
|
85
|
+
* What the merge leaves alone: every live route that is not for a declared
|
|
86
|
+
* name and not foundry's own route for a name it dropped. Whatever its shape:
|
|
87
|
+
* a hostname, only a `path`, or neither.
|
|
88
|
+
*/
|
|
89
|
+
export declare function foreignRoutes(live: readonly IngressRoute[], declared: readonly string[], service: string, written: ReadonlySet<string>): IngressRoute[];
|
|
90
|
+
/** The declared names whose live routes the merge replaces: there are some, and they are not already foundry's one route. */
|
|
91
|
+
export declare function replacedNames(live: readonly IngressRoute[], declared: readonly string[], service: string): string[];
|
|
92
|
+
/** The names foundry removes from `live`: written, no longer declared, still its own route. */
|
|
93
|
+
export declare const removedNames: (live: readonly IngressRoute[], declared: readonly string[], service: string, written: ReadonlySet<string>) => string[];
|
|
94
|
+
/**
|
|
95
|
+
* The ingress list an adopted tunnel gets (#312): the declared exact names,
|
|
96
|
+
* then every foreign live route in its live order and unchanged, with the
|
|
97
|
+
* snapshot's routes for a name foundry dropped put back among them, then the
|
|
98
|
+
* declared wildcards, then the live catch-all as it is. Only a live list that
|
|
99
|
+
* does not end in a catch-all gets `http_status:404`.
|
|
100
|
+
*/
|
|
101
|
+
export declare function mergeIngress(live: readonly IngressRoute[], declared: readonly string[], service: string, written: ReadonlySet<string>, snapshot: readonly IngressRoute[]): IngressRoute[];
|
|
102
|
+
/**
|
|
103
|
+
* The ingress list restore writes (#312): the live list without foundry's own
|
|
104
|
+
* route for a name it wrote (`written` accumulates for the life of the
|
|
105
|
+
* snapshot), plus the snapshot's routes for that name. A name with no live
|
|
106
|
+
* route that is exactly foundry's is left alone, as is a route a person added
|
|
107
|
+
* or edited, and the live catch-all. The `http_status:404` foundry appended is
|
|
108
|
+
* removed only while it is still last and the snapshot had no catch-all; an
|
|
109
|
+
* empty snapshot keeps the live catch-all, or gets one.
|
|
110
|
+
*/
|
|
111
|
+
export declare function restoreIngress(live: readonly IngressRoute[], snapshot: readonly IngressRoute[], service: string, written: ReadonlySet<string>): IngressRoute[];
|
|
112
|
+
export {};
|