@wtfalch/foundry 0.12.0 → 0.14.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 +0 -16
- package/dist/index.d.ts +2 -3
- package/dist/index.js +1 -1
- package/dist/migrations/0032_drop_adoptions.sql +16 -0
- package/dist/migrations/0033_declared_org_version.sql +13 -0
- package/dist/org.d.ts +6 -19
- package/dist/org.js +0 -52
- package/dist/plan/agora.d.ts +63 -1
- package/dist/plan/agora.js +91 -1
- package/dist/plan/broker-entries.d.ts +1 -5
- package/dist/plan/cloudflared-connector.d.ts +2 -10
- package/dist/plan/cloudflared-connector.js +16 -24
- package/dist/plan/diff.d.ts +0 -12
- package/dist/plan/diff.js +77 -239
- package/dist/plan/executor.d.ts +20 -93
- package/dist/plan/executor.js +208 -918
- package/dist/plan/plan-apply.d.ts +3 -47
- package/dist/plan/plan-apply.js +9 -125
- package/dist/plan/platform-diff.d.ts +0 -6
- package/dist/plan/platform-diff.js +0 -8
- package/dist/plan/providers-readers.d.ts +1 -8
- package/dist/plan/providers-readers.js +4 -58
- package/dist/plan.d.ts +0 -6
- package/dist/store/postgres.d.ts +1 -6
- package/dist/store/postgres.js +25 -74
- package/dist/store/schema.d.ts +17 -162
- package/dist/store/schema.js +3 -19
- package/dist/store.d.ts +18 -37
- package/package.json +1 -1
- package/dist/plan/adopt.d.ts +0 -112
- package/dist/plan/adopt.js +0 -252
- package/dist/plan/adoption-vault.d.ts +0 -9
- package/dist/plan/adoption-vault.js +0 -1
package/dist/apply.d.ts
CHANGED
|
@@ -11,13 +11,6 @@ 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
|
-
}
|
|
21
14
|
export interface PlanApply {
|
|
22
15
|
/** Reads live state from Zitadel, Coolify and Cloudflare and diffs it
|
|
23
16
|
* against the declared org (#19 "Done when"). */
|
|
@@ -45,13 +38,4 @@ export interface PlanApply {
|
|
|
45
38
|
* `destroy` only tears down what's left.
|
|
46
39
|
*/
|
|
47
40
|
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>;
|
|
57
41
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -8,10 +8,9 @@ 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 {
|
|
12
|
-
export type { AdoptionVault } from './plan/adoption-vault.js';
|
|
11
|
+
export type { DeclaredOrgStore, PlatformStore, RequestedChangeStore, StaleUpdate, UpdateOptions, } from './store.js';
|
|
13
12
|
export type { PlanApply } from './apply.js';
|
|
14
|
-
export {
|
|
13
|
+
export { createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
|
|
15
14
|
export { createPostgresAuditLog } from './store/audit.js';
|
|
16
15
|
export type { FoundryStoreDb } from './store/postgres.js';
|
|
17
16
|
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 {
|
|
5
|
+
export { 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,16 @@
|
|
|
1
|
+
-- @wtfalch/foundry: removes what 0031 added. Adoption is gone: every org is
|
|
2
|
+
-- provisioned fresh (2026-10-02). 0031 shipped in 0.11.0 and 0.12.0, so it
|
|
3
|
+
-- stays in place and this migration drops its table and column.
|
|
4
|
+
--
|
|
5
|
+
-- Drops `foundry_adoptions` (its rows only recorded snapshots for resources
|
|
6
|
+
-- foundry took over in place) and `foundry_declared_orgs.hosting_adopt`.
|
|
7
|
+
-- Nothing else reads either. An entry's own `adopt` key inside
|
|
8
|
+
-- `hosting_entries` is jsonb that the code now ignores, so it needs no change.
|
|
9
|
+
--
|
|
10
|
+
-- Safe to run twice, and on a database that never ran 0031: every drop is
|
|
11
|
+
-- `if exists`.
|
|
12
|
+
|
|
13
|
+
drop table if exists foundry_adoptions;
|
|
14
|
+
|
|
15
|
+
alter table foundry_declared_orgs
|
|
16
|
+
drop column if exists hosting_adopt;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
-- @wtfalch/foundry: a version counter on the declared org, for a
|
|
2
|
+
-- compare-and-set write (#351). Mirrors `src/store/schema.ts`'s
|
|
3
|
+
-- `declaredOrgs.version`.
|
|
4
|
+
--
|
|
5
|
+
-- Every write through the store bumps it by 1. `DeclaredOrgStore.update` with
|
|
6
|
+
-- an expected version writes `where id = $1 and version = $2` in one
|
|
7
|
+
-- statement, so a caller that read the org, planned, and writes back cannot
|
|
8
|
+
-- overwrite an edit that landed in between. Existing rows start at 1.
|
|
9
|
+
--
|
|
10
|
+
-- Additive, and safe to run twice.
|
|
11
|
+
|
|
12
|
+
alter table foundry_declared_orgs
|
|
13
|
+
add column if not exists version integer not null default 1;
|
package/dist/org.d.ts
CHANGED
|
@@ -208,15 +208,6 @@ export interface HostingConfig {
|
|
|
208
208
|
issuerApps: IssuerApp[];
|
|
209
209
|
/** One entry per app the org runs. */
|
|
210
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
|
-
};
|
|
220
211
|
}
|
|
221
212
|
/** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
|
|
222
213
|
export declare const APP_ENTRY_KEY = "app";
|
|
@@ -266,10 +257,6 @@ export interface HostingEntry {
|
|
|
266
257
|
* this map.
|
|
267
258
|
*/
|
|
268
259
|
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
260
|
/** What the entry signs in through. Absent means it uses nothing. */
|
|
274
261
|
uses?: HostingEntryUses;
|
|
275
262
|
}
|
|
@@ -317,12 +304,6 @@ export declare const APP_ENV_SOURCES: ReadonlySet<AppEnvSource>;
|
|
|
317
304
|
* - Every `entries[]` entry follows {@link validateHostingEntry}; keys are
|
|
318
305
|
* unique, and at most one entry has a `database`, because the one
|
|
319
306
|
* `<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).
|
|
326
307
|
*/
|
|
327
308
|
export declare function validateHosting(hosting: HostingConfig, names?: {
|
|
328
309
|
domain: string | null;
|
|
@@ -546,6 +527,12 @@ export interface AdoptedApp {
|
|
|
546
527
|
*/
|
|
547
528
|
export interface DeclaredOrg {
|
|
548
529
|
id: OrgId;
|
|
530
|
+
/**
|
|
531
|
+
* How many writes the store has made to this org, starting at 1 (#351).
|
|
532
|
+
* Set by the store on read; ignored on `create` and `update`. Pass it back
|
|
533
|
+
* as `update`'s `expectedVersion` to refuse a write over a newer edit.
|
|
534
|
+
*/
|
|
535
|
+
version?: number;
|
|
549
536
|
/**
|
|
550
537
|
* Mutable at any time — except: once `mail.mode` is not `'off'`, the slug
|
|
551
538
|
* is frozen. The level-1 mail address is built from it
|
package/dist/org.js
CHANGED
|
@@ -13,9 +13,6 @@ 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}$/;
|
|
19
16
|
const ISSUER_APP_KINDS = new Set(['web', 'native', 'api']);
|
|
20
17
|
const HOSTING_BUILD_PACKS = new Set([
|
|
21
18
|
'dockerfile',
|
|
@@ -34,12 +31,6 @@ const HOSTING_BUILD_PACKS = new Set([
|
|
|
34
31
|
* - Every `entries[]` entry follows {@link validateHostingEntry}; keys are
|
|
35
32
|
* unique, and at most one entry has a `database`, because the one
|
|
36
33
|
* `<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).
|
|
43
34
|
*/
|
|
44
35
|
export function validateHosting(hosting, names) {
|
|
45
36
|
if (hosting.tunnel && !hosting.enabled) {
|
|
@@ -69,27 +60,7 @@ export function validateHosting(hosting, names) {
|
|
|
69
60
|
if (hosting.enabled && !appEntry(hosting)) {
|
|
70
61
|
return `hosting.enabled requires an entry keyed '${APP_ENTRY_KEY}'`;
|
|
71
62
|
}
|
|
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
|
-
}
|
|
91
63
|
const seenKeys = new Set();
|
|
92
|
-
const seenAdopted = new Set();
|
|
93
64
|
for (const entry of hosting.entries) {
|
|
94
65
|
if (seenKeys.has(entry.key)) {
|
|
95
66
|
return `hosting.entries key '${entry.key}' is not unique`;
|
|
@@ -98,33 +69,10 @@ export function validateHosting(hosting, names) {
|
|
|
98
69
|
const invalid = validateHostingEntry(entry, hosting.enabled);
|
|
99
70
|
if (invalid)
|
|
100
71
|
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
72
|
const invalidAuth = validateEntryAuth(entry, hosting, names?.domain);
|
|
119
73
|
if (invalidAuth)
|
|
120
74
|
return invalidAuth;
|
|
121
75
|
}
|
|
122
|
-
if (hosting.adopt?.database && !hosting.entries.some((entry) => entry.database)) {
|
|
123
|
-
return 'hosting.adopt.database is set, so an entry must set database';
|
|
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
76
|
const invalidNames = validateEntryHostnames(hosting, names);
|
|
129
77
|
if (invalidNames)
|
|
130
78
|
return invalidNames;
|
package/dist/plan/agora.d.ts
CHANGED
|
@@ -90,12 +90,74 @@ export declare function agoraHostMissing(host: AgoraHostValues): string[];
|
|
|
90
90
|
* `AGORA_SUFFIX` reserves the collision).
|
|
91
91
|
*/
|
|
92
92
|
export declare function agoraHostnameFor(org: DeclaredOrg): string;
|
|
93
|
+
/** The port `calls/packages/service/Dockerfile` documents (`EXPOSE 8080`); `config.mjs`'s `PORT` has no default, so the executor pushes it. */
|
|
94
|
+
export declare const CALLS_SERVICE_PORT = "8080";
|
|
95
|
+
/**
|
|
96
|
+
* Recorded by the executor once the calls service app carries its fixed
|
|
97
|
+
* internal name (`callsServiceInternalName`). `desiredAgoraEnv` waits for it,
|
|
98
|
+
* so Agora is never given an address that does not resolve yet.
|
|
99
|
+
*/
|
|
100
|
+
export declare const CALLS_SERVICE_NAME_SCOPE_KEY = "coolify_calls_service_internal_name";
|
|
101
|
+
/**
|
|
102
|
+
* The calls service app's fixed internal name: the executor gives the app
|
|
103
|
+
* this `custom_internal_name`, so Agora reaches it as
|
|
104
|
+
* `http://<name>:{@link CALLS_SERVICE_PORT}` inside Coolify's Docker network.
|
|
105
|
+
*/
|
|
106
|
+
export declare function callsServiceInternalName(org: DeclaredOrg): string;
|
|
107
|
+
/** Whether the recorded marker says the calls service carries the name the org's current slug gives it. */
|
|
108
|
+
export declare function callsServiceHasName(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): boolean;
|
|
109
|
+
/**
|
|
110
|
+
* Recorded once the calls service app carries the seven ZITADEL token env
|
|
111
|
+
* vars (`CALLS_ISSUER` ... `CALLS_INTROSPECTION_CLIENT_SECRET`,
|
|
112
|
+
* `calls/docs/member-auth.md`). Its value is {@link callsTokenEnvMarker}, so a
|
|
113
|
+
* changed issuer, audience or organisation plans the step again. The same
|
|
114
|
+
* marker-and-one-restart mechanism as {@link CALLS_SERVICE_NAME_SCOPE_KEY}.
|
|
115
|
+
*/
|
|
116
|
+
export declare const CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY = "coolify_calls_service_token_env";
|
|
117
|
+
/**
|
|
118
|
+
* The introspection API app minted for the calls service (once), and the
|
|
119
|
+
* undelivered key recorded before its secret leaves the executor
|
|
120
|
+
* (`foundry/CLAUDE.md`, platform rule 1). No `:` in either, as that rule says.
|
|
121
|
+
*/
|
|
122
|
+
export declare const CALLS_INTROSPECTION_SCOPE_KEY = "zitadel_calls_introspection_app";
|
|
123
|
+
export declare const CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY = "zitadel_calls_introspection_undelivered";
|
|
124
|
+
/** What the plan hands the calls service step: the two host values the token env needs. */
|
|
125
|
+
export interface CallsTokenHost {
|
|
126
|
+
issuer: string;
|
|
127
|
+
messagingProjectId: string;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The token env's inputs, or null while any is missing: the host's issuer and
|
|
131
|
+
* messaging project id (`AgoraHostValues`) and the org's recorded ZITADEL org
|
|
132
|
+
* id. Null means write none of the seven, never a partial set: the calls
|
|
133
|
+
* service refuses to boot on one.
|
|
134
|
+
*/
|
|
135
|
+
export declare function callsTokenHostFrom(host: AgoraHostValues): CallsTokenHost | null;
|
|
136
|
+
/** The value {@link CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY} holds once the env is in place; null when it cannot be written yet. */
|
|
137
|
+
export declare function callsTokenEnvMarker(host: CallsTokenHost | null, recorded: Readonly<Record<string, string>>): string | null;
|
|
138
|
+
/**
|
|
139
|
+
* The calls service's introspection API app, in the messaging project. One per
|
|
140
|
+
* org: the project is shared by every org, so the name carries the slug (a DNS
|
|
141
|
+
* label, at most 63 characters) and only an exact match is ever deleted.
|
|
142
|
+
*/
|
|
143
|
+
export declare function callsIntrospectionAppName(org: DeclaredOrg): string;
|
|
144
|
+
/**
|
|
145
|
+
* The block shown, and thrown, when the messaging project or the ZITADEL org
|
|
146
|
+
* differs from what the token env was written for: the recorded client
|
|
147
|
+
* belongs to the old project, and foundry does not replace or delete it. Null
|
|
148
|
+
* when nothing is recorded, or only the issuer differs (the client is kept).
|
|
149
|
+
*/
|
|
150
|
+
export declare function callsTokenEnvMovedMessage(org: DeclaredOrg, host: CallsTokenHost | null, recorded: Readonly<Record<string, string>>): string | null;
|
|
151
|
+
/** True when there is nothing to write yet, or the recorded marker says it was written. */
|
|
152
|
+
export declare function callsServiceHasTokenEnv(host: CallsTokenHost | null, recorded: Readonly<Record<string, string>>): boolean;
|
|
93
153
|
/**
|
|
94
154
|
* The env vars an org's Agora app should carry, and every input still
|
|
95
155
|
* missing to build them. `AGORA_CONFIG` is present only when `missing` is
|
|
96
156
|
* empty; callers that write must refuse on a non-empty `missing`.
|
|
97
157
|
* `AGORA_MAIL_UPSTREAM` is present only when mail is on and the host supplies
|
|
98
|
-
* an address.
|
|
158
|
+
* an address. `AGORA_CALLS_UPSTREAM` is per org, so it comes from `recorded`,
|
|
159
|
+
* not `host`: present only when calls are on and this org's calls service
|
|
160
|
+
* app carries its internal name ({@link callsServiceHasName}).
|
|
99
161
|
*/
|
|
100
162
|
export declare function desiredAgoraEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, host: AgoraHostValues): {
|
|
101
163
|
env: Record<string, string>;
|
package/dist/plan/agora.js
CHANGED
|
@@ -89,12 +89,97 @@ export function agoraHostMissing(host) {
|
|
|
89
89
|
export function agoraHostnameFor(org) {
|
|
90
90
|
return org.domain ? `agora.${org.domain}` : `${org.slug}-agora.oikos.host`;
|
|
91
91
|
}
|
|
92
|
+
/** The port `calls/packages/service/Dockerfile` documents (`EXPOSE 8080`); `config.mjs`'s `PORT` has no default, so the executor pushes it. */
|
|
93
|
+
export const CALLS_SERVICE_PORT = '8080';
|
|
94
|
+
/**
|
|
95
|
+
* Recorded by the executor once the calls service app carries its fixed
|
|
96
|
+
* internal name (`callsServiceInternalName`). `desiredAgoraEnv` waits for it,
|
|
97
|
+
* so Agora is never given an address that does not resolve yet.
|
|
98
|
+
*/
|
|
99
|
+
export const CALLS_SERVICE_NAME_SCOPE_KEY = 'coolify_calls_service_internal_name';
|
|
100
|
+
/**
|
|
101
|
+
* The calls service app's fixed internal name: the executor gives the app
|
|
102
|
+
* this `custom_internal_name`, so Agora reaches it as
|
|
103
|
+
* `http://<name>:{@link CALLS_SERVICE_PORT}` inside Coolify's Docker network.
|
|
104
|
+
*/
|
|
105
|
+
export function callsServiceInternalName(org) {
|
|
106
|
+
return `${org.slug}-calls`;
|
|
107
|
+
}
|
|
108
|
+
/** Whether the recorded marker says the calls service carries the name the org's current slug gives it. */
|
|
109
|
+
export function callsServiceHasName(org, recorded) {
|
|
110
|
+
return recorded[CALLS_SERVICE_NAME_SCOPE_KEY] === callsServiceInternalName(org);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Recorded once the calls service app carries the seven ZITADEL token env
|
|
114
|
+
* vars (`CALLS_ISSUER` ... `CALLS_INTROSPECTION_CLIENT_SECRET`,
|
|
115
|
+
* `calls/docs/member-auth.md`). Its value is {@link callsTokenEnvMarker}, so a
|
|
116
|
+
* changed issuer, audience or organisation plans the step again. The same
|
|
117
|
+
* marker-and-one-restart mechanism as {@link CALLS_SERVICE_NAME_SCOPE_KEY}.
|
|
118
|
+
*/
|
|
119
|
+
export const CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY = 'coolify_calls_service_token_env';
|
|
120
|
+
/**
|
|
121
|
+
* The introspection API app minted for the calls service (once), and the
|
|
122
|
+
* undelivered key recorded before its secret leaves the executor
|
|
123
|
+
* (`foundry/CLAUDE.md`, platform rule 1). No `:` in either, as that rule says.
|
|
124
|
+
*/
|
|
125
|
+
export const CALLS_INTROSPECTION_SCOPE_KEY = 'zitadel_calls_introspection_app';
|
|
126
|
+
export const CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY = 'zitadel_calls_introspection_undelivered';
|
|
127
|
+
/**
|
|
128
|
+
* The token env's inputs, or null while any is missing: the host's issuer and
|
|
129
|
+
* messaging project id (`AgoraHostValues`) and the org's recorded ZITADEL org
|
|
130
|
+
* id. Null means write none of the seven, never a partial set: the calls
|
|
131
|
+
* service refuses to boot on one.
|
|
132
|
+
*/
|
|
133
|
+
export function callsTokenHostFrom(host) {
|
|
134
|
+
if (!host.issuer || !host.messagingProjectId)
|
|
135
|
+
return null;
|
|
136
|
+
return { issuer: host.issuer, messagingProjectId: host.messagingProjectId };
|
|
137
|
+
}
|
|
138
|
+
/** The value {@link CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY} holds once the env is in place; null when it cannot be written yet. */
|
|
139
|
+
export function callsTokenEnvMarker(host, recorded) {
|
|
140
|
+
const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
141
|
+
if (!host || !zitadelOrgId)
|
|
142
|
+
return null;
|
|
143
|
+
return `${host.issuer} ${host.messagingProjectId} ${zitadelOrgId}`;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The calls service's introspection API app, in the messaging project. One per
|
|
147
|
+
* org: the project is shared by every org, so the name carries the slug (a DNS
|
|
148
|
+
* label, at most 63 characters) and only an exact match is ever deleted.
|
|
149
|
+
*/
|
|
150
|
+
export function callsIntrospectionAppName(org) {
|
|
151
|
+
return `calls-introspection-${org.slug}`;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The block shown, and thrown, when the messaging project or the ZITADEL org
|
|
155
|
+
* differs from what the token env was written for: the recorded client
|
|
156
|
+
* belongs to the old project, and foundry does not replace or delete it. Null
|
|
157
|
+
* when nothing is recorded, or only the issuer differs (the client is kept).
|
|
158
|
+
*/
|
|
159
|
+
export function callsTokenEnvMovedMessage(org, host, recorded) {
|
|
160
|
+
const wanted = callsTokenEnvMarker(host, recorded);
|
|
161
|
+
const written = recorded[CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY];
|
|
162
|
+
if (!wanted || !written || !recorded[CALLS_INTROSPECTION_SCOPE_KEY])
|
|
163
|
+
return null;
|
|
164
|
+
const [, oldProject, oldOrg] = written.split(' ');
|
|
165
|
+
const [, newProject, newOrg] = wanted.split(' ');
|
|
166
|
+
if (oldProject === newProject && oldOrg === newOrg)
|
|
167
|
+
return null;
|
|
168
|
+
return `${org.slug}'s calls service is blocked, not changed: the messaging project or ZITADEL org differs from the one its token env was written for (project ${oldProject} -> ${newProject}, org ${oldOrg} -> ${newOrg}); retire the ZITADEL app ${callsIntrospectionAppName(org)} (${recorded[CALLS_INTROSPECTION_SCOPE_KEY]}) in the old project by hand, then remove the recorded keys ${CALLS_INTROSPECTION_SCOPE_KEY} and ${CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY}`;
|
|
169
|
+
}
|
|
170
|
+
/** True when there is nothing to write yet, or the recorded marker says it was written. */
|
|
171
|
+
export function callsServiceHasTokenEnv(host, recorded) {
|
|
172
|
+
const marker = callsTokenEnvMarker(host, recorded);
|
|
173
|
+
return marker === null || recorded[CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY] === marker;
|
|
174
|
+
}
|
|
92
175
|
/**
|
|
93
176
|
* The env vars an org's Agora app should carry, and every input still
|
|
94
177
|
* missing to build them. `AGORA_CONFIG` is present only when `missing` is
|
|
95
178
|
* empty; callers that write must refuse on a non-empty `missing`.
|
|
96
179
|
* `AGORA_MAIL_UPSTREAM` is present only when mail is on and the host supplies
|
|
97
|
-
* an address.
|
|
180
|
+
* an address. `AGORA_CALLS_UPSTREAM` is per org, so it comes from `recorded`,
|
|
181
|
+
* not `host`: present only when calls are on and this org's calls service
|
|
182
|
+
* app carries its internal name ({@link callsServiceHasName}).
|
|
98
183
|
*/
|
|
99
184
|
export function desiredAgoraEnv(org, recorded, host) {
|
|
100
185
|
const env = { AGORA_CANONICAL_HOST: agoraHostnameFor(org) };
|
|
@@ -117,6 +202,8 @@ export function desiredAgoraEnv(org, recorded, host) {
|
|
|
117
202
|
messagingProjectId: host.messagingProjectId,
|
|
118
203
|
// Chat is not in v1: Agora hides all chat UI unless this is true.
|
|
119
204
|
chat: { enabled: org.chat.enabled },
|
|
205
|
+
// Agora draws call buttons only when this is true.
|
|
206
|
+
calls: { enabled: org.calls.enabled },
|
|
120
207
|
});
|
|
121
208
|
}
|
|
122
209
|
if (org.mail.mode !== 'off' && host.mailUpstream) {
|
|
@@ -127,6 +214,9 @@ export function desiredAgoraEnv(org, recorded, host) {
|
|
|
127
214
|
if (host.filesUpstream) {
|
|
128
215
|
env.AGORA_FILES_UPSTREAM = host.filesUpstream;
|
|
129
216
|
}
|
|
217
|
+
if (org.calls.enabled && callsServiceHasName(org, recorded)) {
|
|
218
|
+
env.AGORA_CALLS_UPSTREAM = `http://${callsServiceInternalName(org)}:${CALLS_SERVICE_PORT}`;
|
|
219
|
+
}
|
|
130
220
|
return { env, missing };
|
|
131
221
|
}
|
|
132
222
|
function stable(value) {
|
|
@@ -20,7 +20,7 @@ export declare function brokerEntryAppliedScopeKey(entryKey: string): string;
|
|
|
20
20
|
/** The foreign entries the last write found invalid, as a JSON array of names. */
|
|
21
21
|
export declare function brokerEntryWarningsScopeKey(entryKey: string): string;
|
|
22
22
|
/** What the app's env says about who signs in, as recorded so far. Missing values are left out. */
|
|
23
|
-
export declare function entryAuthEnv(org: DeclaredOrg, entry:
|
|
23
|
+
export declare function entryAuthEnv(org: DeclaredOrg, entry: HostingEntry, recorded: Readonly<Record<string, string>>, issuer: string | null): Record<string, string>;
|
|
24
24
|
/** The marker `pushAppEnv` records for an entry's sign-in env; the planner compares against it. */
|
|
25
25
|
export declare function entryAuthEnvAppliedScopeKey(entryKey: string): string;
|
|
26
26
|
/** What a broker entry must say, without its key. */
|
|
@@ -74,13 +74,9 @@ export declare class PlatformBrokerPort implements BrokerPort {
|
|
|
74
74
|
});
|
|
75
75
|
appId(): Promise<string>;
|
|
76
76
|
listEnvs(): Promise<{
|
|
77
|
-
uuid?: string;
|
|
78
77
|
key: string;
|
|
79
78
|
value: string;
|
|
80
79
|
is_preview: boolean;
|
|
81
|
-
is_buildtime?: boolean;
|
|
82
|
-
is_literal?: boolean;
|
|
83
|
-
is_multiline?: boolean;
|
|
84
80
|
}[]>;
|
|
85
81
|
setAuthApps(raw: string): Promise<void>;
|
|
86
82
|
deploy(): Promise<void>;
|
|
@@ -29,20 +29,12 @@ export interface EnsureCloudflaredConnectorInput {
|
|
|
29
29
|
tunnelId: string;
|
|
30
30
|
/** Fixed name, found before created -- so a second apply reuses this app rather than making a second one. */
|
|
31
31
|
appName: string;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
environment?: {
|
|
32
|
+
projectUuid: string;
|
|
33
|
+
environment: {
|
|
35
34
|
id: number;
|
|
36
35
|
name: string;
|
|
37
36
|
uuid: string;
|
|
38
37
|
};
|
|
39
|
-
/**
|
|
40
|
-
* #312: `recordedConnectorAppId` is a live app foundry adopted. It is never
|
|
41
|
-
* looked up by name or created; one that cannot be found is an error.
|
|
42
|
-
*/
|
|
43
|
-
adopted?: boolean;
|
|
44
|
-
/** #312: called with the token just before `TUNNEL_TOKEN` is rewritten, so a later restore knows foundry wrote it. */
|
|
45
|
-
beforeTokenWrite?: (token: string) => Promise<void>;
|
|
46
38
|
/** Called only when the app must be created -- never on the found-by-name or found-by-id paths. */
|
|
47
39
|
resolveServer: () => Promise<{
|
|
48
40
|
uuid: string;
|
|
@@ -54,15 +54,8 @@ export async function ensureCloudflaredConnector(input) {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
if (!appId) {
|
|
57
|
-
if (input.adopted) {
|
|
58
|
-
throw new Error(`cloudflared connector ${input.appName}: the adopted app was not found`);
|
|
59
|
-
}
|
|
60
|
-
const { projectUuid, environment } = input;
|
|
61
|
-
if (!projectUuid || !environment) {
|
|
62
|
-
throw new Error(`cloudflared connector ${input.appName}: no project to find or create it in`);
|
|
63
|
-
}
|
|
64
57
|
const found = await coolify.findApplicationByName(input.appName, {
|
|
65
|
-
environmentIds: [environment.id],
|
|
58
|
+
environmentIds: [input.environment.id],
|
|
66
59
|
});
|
|
67
60
|
if (found) {
|
|
68
61
|
const live = await liveStatus(found.uuid);
|
|
@@ -71,21 +64,21 @@ export async function ensureCloudflaredConnector(input) {
|
|
|
71
64
|
status = live.status;
|
|
72
65
|
}
|
|
73
66
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
67
|
+
}
|
|
68
|
+
if (!appId) {
|
|
69
|
+
const server = await input.resolveServer();
|
|
70
|
+
const created = await coolify.createDockerfileApplication({
|
|
71
|
+
project_uuid: input.projectUuid,
|
|
72
|
+
server_uuid: server.uuid,
|
|
73
|
+
environment_name: input.environment.name,
|
|
74
|
+
environment_uuid: input.environment.uuid,
|
|
75
|
+
dockerfile: cloudflaredDockerfile(),
|
|
76
|
+
name: input.appName,
|
|
77
|
+
description: 'cloudflared connector for a Cloudflare tunnel',
|
|
78
|
+
instant_deploy: false,
|
|
79
|
+
});
|
|
80
|
+
appId = created.uuid;
|
|
81
|
+
justCreated = true;
|
|
89
82
|
}
|
|
90
83
|
if (appId !== input.recordedConnectorAppId)
|
|
91
84
|
await input.recordAppId(appId);
|
|
@@ -93,7 +86,6 @@ export async function ensureCloudflaredConnector(input) {
|
|
|
93
86
|
const tokenChanged = justCreated || (await tunnelTokenDiffers(coolify, appId, token));
|
|
94
87
|
if (tokenChanged) {
|
|
95
88
|
try {
|
|
96
|
-
await input.beforeTokenWrite?.(token);
|
|
97
89
|
await coolify.bulkUpsertEnvs(appId, [{ key: 'TUNNEL_TOKEN', value: token, literal: true }]);
|
|
98
90
|
}
|
|
99
91
|
catch {
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { DeclaredOrg, OrgId } from '../org.js';
|
|
2
2
|
import type { Plan, PlanStep, UnmanagedItem, VersionChangeItem } from '../plan.js';
|
|
3
|
-
import type { Adoption } from '../store.js';
|
|
4
3
|
import { type AgoraHostReading } from './agora.js';
|
|
5
4
|
import type { LiveResource } from './live.js';
|
|
6
5
|
import { type OrgPlatformReadiness } from './platform-org.js';
|
|
@@ -35,17 +34,6 @@ export interface PlannedStep {
|
|
|
35
34
|
export interface PlanOrgOptions {
|
|
36
35
|
/** #267: the executor was built with a `loginHostBindings` port. */
|
|
37
36
|
loginHostBindings?: boolean;
|
|
38
|
-
/**
|
|
39
|
-
* #311: the host wired an `AdoptionVault` and an `AdoptionStore`, and the
|
|
40
|
-
* rows it holds. An adopting org plans a `coolify:adopt` step for each
|
|
41
|
-
* resource with no active snapshot (`isActiveSnapshot`), or, with nothing
|
|
42
|
-
* wired, reports adoption blocked and plans neither it nor the resource's
|
|
43
|
-
* own step.
|
|
44
|
-
*/
|
|
45
|
-
adoption?: {
|
|
46
|
-
wired: boolean;
|
|
47
|
-
adopted: readonly Adoption[];
|
|
48
|
-
};
|
|
49
37
|
/** F5: the executor was built with a `broker` port. */
|
|
50
38
|
broker?: boolean;
|
|
51
39
|
}
|