@wtfalch/foundry 0.16.16 → 0.17.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 +22 -1
- package/dist/bootstrap/bootstrap.d.ts +6 -1
- package/dist/bootstrap/bootstrap.js +26 -3
- package/dist/bootstrap/declare.d.ts +11 -1
- package/dist/bootstrap/declare.js +12 -2
- package/dist/index.d.ts +9 -4
- package/dist/index.js +7 -0
- package/dist/migrations/0038_calls_version_pin.sql +8 -0
- package/dist/migrations/0039_destroy_record.sql +7 -0
- package/dist/migrations/0040_company_included_uuid.sql +72 -0
- package/dist/migrations/0041_company_included_uuid_keys.sql +132 -0
- package/dist/migrations/0042_mutation_leases.sql +12 -0
- package/dist/org.d.ts +54 -0
- package/dist/org.js +43 -1
- package/dist/plan/adopt.d.ts +76 -0
- package/dist/plan/adopt.js +184 -0
- package/dist/plan/agora.d.ts +21 -0
- package/dist/plan/agora.js +37 -0
- package/dist/plan/broker-entries.d.ts +9 -1
- package/dist/plan/broker-entries.js +13 -5
- package/dist/plan/chat-matrix.d.ts +1 -1
- package/dist/plan/chat-matrix.js +11 -3
- package/dist/plan/core-files.d.ts +25 -0
- package/dist/plan/core-files.js +25 -0
- package/dist/plan/diff.d.ts +11 -3
- package/dist/plan/diff.js +27 -62
- package/dist/plan/entry-ai-key.d.ts +3 -3
- package/dist/plan/entry-files-key.d.ts +3 -3
- package/dist/plan/executor.d.ts +57 -23
- package/dist/plan/executor.js +134 -50
- package/dist/plan/index.d.ts +4 -0
- package/dist/plan/index.js +2 -0
- package/dist/plan/live.d.ts +5 -0
- package/dist/plan/login-host.d.ts +1 -1
- package/dist/plan/login-host.js +6 -5
- package/dist/plan/plan-apply.d.ts +139 -6
- package/dist/plan/plan-apply.js +546 -76
- package/dist/plan/platform-diff.d.ts +14 -0
- package/dist/plan/platform-diff.js +16 -1
- package/dist/plan/platform-org.d.ts +16 -0
- package/dist/plan/platform-org.js +17 -1
- package/dist/plan/platform-providers.d.ts +1 -1
- package/dist/plan/platform-providers.js +73 -11
- package/dist/plan/providers-readers.d.ts +2 -0
- package/dist/plan/providers-readers.js +4 -0
- package/dist/plan/rollout.d.ts +52 -0
- package/dist/plan/rollout.js +37 -0
- package/dist/plan/step-system.d.ts +22 -0
- package/dist/plan/step-system.js +37 -0
- package/dist/plan.d.ts +3 -0
- package/dist/state.d.ts +13 -0
- package/dist/store/audit.js +15 -1
- package/dist/store/postgres.d.ts +1 -1
- package/dist/store/postgres.js +127 -14
- package/dist/store/schema.d.ts +134 -5
- package/dist/store/schema.js +12 -1
- package/dist/store.d.ts +47 -4
- package/dist/store.js +9 -1
- package/package.json +1 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { DeclaredOrg } from '../org.js';
|
|
2
|
+
import type { Plan } from '../plan.js';
|
|
3
|
+
import type { LiveResource } from './live.js';
|
|
4
|
+
/**
|
|
5
|
+
* Adopt a live app and its database in place (archon#271). The caller names
|
|
6
|
+
* what already runs in Coolify; foundry reads it, checks it is what it would
|
|
7
|
+
* have created for the entry, and records the ids so the next plan finds them
|
|
8
|
+
* instead of planning a create. Nothing here calls Coolify to change anything.
|
|
9
|
+
*
|
|
10
|
+
* No redeploy happens at adopt. The next normal plan may still propose
|
|
11
|
+
* updates to the adopted app (log volume, env push, hostnames, labels) because
|
|
12
|
+
* foundry's applied markers are absent; applying those redeploys the app. The
|
|
13
|
+
* host must show that plan to the operator before it is applied.
|
|
14
|
+
*/
|
|
15
|
+
export interface AdoptInput {
|
|
16
|
+
/** The declared hosting entry (`HostingEntry.key`) that takes the app over. It must have `database: true`. */
|
|
17
|
+
entryKey: string;
|
|
18
|
+
/** The Coolify application uuid. */
|
|
19
|
+
applicationUuid: string;
|
|
20
|
+
/** The Coolify Postgres database uuid. */
|
|
21
|
+
databaseUuid: string;
|
|
22
|
+
/** The Coolify project both live in. Optional when the org already records one. */
|
|
23
|
+
projectUuid?: string;
|
|
24
|
+
}
|
|
25
|
+
/** What the adopted resources looked like when adopted: the reader's output, never a secret. */
|
|
26
|
+
export interface AdoptSnapshot {
|
|
27
|
+
adoptedAt: string;
|
|
28
|
+
projectUuid: string;
|
|
29
|
+
/** True when adopt recorded `coolify_project` itself, so release takes it out again. */
|
|
30
|
+
projectRecorded: boolean;
|
|
31
|
+
application: {
|
|
32
|
+
uuid: string;
|
|
33
|
+
config: Record<string, unknown>;
|
|
34
|
+
};
|
|
35
|
+
database: {
|
|
36
|
+
uuid: string;
|
|
37
|
+
config: Record<string, unknown>;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** The flat scope key (no `:`) holding an entry's {@link AdoptSnapshot} as JSON. Its presence means "adopted". */
|
|
41
|
+
export declare function adoptedScopeKey(entryKey: string): string;
|
|
42
|
+
/** `null` when absent, or when it is not a snapshot (callers that delete must treat the latter as an error: see {@link adoptedKeysUnreadable}). */
|
|
43
|
+
export declare function parseAdoptSnapshot(raw: string | undefined): AdoptSnapshot | null;
|
|
44
|
+
/** The `adopted.<entry>` keys of `serviceIds` whose value is not a readable snapshot. */
|
|
45
|
+
export declare function adoptedKeysUnreadable(serviceIds: Readonly<Record<string, string>>): string[];
|
|
46
|
+
export declare function snapshotConfig(config: Record<string, unknown>): Record<string, unknown>;
|
|
47
|
+
export declare class AdoptRefusedError extends Error {
|
|
48
|
+
readonly reasons: string[];
|
|
49
|
+
constructor(reasons: string[]);
|
|
50
|
+
}
|
|
51
|
+
export interface AdoptPlan {
|
|
52
|
+
plan: Plan;
|
|
53
|
+
/** Empty when the live resources match the entry. A non-empty list refuses `adopt` and the plan has no steps. */
|
|
54
|
+
mismatches: string[];
|
|
55
|
+
/** What `adopt` would record; absent with mismatches. */
|
|
56
|
+
snapshot?: AdoptSnapshot;
|
|
57
|
+
}
|
|
58
|
+
/** A live app or database in the project that this org has not recorded, for Archon's "Live, not theirs". */
|
|
59
|
+
export interface AdoptCandidate {
|
|
60
|
+
kind: 'application' | 'database';
|
|
61
|
+
uuid: string;
|
|
62
|
+
name: string;
|
|
63
|
+
status?: string;
|
|
64
|
+
/** True when its name is the one foundry would have given it for the entry. */
|
|
65
|
+
matchesName: boolean;
|
|
66
|
+
}
|
|
67
|
+
/** Live applications and databases in the project that no recorded id of this org names. */
|
|
68
|
+
export declare function adoptCandidates(org: DeclaredOrg, entryKey: string, recordedIds: Readonly<Record<string, string>>, live: readonly LiveResource[]): AdoptCandidate[];
|
|
69
|
+
/**
|
|
70
|
+
* Checks `input` against the declaration, the recorded ids and the live
|
|
71
|
+
* resources of the project, and builds the plan steps. `live` is the reader's
|
|
72
|
+
* output for the project (`null` when the reader cannot read a project).
|
|
73
|
+
*/
|
|
74
|
+
export declare function planAdoptFrom(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, input: AdoptInput, live: readonly LiveResource[] | null, now: Date,
|
|
75
|
+
/** What another org already records among the three uuids, one reason each (`FoundryPlanApply.computeAdopt`). */
|
|
76
|
+
claimedElsewhere?: readonly string[]): AdoptPlan;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { appNameFor, appStepKey } from './platform-diff.js';
|
|
2
|
+
import { withStepSystem } from './step-system.js';
|
|
3
|
+
/** The flat scope key (no `:`) holding an entry's {@link AdoptSnapshot} as JSON. Its presence means "adopted". */
|
|
4
|
+
export function adoptedScopeKey(entryKey) {
|
|
5
|
+
return `adopted.${entryKey}`;
|
|
6
|
+
}
|
|
7
|
+
/** `null` when absent, or when it is not a snapshot (callers that delete must treat the latter as an error: see {@link adoptedKeysUnreadable}). */
|
|
8
|
+
export function parseAdoptSnapshot(raw) {
|
|
9
|
+
if (!raw)
|
|
10
|
+
return null;
|
|
11
|
+
try {
|
|
12
|
+
const v = JSON.parse(raw);
|
|
13
|
+
return typeof v?.application?.uuid === 'string' && typeof v?.database?.uuid === 'string'
|
|
14
|
+
? v
|
|
15
|
+
: null;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** The `adopted.<entry>` keys of `serviceIds` whose value is not a readable snapshot. */
|
|
22
|
+
export function adoptedKeysUnreadable(serviceIds) {
|
|
23
|
+
return Object.entries(serviceIds)
|
|
24
|
+
.filter(([k, v]) => k.startsWith('adopted.') && parseAdoptSnapshot(v) === null)
|
|
25
|
+
.map(([k]) => k);
|
|
26
|
+
}
|
|
27
|
+
/** The only reader fields a snapshot keeps, and only as plain values: never an env value, password or URL with a credential. */
|
|
28
|
+
const SNAPSHOT_FIELDS = [
|
|
29
|
+
'name',
|
|
30
|
+
'status',
|
|
31
|
+
'fqdn',
|
|
32
|
+
'image',
|
|
33
|
+
'docker_registry_image_name',
|
|
34
|
+
'git_repository',
|
|
35
|
+
'git_branch',
|
|
36
|
+
'is_auto_deploy_enabled',
|
|
37
|
+
'environment_id',
|
|
38
|
+
'server',
|
|
39
|
+
'server_uuid',
|
|
40
|
+
'destination',
|
|
41
|
+
'destination_uuid',
|
|
42
|
+
];
|
|
43
|
+
export function snapshotConfig(config) {
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const field of SNAPSHOT_FIELDS) {
|
|
46
|
+
const v = config[field];
|
|
47
|
+
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')
|
|
48
|
+
out[field] = v;
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
export class AdoptRefusedError extends Error {
|
|
53
|
+
reasons;
|
|
54
|
+
constructor(reasons) {
|
|
55
|
+
super(`adopt refused: ${reasons.join('; ')}`);
|
|
56
|
+
this.reasons = reasons;
|
|
57
|
+
this.name = 'AdoptRefusedError';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function databaseNameFor(org) {
|
|
61
|
+
return `${org.slug}-db`;
|
|
62
|
+
}
|
|
63
|
+
function nameOf(r) {
|
|
64
|
+
return typeof r.config.name === 'string' ? r.config.name : '';
|
|
65
|
+
}
|
|
66
|
+
/** Live applications and databases in the project that no recorded id of this org names. */
|
|
67
|
+
export function adoptCandidates(org, entryKey, recordedIds, live) {
|
|
68
|
+
const entry = org.hosting.entries.find((e) => e.key === entryKey);
|
|
69
|
+
const taken = new Set(Object.values(recordedIds));
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const r of live) {
|
|
72
|
+
if ((r.kind !== 'application' && r.kind !== 'database') || taken.has(r.key))
|
|
73
|
+
continue;
|
|
74
|
+
const want = r.kind === 'application' ? (entry ? appNameFor(org, entry) : null) : databaseNameFor(org);
|
|
75
|
+
out.push({
|
|
76
|
+
kind: r.kind,
|
|
77
|
+
uuid: r.key,
|
|
78
|
+
name: nameOf(r),
|
|
79
|
+
...(typeof r.config.status === 'string' ? { status: r.config.status } : {}),
|
|
80
|
+
matchesName: want !== null && nameOf(r) === want,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Checks `input` against the declaration, the recorded ids and the live
|
|
87
|
+
* resources of the project, and builds the plan steps. `live` is the reader's
|
|
88
|
+
* output for the project (`null` when the reader cannot read a project).
|
|
89
|
+
*/
|
|
90
|
+
export function planAdoptFrom(org, recordedIds, input, live, now,
|
|
91
|
+
/** What another org already records among the three uuids, one reason each (`FoundryPlanApply.computeAdopt`). */
|
|
92
|
+
claimedElsewhere = []) {
|
|
93
|
+
const mismatches = [...claimedElsewhere];
|
|
94
|
+
const entry = org.hosting.enabled
|
|
95
|
+
? org.hosting.entries.find((e) => e.key === input.entryKey)
|
|
96
|
+
: undefined;
|
|
97
|
+
if (!entry)
|
|
98
|
+
mismatches.push(`${org.slug} declares no hosting entry ${input.entryKey}`);
|
|
99
|
+
else if (!entry.database) {
|
|
100
|
+
mismatches.push(`entry ${input.entryKey} declares no database, so there is none to adopt`);
|
|
101
|
+
}
|
|
102
|
+
const appKey = `coolify:app:${appStepKey(org, input.entryKey)}`;
|
|
103
|
+
const dbKey = `coolify:database:${org.id}`;
|
|
104
|
+
if (recordedIds[appKey] || recordedIds[dbKey] || recordedIds[adoptedScopeKey(input.entryKey)]) {
|
|
105
|
+
mismatches.push(`entry ${input.entryKey} is already applied (ids are recorded); release it first or let apply manage it`);
|
|
106
|
+
}
|
|
107
|
+
const recordedProject = recordedIds.coolify_project;
|
|
108
|
+
const projectUuid = input.projectUuid ?? recordedProject;
|
|
109
|
+
if (!projectUuid)
|
|
110
|
+
mismatches.push('no Coolify project: name projectUuid');
|
|
111
|
+
else if (recordedProject && recordedProject !== projectUuid) {
|
|
112
|
+
mismatches.push(`project ${projectUuid} is not the org's recorded project ${recordedProject}`);
|
|
113
|
+
}
|
|
114
|
+
let app;
|
|
115
|
+
let db;
|
|
116
|
+
if (projectUuid && live === null) {
|
|
117
|
+
mismatches.push('the Coolify reader cannot read a project, so nothing can be verified');
|
|
118
|
+
}
|
|
119
|
+
else if (projectUuid && live) {
|
|
120
|
+
app = live.find((r) => r.kind === 'application' && r.key === input.applicationUuid);
|
|
121
|
+
db = live.find((r) => r.kind === 'database' && r.key === input.databaseUuid);
|
|
122
|
+
if (!app) {
|
|
123
|
+
mismatches.push(`application ${input.applicationUuid} is not in project ${projectUuid}`);
|
|
124
|
+
}
|
|
125
|
+
else if (entry && nameOf(app) !== appNameFor(org, entry)) {
|
|
126
|
+
mismatches.push(`application is named ${nameOf(app)}, foundry would name it ${appNameFor(org, entry)}`);
|
|
127
|
+
}
|
|
128
|
+
if (!db) {
|
|
129
|
+
mismatches.push(`database ${input.databaseUuid} is not in project ${projectUuid}`);
|
|
130
|
+
}
|
|
131
|
+
else if (nameOf(db) !== databaseNameFor(org)) {
|
|
132
|
+
mismatches.push(`database is named ${nameOf(db)}, foundry would name it ${databaseNameFor(org)}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (mismatches.length > 0 || !app || !db || !projectUuid) {
|
|
136
|
+
return {
|
|
137
|
+
plan: {
|
|
138
|
+
orgId: org.id,
|
|
139
|
+
steps: [],
|
|
140
|
+
unmanaged: mismatches.map((description) => ({ description, key: 'adopt:mismatch' })),
|
|
141
|
+
},
|
|
142
|
+
mismatches,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const drafts = [
|
|
146
|
+
{
|
|
147
|
+
action: {
|
|
148
|
+
system: 'coolify',
|
|
149
|
+
kind: 'adopt-app',
|
|
150
|
+
key: appStepKey(org, input.entryKey),
|
|
151
|
+
desired: { applicationUuid: input.applicationUuid },
|
|
152
|
+
},
|
|
153
|
+
step: {
|
|
154
|
+
description: `adopt ${org.slug}'s ${input.entryKey} app (Coolify ${input.applicationUuid}) in place: record it, no redeploy`,
|
|
155
|
+
destroys: false,
|
|
156
|
+
migrates: false,
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
action: {
|
|
161
|
+
system: 'coolify',
|
|
162
|
+
kind: 'adopt-database',
|
|
163
|
+
key: org.id,
|
|
164
|
+
desired: { databaseUuid: input.databaseUuid },
|
|
165
|
+
},
|
|
166
|
+
step: {
|
|
167
|
+
description: `adopt ${org.slug}'s database (Coolify ${input.databaseUuid}) in place: record it, no restart`,
|
|
168
|
+
destroys: false,
|
|
169
|
+
migrates: false,
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
];
|
|
173
|
+
return {
|
|
174
|
+
plan: { orgId: org.id, steps: drafts.map((d) => withStepSystem(d).step), unmanaged: [] },
|
|
175
|
+
mismatches,
|
|
176
|
+
snapshot: {
|
|
177
|
+
adoptedAt: now.toISOString(),
|
|
178
|
+
projectUuid,
|
|
179
|
+
projectRecorded: recordedProject === undefined,
|
|
180
|
+
application: { uuid: app.key, config: snapshotConfig(app.config) },
|
|
181
|
+
database: { uuid: db.key, config: snapshotConfig(db.config) },
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
package/dist/plan/agora.d.ts
CHANGED
|
@@ -183,3 +183,24 @@ export declare function desiredAgoraEnv(org: DeclaredOrg, recorded: Readonly<Rec
|
|
|
183
183
|
};
|
|
184
184
|
/** The desired env keys the live app lacks or holds a different value for. Extra live keys are ignored. */
|
|
185
185
|
export declare function agoraEnvDrift(desired: Readonly<Record<string, string>>, live: Readonly<Record<string, string>>): string[];
|
|
186
|
+
/**
|
|
187
|
+
* #442: recorded once a restart has confirmed the org's live Agora process
|
|
188
|
+
* actually serves {@link agoraEnvRevision}'s digest of the env the executor
|
|
189
|
+
* last wrote -- distinct from Coolify's own env store, which `bulkUpsertEnvs`
|
|
190
|
+
* always leaves matching desired the moment the write succeeds, whether or
|
|
191
|
+
* not the running container was ever restarted onto it. Before this, a plan
|
|
192
|
+
* comparing only the live Coolify env (`agoraEnvDrift` above) could read a
|
|
193
|
+
* step as converged the instant the env was written, so a crash between that
|
|
194
|
+
* write and the restart -- or simply never restarting at all -- left the
|
|
195
|
+
* running process on the old config forever, with nothing left to replan
|
|
196
|
+
* against (#442's own probe: "one env write, zero restarts, zero deploys").
|
|
197
|
+
*/
|
|
198
|
+
export declare const AGORA_ACTIVATED_ENV_SCOPE_KEY = "coolify_agora_activated_env";
|
|
199
|
+
/**
|
|
200
|
+
* A stable digest of `env`, for comparison against what was last recorded
|
|
201
|
+
* under {@link AGORA_ACTIVATED_ENV_SCOPE_KEY}. `AGORA_CONFIG` is compared by
|
|
202
|
+
* its parsed structure rather than its raw string, the same rule
|
|
203
|
+
* {@link agoraEnvDrift}'s own `sameEnvValue` follows, so a cosmetic key-order
|
|
204
|
+
* difference in that JSON never forces a spurious restart.
|
|
205
|
+
*/
|
|
206
|
+
export declare function agoraEnvRevision(env: Readonly<Record<string, string>>): string;
|
package/dist/plan/agora.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { chatUpstreams } from './chat-matrix.js';
|
|
2
|
+
import { FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY } from './core-files.js';
|
|
2
3
|
/**
|
|
3
4
|
* #164: what both the planner (`diff.ts`) and the executor (`executor.ts`)
|
|
4
5
|
* need to agree on for an org's Agora -- the hostname, the values the host
|
|
@@ -218,6 +219,13 @@ export function desiredAgoraEnv(org, recorded, rawHost) {
|
|
|
218
219
|
if (!clientId)
|
|
219
220
|
missing.push('clientId');
|
|
220
221
|
missing.push(...agoraHostMissing(host));
|
|
222
|
+
// #437: the core Files audience and the core collection
|
|
223
|
+
// (`core-files.ts`), both optional on `Agora` itself (`agoras.ts`) --
|
|
224
|
+
// unlike `messagingProjectId`, an org whose `files:organisation` step has
|
|
225
|
+
// not run yet (or not recorded either one) simply gets an Agora with
|
|
226
|
+
// Files off, never a blocked `AGORA_CONFIG`.
|
|
227
|
+
const filesProjectId = recorded[ZITADEL_FILES_PROJECT_SCOPE_KEY];
|
|
228
|
+
const filesCollectionId = recorded[FILES_CORE_COLLECTION_SCOPE_KEY];
|
|
221
229
|
if (missing.length === 0) {
|
|
222
230
|
env.AGORA_CONFIG = JSON.stringify({
|
|
223
231
|
name: org.displayName,
|
|
@@ -227,6 +235,8 @@ export function desiredAgoraEnv(org, recorded, rawHost) {
|
|
|
227
235
|
organisationId: zitadelOrgId,
|
|
228
236
|
mailProjectId: host.mailProjectId,
|
|
229
237
|
messagingProjectId: host.messagingProjectId,
|
|
238
|
+
...(filesProjectId ? { filesProjectId } : {}),
|
|
239
|
+
...(filesCollectionId ? { filesCollectionId } : {}),
|
|
230
240
|
// Agora hides all chat UI unless this is true.
|
|
231
241
|
chat: { enabled: org.chat.enabled },
|
|
232
242
|
// Agora draws call buttons only when this is true.
|
|
@@ -282,3 +292,30 @@ export function agoraEnvDrift(desired, live) {
|
|
|
282
292
|
})
|
|
283
293
|
.map(([key]) => key);
|
|
284
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* #442: recorded once a restart has confirmed the org's live Agora process
|
|
297
|
+
* actually serves {@link agoraEnvRevision}'s digest of the env the executor
|
|
298
|
+
* last wrote -- distinct from Coolify's own env store, which `bulkUpsertEnvs`
|
|
299
|
+
* always leaves matching desired the moment the write succeeds, whether or
|
|
300
|
+
* not the running container was ever restarted onto it. Before this, a plan
|
|
301
|
+
* comparing only the live Coolify env (`agoraEnvDrift` above) could read a
|
|
302
|
+
* step as converged the instant the env was written, so a crash between that
|
|
303
|
+
* write and the restart -- or simply never restarting at all -- left the
|
|
304
|
+
* running process on the old config forever, with nothing left to replan
|
|
305
|
+
* against (#442's own probe: "one env write, zero restarts, zero deploys").
|
|
306
|
+
*/
|
|
307
|
+
export const AGORA_ACTIVATED_ENV_SCOPE_KEY = 'coolify_agora_activated_env';
|
|
308
|
+
/**
|
|
309
|
+
* A stable digest of `env`, for comparison against what was last recorded
|
|
310
|
+
* under {@link AGORA_ACTIVATED_ENV_SCOPE_KEY}. `AGORA_CONFIG` is compared by
|
|
311
|
+
* its parsed structure rather than its raw string, the same rule
|
|
312
|
+
* {@link agoraEnvDrift}'s own `sameEnvValue` follows, so a cosmetic key-order
|
|
313
|
+
* difference in that JSON never forces a spurious restart.
|
|
314
|
+
*/
|
|
315
|
+
export function agoraEnvRevision(env) {
|
|
316
|
+
const normalized = {};
|
|
317
|
+
for (const [key, value] of Object.entries(env)) {
|
|
318
|
+
normalized[key] = key === 'AGORA_CONFIG' ? stable(JSON.parse(value)) : value;
|
|
319
|
+
}
|
|
320
|
+
return stable(normalized);
|
|
321
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DeclaredOrg, HostingEntry } from '../org.js';
|
|
2
|
-
import type { PlatformStore } from '../store.js';
|
|
2
|
+
import type { DeclaredOrgStore, PlatformStore } from '../store.js';
|
|
3
3
|
import type { CoolifyPort } from './executor.js';
|
|
4
4
|
/**
|
|
5
5
|
* F5 (https://github.com/wtfalch/foundry/issues/314): an entry with
|
|
@@ -71,9 +71,17 @@ export declare class PlatformBrokerPort implements BrokerPort {
|
|
|
71
71
|
constructor(options: {
|
|
72
72
|
store: Pick<PlatformStore, 'getServiceIds' | 'putServiceIds'>;
|
|
73
73
|
coolify: Pick<CoolifyPort, 'listEnvs' | 'bulkUpsertEnvs' | 'deploy'>;
|
|
74
|
+
/**
|
|
75
|
+
* #441: the lease store (the `DeclaredOrgStore`). With it, `recordDeploy`
|
|
76
|
+
* holds the `platform` lease and fences its write; while another platform
|
|
77
|
+
* change holds it the record is skipped, and the broker's next deploy
|
|
78
|
+
* records it. Without it the write is unfenced, as before.
|
|
79
|
+
*/
|
|
80
|
+
leases?: Pick<DeclaredOrgStore, 'acquireMutationLease' | 'releaseMutationLease'>;
|
|
74
81
|
});
|
|
75
82
|
appId(): Promise<string>;
|
|
76
83
|
listEnvs(): Promise<{
|
|
84
|
+
uuid?: string;
|
|
77
85
|
key: string;
|
|
78
86
|
value: string;
|
|
79
87
|
is_preview: boolean;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { entryClientIdScopeKey } from './agora.js';
|
|
2
3
|
import { entryHostnames } from './hostnames.js';
|
|
3
4
|
import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, parseBrokerDeployRecord, } from './platform-diff.js';
|
|
@@ -99,11 +100,18 @@ export class PlatformBrokerPort {
|
|
|
99
100
|
return (await this.options.store.getServiceIds())[PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY];
|
|
100
101
|
}
|
|
101
102
|
async recordDeploy(value) {
|
|
102
|
-
const
|
|
103
|
-
await
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
103
|
+
const { leases, store } = this.options;
|
|
104
|
+
const lease = await leases?.acquireMutationLease('platform', randomUUID(), 60_000);
|
|
105
|
+
if (lease && !('token' in lease))
|
|
106
|
+
return;
|
|
107
|
+
try {
|
|
108
|
+
const ids = await store.getServiceIds();
|
|
109
|
+
await store.putServiceIds({ ...ids, [PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY]: value }, lease);
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
if (lease)
|
|
113
|
+
await leases?.releaseMutationLease(lease).catch(() => undefined);
|
|
114
|
+
}
|
|
107
115
|
}
|
|
108
116
|
}
|
|
109
117
|
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -62,7 +62,7 @@ export interface ChatSynapseHomeserverInput {
|
|
|
62
62
|
masHost: string;
|
|
63
63
|
masSharedSecret: string;
|
|
64
64
|
}
|
|
65
|
-
/** `homeserver.yaml`: federation
|
|
65
|
+
/** `homeserver.yaml`: federation off, presence on (stable Synapse features only -- no MSC3026 `busy`, foundry#41), MAS for auth, the chat gate module on. */
|
|
66
66
|
export declare function chatSynapseHomeserverYaml(input: ChatSynapseHomeserverInput): string;
|
|
67
67
|
export interface ChatMasConfigInput {
|
|
68
68
|
orgSlug: string;
|
package/dist/plan/chat-matrix.js
CHANGED
|
@@ -43,7 +43,7 @@ export function chatAdminUser(orgSlug) {
|
|
|
43
43
|
return `@chat-admin:${chatServerName(orgSlug)}`;
|
|
44
44
|
}
|
|
45
45
|
const q = (value) => JSON.stringify(value);
|
|
46
|
-
/** `homeserver.yaml`: federation
|
|
46
|
+
/** `homeserver.yaml`: federation off, presence on (stable Synapse features only -- no MSC3026 `busy`, foundry#41), MAS for auth, the chat gate module on. */
|
|
47
47
|
export function chatSynapseHomeserverYaml(input) {
|
|
48
48
|
return [
|
|
49
49
|
'# Generated by @wtfalch/foundry (executor.ts, coolify:chat). Do not edit',
|
|
@@ -77,9 +77,17 @@ export function chatSynapseHomeserverYaml(input) {
|
|
|
77
77
|
'# An empty list is the recommended way of disabling federation.',
|
|
78
78
|
'federation_domain_whitelist: []',
|
|
79
79
|
'',
|
|
80
|
-
|
|
80
|
+
// foundry#41, decided 2026-10-01: on for every company, stable Synapse
|
|
81
|
+
// features only. No `msc3026_enabled` here -- that experimental flag
|
|
82
|
+
// only gates the wire value `org.matrix.msc3026.busy`, and
|
|
83
|
+
// `ChatClient.announcePresence` (chat/src/chat/client.ts) only ever
|
|
84
|
+
// writes `online`/`unavailable`, never `busy`, so this homeserver has
|
|
85
|
+
// nothing to accept that flag would unlock. `ChatClient`'s `Presence`
|
|
86
|
+
// type still reads a foreign `busy` off `m.presence` if one ever
|
|
87
|
+
// arrives; dropping that read path per the same decision is
|
|
88
|
+
// wtfalch/chat's own follow-up, not this file's.
|
|
81
89
|
'presence:',
|
|
82
|
-
' enabled:
|
|
90
|
+
' enabled: true',
|
|
83
91
|
'',
|
|
84
92
|
'enable_authenticated_media: true',
|
|
85
93
|
'max_upload_size: "50M"',
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #437: the core Files contract every company gets, not only one with a
|
|
3
|
+
* custom hosting entry declaring `uses.files` -- the one ZITADEL audience
|
|
4
|
+
* Agora requests to read/browse Files (`agoras.ts`'s `filesProjectId`, the
|
|
5
|
+
* same `urn:zitadel:iam:org:project:id:<id>:aud` shape `messagingProjectId`
|
|
6
|
+
* already uses) and the one collection every file Agora itself uploads goes
|
|
7
|
+
* into (`agoras.ts`'s `filesCollectionId`; Files has no route to list an
|
|
8
|
+
* organisation's collections and an ordinary member cannot create one, so
|
|
9
|
+
* this has to exist before Agora ever tries).
|
|
10
|
+
*
|
|
11
|
+
* Both are idempotent by name: the ZITADEL project is found-or-created by
|
|
12
|
+
* `${org.slug}-files` the same way `coolifyChat`'s own messaging project is,
|
|
13
|
+
* and the collection by `CORE_FILES_COLLECTION_NAME` through
|
|
14
|
+
* `FilesOrganisationPort.ensureCollection` (SDK `ensureCollection`,
|
|
15
|
+
* wtfalch/files#127 -- already exposed on the port, no new Files capability
|
|
16
|
+
* needed). `executor.ts`'s `filesOrganisation` creates both, every apply,
|
|
17
|
+
* right after the organisation itself -- the same "regardless of whether it
|
|
18
|
+
* already ran" shape that method's own doc comment already describes.
|
|
19
|
+
*/
|
|
20
|
+
/** The one collection every file Agora uploads through Files goes into, inside the org's own Files organisation (scoped there, so the name needs nothing org-specific). */
|
|
21
|
+
export declare const CORE_FILES_COLLECTION_NAME = "agora";
|
|
22
|
+
/** Recorded id of {@link CORE_FILES_COLLECTION_NAME}, read by `agora.ts`'s `desiredAgoraEnv` as `filesCollectionId`. */
|
|
23
|
+
export declare const FILES_CORE_COLLECTION_SCOPE_KEY = "files_core_collection";
|
|
24
|
+
/** Recorded id of the org's `${org.slug}-files` ZITADEL project, read by `agora.ts`'s `desiredAgoraEnv` as `filesProjectId`. */
|
|
25
|
+
export declare const ZITADEL_FILES_PROJECT_SCOPE_KEY = "zitadel_files_project";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #437: the core Files contract every company gets, not only one with a
|
|
3
|
+
* custom hosting entry declaring `uses.files` -- the one ZITADEL audience
|
|
4
|
+
* Agora requests to read/browse Files (`agoras.ts`'s `filesProjectId`, the
|
|
5
|
+
* same `urn:zitadel:iam:org:project:id:<id>:aud` shape `messagingProjectId`
|
|
6
|
+
* already uses) and the one collection every file Agora itself uploads goes
|
|
7
|
+
* into (`agoras.ts`'s `filesCollectionId`; Files has no route to list an
|
|
8
|
+
* organisation's collections and an ordinary member cannot create one, so
|
|
9
|
+
* this has to exist before Agora ever tries).
|
|
10
|
+
*
|
|
11
|
+
* Both are idempotent by name: the ZITADEL project is found-or-created by
|
|
12
|
+
* `${org.slug}-files` the same way `coolifyChat`'s own messaging project is,
|
|
13
|
+
* and the collection by `CORE_FILES_COLLECTION_NAME` through
|
|
14
|
+
* `FilesOrganisationPort.ensureCollection` (SDK `ensureCollection`,
|
|
15
|
+
* wtfalch/files#127 -- already exposed on the port, no new Files capability
|
|
16
|
+
* needed). `executor.ts`'s `filesOrganisation` creates both, every apply,
|
|
17
|
+
* right after the organisation itself -- the same "regardless of whether it
|
|
18
|
+
* already ran" shape that method's own doc comment already describes.
|
|
19
|
+
*/
|
|
20
|
+
/** The one collection every file Agora uploads through Files goes into, inside the org's own Files organisation (scoped there, so the name needs nothing org-specific). */
|
|
21
|
+
export const CORE_FILES_COLLECTION_NAME = 'agora';
|
|
22
|
+
/** Recorded id of {@link CORE_FILES_COLLECTION_NAME}, read by `agora.ts`'s `desiredAgoraEnv` as `filesCollectionId`. */
|
|
23
|
+
export const FILES_CORE_COLLECTION_SCOPE_KEY = 'files_core_collection';
|
|
24
|
+
/** Recorded id of the org's `${org.slug}-files` ZITADEL project, read by `agora.ts`'s `desiredAgoraEnv` as `filesProjectId`. */
|
|
25
|
+
export const ZITADEL_FILES_PROJECT_SCOPE_KEY = 'zitadel_files_project';
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -26,6 +26,15 @@ export interface PlannedStep {
|
|
|
26
26
|
step: PlanStep;
|
|
27
27
|
action: StepAction;
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* A {@link PlannedStep} before `system` is set. Planners build these; the one
|
|
31
|
+
* place a plan is returned (`withStepSystem`, `step-system.ts`) sets `system`
|
|
32
|
+
* from the action, so no planner names it by hand.
|
|
33
|
+
*/
|
|
34
|
+
export interface DraftStep {
|
|
35
|
+
step: Omit<PlanStep, 'system'>;
|
|
36
|
+
action: StepAction;
|
|
37
|
+
}
|
|
29
38
|
/**
|
|
30
39
|
* Facts about the host's executor ports that an org's plan needs, so a step
|
|
31
40
|
* whose port is not wired is reported blocked instead of planned (rule 2).
|
|
@@ -70,9 +79,8 @@ export declare function toPlan(detail: PlanDetail): Plan;
|
|
|
70
79
|
* `hosting.issuerApps[]` entry rather than a single singleton, since an org
|
|
71
80
|
* can declare more than one ZITADEL app. #80 adds two more singletons,
|
|
72
81
|
* modeled on {@link planIssuer}: `planFilesOrganisation` and
|
|
73
|
-
* `planAiOrganisation`,
|
|
74
|
-
*
|
|
75
|
-
* limit to land, and AI use is switched on, before either limit is ever set.
|
|
82
|
+
* `planAiOrganisation`, the second planned before {@link planAiBudget} so AI
|
|
83
|
+
* use is switched on before its own limit is ever set.
|
|
76
84
|
* `planOwners` below adds one more singleton, in a different shape from the
|
|
77
85
|
* rest: the org's owner, invited by ZITADEL email, idempotent on the
|
|
78
86
|
* *recorded* resource id (`recordedIds`, sourced from
|