@wtfalch/foundry 0.16.15 → 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 +82 -5
- package/dist/org.js +62 -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/deploy-health.d.ts +136 -0
- package/dist/plan/deploy-health.js +153 -0
- package/dist/plan/diff.d.ts +11 -3
- package/dist/plan/diff.js +77 -71
- package/dist/plan/entry-ai-key.d.ts +3 -3
- package/dist/plan/entry-files-key.d.ts +6 -4
- package/dist/plan/entry-files-key.js +7 -10
- package/dist/plan/executor.d.ts +109 -28
- package/dist/plan/executor.js +213 -56
- package/dist/plan/hostnames.d.ts +6 -0
- package/dist/plan/hostnames.js +13 -0
- package/dist/plan/index.d.ts +5 -1
- package/dist/plan/index.js +3 -1
- 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 +153 -3
- package/dist/plan/plan-apply.js +584 -69
- package/dist/plan/platform-diff.d.ts +103 -9
- package/dist/plan/platform-diff.js +192 -14
- package/dist/plan/platform-org.d.ts +25 -0
- package/dist/plan/platform-org.js +17 -1
- package/dist/plan/platform-providers.d.ts +38 -5
- package/dist/plan/platform-providers.js +592 -109
- 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 +2 -2
package/dist/org.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { floatingTagBlocked } from './bootstrap/versions.js';
|
|
1
2
|
/** {@link MailConfig.mailApps} when the org declares none of its own (#34, decided 2026-09-30). */
|
|
2
3
|
export const DEFAULT_MAIL_APPS = ['agora', 'webmail'];
|
|
3
4
|
/** {@link MailConfig.noReplyLocalPart}'s own default, restated wherever it is read rather than imported (this file's `LOG_DIR`-style convention, named in `plan/executor.ts`'s own doc comments). */
|
|
@@ -39,11 +40,27 @@ export const filesCollectionEnvName = (name) => `FILES_COLLECTION_${name}_ID`;
|
|
|
39
40
|
export function appEntry(hosting) {
|
|
40
41
|
return hosting.entries.find((entry) => entry.key === APP_ENTRY_KEY);
|
|
41
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Two sources are secrets. `sharedSecret` is {@link EnvSharedSecret}.
|
|
45
|
+
* `platformMailKey` is the platform's held Lettermint key, handed over as the
|
|
46
|
+
* value of the declared env name (the platform's own apps call it
|
|
47
|
+
* `LETTERMINT_API_TOKEN`). Any hosting entry of any customer org may name it
|
|
48
|
+
* (William, 2026-10-04): mail is Lettermint, and the key is the platform's.
|
|
49
|
+
*/
|
|
42
50
|
export const APP_ENV_SOURCES = new Set([
|
|
43
51
|
'filesOrganisationId',
|
|
44
52
|
'aiOrganisationId',
|
|
45
53
|
'mailDomain',
|
|
54
|
+
'sharedSecret',
|
|
55
|
+
'platformMailKey',
|
|
46
56
|
]);
|
|
57
|
+
/** The sources whose value is a secret: resolved at apply time, never recorded. */
|
|
58
|
+
export const SECRET_APP_ENV_SOURCES = new Set([
|
|
59
|
+
'sharedSecret',
|
|
60
|
+
'platformMailKey',
|
|
61
|
+
]);
|
|
62
|
+
/** The held-key ref of an org's shared secret. */
|
|
63
|
+
export const sharedSecretRef = (orgId, name) => `foundry:shared-secret:${orgId}:${name}`;
|
|
47
64
|
const ISSUER_APP_KINDS = new Set(['web', 'native', 'api']);
|
|
48
65
|
const HOSTING_BUILD_PACKS = new Set([
|
|
49
66
|
'dockerfile',
|
|
@@ -267,6 +284,9 @@ function validateHostingEntry(entry, enabled) {
|
|
|
267
284
|
if (!APP_ENV_SOURCES.has(value?.from)) {
|
|
268
285
|
return `${at} env '${name}' has an unknown source '${value?.from}'`;
|
|
269
286
|
}
|
|
287
|
+
if (value.from === 'sharedSecret' && !ENTRY_KEY.test(value.name ?? '')) {
|
|
288
|
+
return `${at} env '${name}' sharedSecret needs a name (lowercase letters, digits and hyphens)`;
|
|
289
|
+
}
|
|
270
290
|
}
|
|
271
291
|
else if (SECRET_ENV_NAME.test(name) || (name.endsWith('_URL') && urlHasPassword(value))) {
|
|
272
292
|
return `${at} env '${name}' looks like a secret: ${HELD_SECRETS}`;
|
|
@@ -464,6 +484,9 @@ export function validateDomain(domain) {
|
|
|
464
484
|
* - `site.enabled` refuses an `issuerApps` entry named `cms`: the fixed cms
|
|
465
485
|
* client (#139, `plan/diff.ts`'s `planCmsClient`) already owns the
|
|
466
486
|
* `zitadel:app:cms` key, and the two would overwrite each other.
|
|
487
|
+
* - `site.repo`, if set, must be a repo under `wtfalch` (#245), the same
|
|
488
|
+
* `REPO` rule {@link validateHostingEntry} already holds `HostingEntry.repo`
|
|
489
|
+
* to.
|
|
467
490
|
*/
|
|
468
491
|
export function validateSite(org) {
|
|
469
492
|
if (org.site.enabled && !org.domain) {
|
|
@@ -478,6 +501,32 @@ export function validateSite(org) {
|
|
|
478
501
|
if (org.site.enabled && org.hosting.issuerApps.some((app) => app.name === 'cms')) {
|
|
479
502
|
return "site.enabled reserves the issuerApps name 'cms'";
|
|
480
503
|
}
|
|
504
|
+
if (org.site.repo != null && !REPO.test(org.site.repo)) {
|
|
505
|
+
return 'site.repo must be a repo under wtfalch, or null';
|
|
506
|
+
}
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* #259: `calls.versionPin` holds this org's calls Matrix stack on an older
|
|
511
|
+
* release during an ordered rollout. A pin is a tag, held to the same
|
|
512
|
+
* "no floating tag" rule the pinned releases in `versions.json` already
|
|
513
|
+
* follow ({@link floatingTagBlocked}) -- a pin that floats would defeat the
|
|
514
|
+
* reason to pin at all.
|
|
515
|
+
*/
|
|
516
|
+
export function validateCalls(org) {
|
|
517
|
+
const pin = org.calls.versionPin;
|
|
518
|
+
if (!pin)
|
|
519
|
+
return null;
|
|
520
|
+
if (pin.synapse !== undefined) {
|
|
521
|
+
const blocked = floatingTagBlocked('Synapse', pin.synapse, 'v1.161.0');
|
|
522
|
+
if (blocked)
|
|
523
|
+
return `calls.versionPin ${blocked}`;
|
|
524
|
+
}
|
|
525
|
+
if (pin.mas !== undefined) {
|
|
526
|
+
const blocked = floatingTagBlocked('matrix-authentication-service', pin.mas, '1.24.0');
|
|
527
|
+
if (blocked)
|
|
528
|
+
return `calls.versionPin ${blocked}`;
|
|
529
|
+
}
|
|
481
530
|
return null;
|
|
482
531
|
}
|
|
483
532
|
const ORG_NUMBER_PATTERN = /^\d{9}$/;
|
|
@@ -636,9 +685,21 @@ function validateOwnerMailboxes(org) {
|
|
|
636
685
|
* two rules `validateHosting` already applies to `issuerApps`' names, for
|
|
637
686
|
* the same reason: a duplicate or blank entry can never mean anything
|
|
638
687
|
* different from leaving it out.
|
|
688
|
+
*
|
|
689
|
+
* A domain plus `mail.mode === 'received'` is tier 2 (own domain, MX stays
|
|
690
|
+
* at Google or Microsoft), which William dropped on 2026-10-04 (#422):
|
|
691
|
+
* only tier 1 (no domain) and tier 3 (own domain, `hosted`) are offered.
|
|
692
|
+
* Refused unless `opts.allowExistingTier2` says this exact combination was
|
|
693
|
+
* already the stored state before this write -- `store/postgres.ts`'s
|
|
694
|
+
* `update` passes that for an org already grandfathered into tier 2, so a
|
|
695
|
+
* patch that does not touch `domain` or `mail.mode` still goes through.
|
|
696
|
+
* `create` never passes it: a brand new org cannot already be grandfathered.
|
|
639
697
|
*/
|
|
640
|
-
export function validateMail(org) {
|
|
698
|
+
export function validateMail(org, opts) {
|
|
641
699
|
const { mail } = org;
|
|
700
|
+
if (org.domain && mail.mode === 'received' && !opts?.allowExistingTier2) {
|
|
701
|
+
return `mail.mode 'received' (tier 2, own domain with mail elsewhere) is not offered; use 'hosted' or leave the domain off`;
|
|
702
|
+
}
|
|
642
703
|
const address = mail.ownerMailboxAddress ?? null;
|
|
643
704
|
if (address !== null) {
|
|
644
705
|
if (!OWNER_EMAIL_PATTERN.test(address)) {
|
|
@@ -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';
|