@wtfalch/foundry 0.28.0 → 0.29.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/plan/boule.d.ts +27 -3
- package/dist/plan/boule.js +39 -1
- package/dist/plan/diff.js +49 -6
- package/dist/plan/executor.d.ts +39 -0
- package/dist/plan/executor.js +165 -22
- package/dist/plan/providers-readers.d.ts +1 -1
- package/dist/plan/providers-readers.js +19 -4
- package/package.json +1 -1
package/dist/plan/boule.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type DeclaredOrg } from '../org.js';
|
|
2
2
|
import type { BrokerEntryInput, BrokerEntryWant } from './broker-entries.js';
|
|
3
3
|
/**
|
|
4
4
|
* #161 (Boule part): what both the planner (`diff.ts`) and the executor
|
|
@@ -36,8 +36,28 @@ export declare const BOULE_DEPLOYED_SCOPE_KEY = "coolify_boule_deployed";
|
|
|
36
36
|
* issuer.
|
|
37
37
|
*/
|
|
38
38
|
export declare function bouleHostnameFor(org: DeclaredOrg): string;
|
|
39
|
+
/** The env name Boule reads its Lettermint token from (boule#266). */
|
|
40
|
+
export declare const BOULE_MAIL_TOKEN_ENV = "LETTERMINT_API_TOKEN";
|
|
41
|
+
/**
|
|
42
|
+
* Whether Boule should mail invites: the org's mail is hosted and
|
|
43
|
+
* `coolify:mail` has recorded its domain. Mail off, received, or no domain
|
|
44
|
+
* yet: no mail env at all, and Boule shows the invite link on screen.
|
|
45
|
+
*/
|
|
46
|
+
export declare function bouleMailWanted(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Boule's `MAIL_FROM`: the org's no-reply address on its recorded mail
|
|
49
|
+
* domain. The local part falls back to `DEFAULT_NO_REPLY_LOCAL_PART`, the
|
|
50
|
+
* same default the hosted app's `MAIL_FROM` uses and the store column holds.
|
|
51
|
+
*/
|
|
52
|
+
export declare function bouleMailFrom(org: DeclaredOrg, mailDomain: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* Whether the live Boule's mail token needs writing: it holds none, or the
|
|
55
|
+
* org's hosted app holds a different one (the app's token wins). Compares
|
|
56
|
+
* values in memory only; the result names nothing secret.
|
|
57
|
+
*/
|
|
58
|
+
export declare function bouleMailTokenDrift(liveToken: string | undefined, appToken: string | undefined): boolean;
|
|
39
59
|
/** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
|
|
40
|
-
export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR", "ARCHON_URL"];
|
|
60
|
+
export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR", "ARCHON_URL", "MAIL_FROM", "LETTERMINT_API_TOKEN"];
|
|
41
61
|
/**
|
|
42
62
|
* The env vars an org's Boule app should carry, and every input still missing
|
|
43
63
|
* to build them. Callers that write must refuse on a non-empty `missing`: a
|
|
@@ -55,11 +75,15 @@ export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER",
|
|
|
55
75
|
* absent: Boule has no platform wired in every test and every org-only
|
|
56
76
|
* caller, same as `issuer` before #177.
|
|
57
77
|
*
|
|
78
|
+
* `mailWanted` (see {@link bouleMailWanted}) adds `MAIL_FROM`. The Lettermint
|
|
79
|
+
* token is never here: it is a secret the executor writes and the plan
|
|
80
|
+
* compares in memory ({@link bouleMailTokenDrift}).
|
|
81
|
+
*
|
|
58
82
|
* Not here, and written by the executor instead: the database URLs
|
|
59
83
|
* (`DATABASE_URL_OWNER`, `DATABASE_URL`, minted once) and `AUTH_COOKIE_SECRET`
|
|
60
84
|
* (generated once, never rotated).
|
|
61
85
|
*/
|
|
62
|
-
export declare function desiredBouleEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, issuer: string | null, archonUrl: string | null): {
|
|
86
|
+
export declare function desiredBouleEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, issuer: string | null, archonUrl: string | null, mailWanted?: boolean): {
|
|
63
87
|
env: Record<string, string>;
|
|
64
88
|
missing: string[];
|
|
65
89
|
};
|
package/dist/plan/boule.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DEFAULT_NO_REPLY_LOCAL_PART } from '../org.js';
|
|
1
2
|
import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
|
|
2
3
|
/**
|
|
3
4
|
* #161 (Boule part): what both the planner (`diff.ts`) and the executor
|
|
@@ -37,6 +38,34 @@ export const BOULE_DEPLOYED_SCOPE_KEY = 'coolify_boule_deployed';
|
|
|
37
38
|
export function bouleHostnameFor(org) {
|
|
38
39
|
return org.domain ? `boule.${org.domain}` : `${org.slug}-boule.oikos.host`;
|
|
39
40
|
}
|
|
41
|
+
/** The env name Boule reads its Lettermint token from (boule#266). */
|
|
42
|
+
export const BOULE_MAIL_TOKEN_ENV = 'LETTERMINT_API_TOKEN';
|
|
43
|
+
/** The scope id `coolify:mail` records the org's resolved mail domain under. */
|
|
44
|
+
const MAIL_DOMAIN_RECORDED_KEY = 'mail_domain';
|
|
45
|
+
/**
|
|
46
|
+
* Whether Boule should mail invites: the org's mail is hosted and
|
|
47
|
+
* `coolify:mail` has recorded its domain. Mail off, received, or no domain
|
|
48
|
+
* yet: no mail env at all, and Boule shows the invite link on screen.
|
|
49
|
+
*/
|
|
50
|
+
export function bouleMailWanted(org, recorded) {
|
|
51
|
+
return org.mail.mode === 'hosted' && Boolean(recorded[MAIL_DOMAIN_RECORDED_KEY]);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Boule's `MAIL_FROM`: the org's no-reply address on its recorded mail
|
|
55
|
+
* domain. The local part falls back to `DEFAULT_NO_REPLY_LOCAL_PART`, the
|
|
56
|
+
* same default the hosted app's `MAIL_FROM` uses and the store column holds.
|
|
57
|
+
*/
|
|
58
|
+
export function bouleMailFrom(org, mailDomain) {
|
|
59
|
+
return `${org.mail.noReplyLocalPart || DEFAULT_NO_REPLY_LOCAL_PART}@${mailDomain}`;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Whether the live Boule's mail token needs writing: it holds none, or the
|
|
63
|
+
* org's hosted app holds a different one (the app's token wins). Compares
|
|
64
|
+
* values in memory only; the result names nothing secret.
|
|
65
|
+
*/
|
|
66
|
+
export function bouleMailTokenDrift(liveToken, appToken) {
|
|
67
|
+
return !liveToken || (Boolean(appToken) && appToken !== liveToken);
|
|
68
|
+
}
|
|
40
69
|
/** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
|
|
41
70
|
export const BOULE_MANAGED_ENV_KEYS = [
|
|
42
71
|
'APP_URL',
|
|
@@ -46,6 +75,8 @@ export const BOULE_MANAGED_ENV_KEYS = [
|
|
|
46
75
|
'BOULE_ORG_NAME',
|
|
47
76
|
'AUTHZ_BOOTSTRAP_OPERATOR',
|
|
48
77
|
'ARCHON_URL',
|
|
78
|
+
'MAIL_FROM',
|
|
79
|
+
BOULE_MAIL_TOKEN_ENV,
|
|
49
80
|
];
|
|
50
81
|
/**
|
|
51
82
|
* The env vars an org's Boule app should carry, and every input still missing
|
|
@@ -64,11 +95,15 @@ export const BOULE_MANAGED_ENV_KEYS = [
|
|
|
64
95
|
* absent: Boule has no platform wired in every test and every org-only
|
|
65
96
|
* caller, same as `issuer` before #177.
|
|
66
97
|
*
|
|
98
|
+
* `mailWanted` (see {@link bouleMailWanted}) adds `MAIL_FROM`. The Lettermint
|
|
99
|
+
* token is never here: it is a secret the executor writes and the plan
|
|
100
|
+
* compares in memory ({@link bouleMailTokenDrift}).
|
|
101
|
+
*
|
|
67
102
|
* Not here, and written by the executor instead: the database URLs
|
|
68
103
|
* (`DATABASE_URL_OWNER`, `DATABASE_URL`, minted once) and `AUTH_COOKIE_SECRET`
|
|
69
104
|
* (generated once, never rotated).
|
|
70
105
|
*/
|
|
71
|
-
export function desiredBouleEnv(org, recorded, issuer, archonUrl) {
|
|
106
|
+
export function desiredBouleEnv(org, recorded, issuer, archonUrl, mailWanted = false) {
|
|
72
107
|
const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
73
108
|
const clientId = recorded[BOULE_CLIENT_ID_SCOPE_KEY];
|
|
74
109
|
const missing = [];
|
|
@@ -96,6 +131,9 @@ export function desiredBouleEnv(org, recorded, issuer, archonUrl) {
|
|
|
96
131
|
: undefined;
|
|
97
132
|
if (ownerUserId)
|
|
98
133
|
env.AUTHZ_BOOTSTRAP_OPERATOR = ownerUserId;
|
|
134
|
+
const mailDomain = recorded[MAIL_DOMAIN_RECORDED_KEY];
|
|
135
|
+
if (mailWanted && mailDomain)
|
|
136
|
+
env.MAIL_FROM = bouleMailFrom(org, mailDomain);
|
|
99
137
|
return { env, missing };
|
|
100
138
|
}
|
|
101
139
|
/** The `AUTH_APPS` name of an org's Boule broker entry, and the scope key suffix it records under. */
|
package/dist/plan/diff.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
2
2
|
import { APP_ENTRY_KEY, ownerMailboxRecorded, ownerMailboxes } from '../org.js';
|
|
3
3
|
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
|
|
4
|
-
import { BOULE_BROKER_ENTRY_KEY, bouleEnvDrift, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
4
|
+
import { BOULE_BROKER_ENTRY_KEY, BOULE_MAIL_TOKEN_ENV, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
5
5
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
|
|
6
6
|
import { chatInternalNames } from './chat-matrix.js';
|
|
7
7
|
import { planEntryAiKeys } from './entry-ai-key.js';
|
|
@@ -398,7 +398,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
398
398
|
const bouleGuarded = guardApp(bouleClient);
|
|
399
399
|
if (bouleGuarded)
|
|
400
400
|
plannedSteps.push(bouleGuarded);
|
|
401
|
-
const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null);
|
|
401
|
+
const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null, platformReadiness);
|
|
402
402
|
if (boule.step)
|
|
403
403
|
plannedSteps.push(boule.step);
|
|
404
404
|
// After the apps and Boule -- an edge key needs its app recorded or planned,
|
|
@@ -468,6 +468,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
468
468
|
unmanaged.push({ description: agora.blocked, key: 'coolify:agora' });
|
|
469
469
|
if (boule.blocked)
|
|
470
470
|
unmanaged.push({ description: boule.blocked, key: 'coolify:boule' });
|
|
471
|
+
if (boule.mailBlocked) {
|
|
472
|
+
unmanaged.push({ description: boule.mailBlocked, key: 'coolify:boule:mail' });
|
|
473
|
+
}
|
|
471
474
|
// Same for an ai key minted but never delivered: planning a new mint would
|
|
472
475
|
// orphan a second live key.
|
|
473
476
|
if (aiKey.blocked)
|
|
@@ -2027,7 +2030,7 @@ function planBouleClient(org, byKey, claimed) {
|
|
|
2027
2030
|
* address (the one Agora signs in at); without it the Boule is blocked, not
|
|
2028
2031
|
* planned, so every other step still applies.
|
|
2029
2032
|
*/
|
|
2030
|
-
function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonUrl) {
|
|
2033
|
+
function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonUrl, readiness) {
|
|
2031
2034
|
const key = resourceKey('coolify', 'boule', org.id);
|
|
2032
2035
|
const live = byKey.get(key);
|
|
2033
2036
|
if (live)
|
|
@@ -2042,26 +2045,59 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
|
|
|
2042
2045
|
if (tooLong.length > 0) {
|
|
2043
2046
|
return {
|
|
2044
2047
|
step: null,
|
|
2048
|
+
mailBlocked: null,
|
|
2045
2049
|
blocked: `${org.slug}'s Boule is blocked, not planned: ${tooLong.map(([n, what]) => `${what} '${n}' is over 63 characters`).join(', ')}; shorten the slug`,
|
|
2046
2050
|
};
|
|
2047
2051
|
}
|
|
2048
2052
|
if (otherSlugs.includes(`${org.slug}-boule`)) {
|
|
2049
2053
|
return {
|
|
2050
2054
|
step: null,
|
|
2055
|
+
mailBlocked: null,
|
|
2051
2056
|
blocked: `${org.slug}'s Boule is blocked, not planned: another declared org has the slug '${org.slug}-boule', which is this Boule's hostname`,
|
|
2052
2057
|
};
|
|
2053
2058
|
}
|
|
2054
2059
|
if (!issuer) {
|
|
2055
2060
|
return {
|
|
2056
2061
|
step: null,
|
|
2062
|
+
mailBlocked: null,
|
|
2057
2063
|
blocked: `${org.slug}'s Boule is blocked, not planned: the host supplies no issuer`,
|
|
2058
2064
|
};
|
|
2059
2065
|
}
|
|
2066
|
+
// Mail env, only for a hosted-mail org whose domain is recorded. Blocked,
|
|
2067
|
+
// not failing: while the platform cannot yet supply the token, Boule is
|
|
2068
|
+
// planned without mail and keeps showing the invite link.
|
|
2069
|
+
const mailWanted = bouleMailWanted(org, recordedIds);
|
|
2070
|
+
const mailDomain = recordedIds.mail_domain?.toLowerCase();
|
|
2071
|
+
const isApexOrg = Boolean(mailDomain && readiness.platformApex && mailDomain === readiness.platformApex.toLowerCase());
|
|
2072
|
+
let mailBlockedReason = null;
|
|
2073
|
+
if (mailWanted && isApexOrg) {
|
|
2074
|
+
if (!readiness.platformLettermintProjectId) {
|
|
2075
|
+
mailBlockedReason = 'platform.mail.lettermintProjectId is not declared yet';
|
|
2076
|
+
}
|
|
2077
|
+
else if (readiness.mailKeyHeld !== true) {
|
|
2078
|
+
mailBlockedReason = `the platform's Lettermint key is not held yet (held-key ref ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF})`;
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
else if (mailWanted) {
|
|
2082
|
+
mailBlockedReason = edgeNeeded(readiness, ['zones', 'dns']);
|
|
2083
|
+
}
|
|
2084
|
+
const mailBlocked = mailBlockedReason
|
|
2085
|
+
? `${org.slug}'s Boule mail is blocked, not planned: ${mailBlockedReason}`
|
|
2086
|
+
: null;
|
|
2087
|
+
const mailOn = mailWanted && !mailBlocked;
|
|
2060
2088
|
const action = {
|
|
2061
2089
|
system: 'coolify',
|
|
2062
2090
|
kind: 'boule',
|
|
2063
2091
|
key: org.id,
|
|
2064
|
-
|
|
2092
|
+
// The platform values go in like the app step's: the executor writes
|
|
2093
|
+
// what the plan showed and refuses a plan that carries no `platformApex`.
|
|
2094
|
+
desired: {
|
|
2095
|
+
issuer,
|
|
2096
|
+
archonUrl,
|
|
2097
|
+
platformApex: readiness.platformApex,
|
|
2098
|
+
platformLettermintProjectId: readiness.platformLettermintProjectId,
|
|
2099
|
+
mailBlocked: mailBlocked !== null,
|
|
2100
|
+
},
|
|
2065
2101
|
};
|
|
2066
2102
|
if (!live) {
|
|
2067
2103
|
return {
|
|
@@ -2070,13 +2106,19 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
|
|
|
2070
2106
|
step: { description: `provision ${org.slug}'s Boule`, destroys: false, migrates: false },
|
|
2071
2107
|
},
|
|
2072
2108
|
blocked: null,
|
|
2109
|
+
mailBlocked,
|
|
2073
2110
|
};
|
|
2074
2111
|
}
|
|
2075
2112
|
const liveEnv = (live.config.env ?? {});
|
|
2076
|
-
const { env } = desiredBouleEnv(org, recordedIds, issuer, archonUrl);
|
|
2113
|
+
const { env } = desiredBouleEnv(org, recordedIds, issuer, archonUrl, mailOn);
|
|
2077
2114
|
const drift = bouleEnvDrift(env, liveEnv);
|
|
2115
|
+
// The token is compared, never planned with its value.
|
|
2116
|
+
if (mailOn &&
|
|
2117
|
+
bouleMailTokenDrift(liveEnv[BOULE_MAIL_TOKEN_ENV], live.config.appMailToken)) {
|
|
2118
|
+
drift.push(BOULE_MAIL_TOKEN_ENV);
|
|
2119
|
+
}
|
|
2078
2120
|
if (drift.length === 0)
|
|
2079
|
-
return { step: null, blocked: null };
|
|
2121
|
+
return { step: null, blocked: null, mailBlocked };
|
|
2080
2122
|
return {
|
|
2081
2123
|
step: {
|
|
2082
2124
|
action,
|
|
@@ -2087,6 +2129,7 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
|
|
|
2087
2129
|
},
|
|
2088
2130
|
},
|
|
2089
2131
|
blocked: null,
|
|
2132
|
+
mailBlocked,
|
|
2090
2133
|
};
|
|
2091
2134
|
}
|
|
2092
2135
|
/**
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -1957,6 +1957,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1957
1957
|
* domain restricted to them before any old project is deleted, so the
|
|
1958
1958
|
* restriction list never becomes empty.
|
|
1959
1959
|
*
|
|
1960
|
+
* #654: the non-platform-apex part now lives in {@link ensureOrgAppMailToken},
|
|
1961
|
+
* shared with Boule, which holds the same token; the rule below is its rule
|
|
1962
|
+
* with the consumers read from the hosted app and Boule.
|
|
1963
|
+
*
|
|
1960
1964
|
* Mint rule ("Credential model"): the project is healthy when the app env
|
|
1961
1965
|
* holds `LETTERMINT_API_TOKEN` and Lettermint lists exactly one project
|
|
1962
1966
|
* named `<slug>-app` -- unlike the earlier provider's key, a Lettermint project's name
|
|
@@ -2003,6 +2007,31 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
2003
2007
|
* this field existed (review round 2, finding 3).
|
|
2004
2008
|
*/
|
|
2005
2009
|
private ensureAppLettermintMail;
|
|
2010
|
+
/**
|
|
2011
|
+
* The one mint rule for the org's `<slug>-app` Lettermint token, shared by
|
|
2012
|
+
* the hosted app (`ensureAppLettermintMail`) and Boule
|
|
2013
|
+
* (`ensureBouleMail`), which hold the same token. Lettermint shows a
|
|
2014
|
+
* token once, so the token lives only in those apps' envs: the rule reads
|
|
2015
|
+
* every consumer that is recorded (the hosted app first, Boule second).
|
|
2016
|
+
* The project is healthy when Lettermint lists exactly one `<slug>-app`
|
|
2017
|
+
* project and some consumer holds a token. A healthy rule gives `self` that
|
|
2018
|
+
* token (the app's wins) when it lacks it or holds another; a peer gets it
|
|
2019
|
+
* in its own step. An
|
|
2020
|
+
* unhealthy one mints a fresh project and writes its token to every
|
|
2021
|
+
* consumer, restarting those that run, before any other `<slug>-app`
|
|
2022
|
+
* project is deleted. Either role may run first: an app added after Boule
|
|
2023
|
+
* adopts Boule's token instead of deleting its project.
|
|
2024
|
+
*
|
|
2025
|
+
* The token goes straight to `bulkUpsertEnvs` and nowhere else: never
|
|
2026
|
+
* returned, recorded, logged or interpolated into an error. Returns
|
|
2027
|
+
* whether `self`'s own token changed and whether it was restarted here.
|
|
2028
|
+
*
|
|
2029
|
+
* A peer recorded but gone from Coolify (404) is no consumer. A healthy
|
|
2030
|
+
* company's step writes only its own env; a re-mint writes every live
|
|
2031
|
+
* consumer, restarts the running ones, restricts the domain, then deletes
|
|
2032
|
+
* the old projects.
|
|
2033
|
+
*/
|
|
2034
|
+
private ensureOrgAppMailToken;
|
|
2006
2035
|
/**
|
|
2007
2036
|
* #149: mints this org's own Lettermint relay credential and writes it to
|
|
2008
2037
|
* Stalwart via `MailPort.applyOrgRelay`, the same mint rule
|
|
@@ -2443,6 +2472,16 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
2443
2472
|
* shape {@link coolifyAgora}'s own doc comment explains.
|
|
2444
2473
|
*/
|
|
2445
2474
|
private coolifyBoule;
|
|
2475
|
+
/**
|
|
2476
|
+
* Gives Boule `LETTERMINT_API_TOKEN`: the platform's held key for an org on
|
|
2477
|
+
* the platform's own apex, else the org's `<slug>-app` token through the
|
|
2478
|
+
* shared mint rule ({@link ensureOrgAppMailToken}), which mints that project
|
|
2479
|
+
* when no hosted app made it yet. `MAIL_FROM` is written with the rest of
|
|
2480
|
+
* Boule's env. Returns whether Boule's token changed, so the caller
|
|
2481
|
+
* restarts a running Boule, unless `restarted`: the shared rule then already
|
|
2482
|
+
* restarted it (`running`), before it deleted the old project.
|
|
2483
|
+
*/
|
|
2484
|
+
private ensureBouleMail;
|
|
2446
2485
|
/**
|
|
2447
2486
|
* `plan/diff.ts`'s `planJobsRegistration`: registers the org's own app with
|
|
2448
2487
|
* the platform's jobs scheduler (`JobsSchedulerPort.registerProject`, the
|
package/dist/plan/executor.js
CHANGED
|
@@ -3,7 +3,7 @@ import { URL } from 'node:url';
|
|
|
3
3
|
import { resolveCallsSfuUrls } from '../calls-sfu.js';
|
|
4
4
|
import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, sharedSecretRef, } from '../org.js';
|
|
5
5
|
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatIntrospectionAppName, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
|
|
6
|
-
import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
6
|
+
import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
7
7
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
8
8
|
import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
9
9
|
import { CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, chatInternalNames, chatMasConfigYaml, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
|
|
@@ -2493,6 +2493,10 @@ export class FoundryStepExecutor {
|
|
|
2493
2493
|
* domain restricted to them before any old project is deleted, so the
|
|
2494
2494
|
* restriction list never becomes empty.
|
|
2495
2495
|
*
|
|
2496
|
+
* #654: the non-platform-apex part now lives in {@link ensureOrgAppMailToken},
|
|
2497
|
+
* shared with Boule, which holds the same token; the rule below is its rule
|
|
2498
|
+
* with the consumers read from the hosted app and Boule.
|
|
2499
|
+
*
|
|
2496
2500
|
* Mint rule ("Credential model"): the project is healthy when the app env
|
|
2497
2501
|
* holds `LETTERMINT_API_TOKEN` and Lettermint lists exactly one project
|
|
2498
2502
|
* named `<slug>-app` -- unlike the earlier provider's key, a Lettermint project's name
|
|
@@ -2545,7 +2549,6 @@ export class FoundryStepExecutor {
|
|
|
2545
2549
|
if (!hasPlatformApexField) {
|
|
2546
2550
|
throw new Error(`coolify:app for ${org.slug}: this step's plan carries no platformApex; plan this org's app again`);
|
|
2547
2551
|
}
|
|
2548
|
-
const appName = `${org.slug}-app`;
|
|
2549
2552
|
// #491: an org whose mail domain is the platform's own apex (Company
|
|
2550
2553
|
// Included on companyincluded.com, today) shares the platform's existing
|
|
2551
2554
|
// Lettermint project rather than minting a `<slug>-app` one of its own --
|
|
@@ -2588,44 +2591,129 @@ export class FoundryStepExecutor {
|
|
|
2588
2591
|
await this.ports.coolify.restart(appId);
|
|
2589
2592
|
return;
|
|
2590
2593
|
}
|
|
2591
|
-
|
|
2594
|
+
await this.ensureOrgAppMailToken({
|
|
2595
|
+
org,
|
|
2596
|
+
context,
|
|
2597
|
+
mailDomain,
|
|
2598
|
+
self: { role: 'app', id: appId, restart: running },
|
|
2599
|
+
mailFrom: `${org.mail.noReplyLocalPart ?? 'no-reply'}@${mailDomain}`,
|
|
2600
|
+
platformApex,
|
|
2601
|
+
platformLettermintProjectId,
|
|
2602
|
+
});
|
|
2603
|
+
}
|
|
2604
|
+
/**
|
|
2605
|
+
* The one mint rule for the org's `<slug>-app` Lettermint token, shared by
|
|
2606
|
+
* the hosted app (`ensureAppLettermintMail`) and Boule
|
|
2607
|
+
* (`ensureBouleMail`), which hold the same token. Lettermint shows a
|
|
2608
|
+
* token once, so the token lives only in those apps' envs: the rule reads
|
|
2609
|
+
* every consumer that is recorded (the hosted app first, Boule second).
|
|
2610
|
+
* The project is healthy when Lettermint lists exactly one `<slug>-app`
|
|
2611
|
+
* project and some consumer holds a token. A healthy rule gives `self` that
|
|
2612
|
+
* token (the app's wins) when it lacks it or holds another; a peer gets it
|
|
2613
|
+
* in its own step. An
|
|
2614
|
+
* unhealthy one mints a fresh project and writes its token to every
|
|
2615
|
+
* consumer, restarting those that run, before any other `<slug>-app`
|
|
2616
|
+
* project is deleted. Either role may run first: an app added after Boule
|
|
2617
|
+
* adopts Boule's token instead of deleting its project.
|
|
2618
|
+
*
|
|
2619
|
+
* The token goes straight to `bulkUpsertEnvs` and nowhere else: never
|
|
2620
|
+
* returned, recorded, logged or interpolated into an error. Returns
|
|
2621
|
+
* whether `self`'s own token changed and whether it was restarted here.
|
|
2622
|
+
*
|
|
2623
|
+
* A peer recorded but gone from Coolify (404) is no consumer. A healthy
|
|
2624
|
+
* company's step writes only its own env; a re-mint writes every live
|
|
2625
|
+
* consumer, restarts the running ones, restricts the domain, then deletes
|
|
2626
|
+
* the old projects.
|
|
2627
|
+
*/
|
|
2628
|
+
async ensureOrgAppMailToken(args) {
|
|
2629
|
+
const { org, context, mailDomain, self, mailFrom } = args;
|
|
2630
|
+
const appName = `${org.slug}-app`;
|
|
2631
|
+
const recorded = context.recordedIds();
|
|
2632
|
+
const appId = self.role === 'app' ? self.id : recorded[`coolify:app:${appStepKey(org)}`];
|
|
2633
|
+
const bouleId = self.role === 'boule' ? self.id : recorded[`coolify:boule:${org.id}`];
|
|
2634
|
+
const peerListEnvs = (id) => !id || id === self.id
|
|
2635
|
+
? Promise.resolve(null)
|
|
2636
|
+
: // A recorded peer that Coolify no longer has is not a consumer: not
|
|
2637
|
+
// read, written or restarted (same 404 rule as the retire step).
|
|
2638
|
+
this.ports.coolify
|
|
2639
|
+
.listEnvs(id)
|
|
2640
|
+
.catch((error) => {
|
|
2641
|
+
if (!isNotFoundError(error))
|
|
2642
|
+
throw error;
|
|
2643
|
+
return null;
|
|
2644
|
+
});
|
|
2645
|
+
const [projects, selfEnvs, appEnvs, bouleEnvs] = await Promise.all([
|
|
2592
2646
|
this.ports.lettermint.listProjects(),
|
|
2593
|
-
this.ports.coolify.listEnvs(
|
|
2647
|
+
this.ports.coolify.listEnvs(self.id),
|
|
2648
|
+
self.role === 'app' ? Promise.resolve(null) : peerListEnvs(appId),
|
|
2649
|
+
self.role === 'boule' ? Promise.resolve(null) : peerListEnvs(bouleId),
|
|
2594
2650
|
]);
|
|
2595
|
-
const
|
|
2651
|
+
const tokenOf = (envs) => envs.find((e) => e.key === 'LETTERMINT_API_TOKEN' && !e.is_preview)?.value || undefined;
|
|
2652
|
+
// The hosted app first, Boule second: the app's token wins a disagreement.
|
|
2653
|
+
const consumers = [];
|
|
2654
|
+
const appSlot = self.role === 'app' ? selfEnvs : appEnvs;
|
|
2655
|
+
const bouleSlot = self.role === 'boule' ? selfEnvs : bouleEnvs;
|
|
2656
|
+
if (appSlot && appId)
|
|
2657
|
+
consumers.push({ id: appId, token: tokenOf(appSlot) });
|
|
2658
|
+
if (bouleSlot && bouleId)
|
|
2659
|
+
consumers.push({ id: bouleId, token: tokenOf(bouleSlot) });
|
|
2660
|
+
const existingToken = consumers.find((c) => c.token)?.token;
|
|
2596
2661
|
const ours = projects.filter((p) => p.name === appName);
|
|
2597
2662
|
const held = ours.length === 1 ? ours[0] : undefined;
|
|
2598
|
-
const healthy = held !== undefined &&
|
|
2663
|
+
const healthy = held !== undefined && existingToken !== undefined;
|
|
2599
2664
|
let allProjects = projects;
|
|
2600
2665
|
let keepProjectId;
|
|
2601
|
-
|
|
2602
|
-
if (healthy && held) {
|
|
2666
|
+
let token;
|
|
2667
|
+
if (healthy && held && existingToken) {
|
|
2603
2668
|
keepProjectId = held.id;
|
|
2669
|
+
token = existingToken;
|
|
2604
2670
|
}
|
|
2605
2671
|
else {
|
|
2606
2672
|
const created = await this.ports.lettermint.createProject(appName);
|
|
2607
2673
|
keepProjectId = created.project.id;
|
|
2608
2674
|
allProjects = [...projects, created.project];
|
|
2609
|
-
|
|
2675
|
+
token = created.token;
|
|
2676
|
+
}
|
|
2677
|
+
// A healthy company: this step writes only its own env; a peer that lacks
|
|
2678
|
+
// the token gets it in its own step. A re-mint kills the old token, so it
|
|
2679
|
+
// reaches every live consumer here.
|
|
2680
|
+
const targets = consumers.filter((c) => c.token !== token && (!healthy || c.id === self.id));
|
|
2681
|
+
const selfChanged = targets.some((c) => c.id === self.id);
|
|
2682
|
+
for (const consumer of consumers) {
|
|
2683
|
+
const updates = [];
|
|
2684
|
+
if (targets.includes(consumer))
|
|
2685
|
+
updates.push({ key: 'LETTERMINT_API_TOKEN', value: token });
|
|
2686
|
+
if (consumer.id === self.id && mailFrom)
|
|
2687
|
+
updates.push({ key: 'MAIL_FROM', value: mailFrom });
|
|
2688
|
+
if (updates.length > 0)
|
|
2689
|
+
await this.ports.coolify.bulkUpsertEnvs(consumer.id, updates);
|
|
2610
2690
|
}
|
|
2611
|
-
envUpdates.push({
|
|
2612
|
-
key: 'MAIL_FROM',
|
|
2613
|
-
value: `${org.mail.noReplyLocalPart ?? 'no-reply'}@${mailDomain}`,
|
|
2614
|
-
});
|
|
2615
|
-
await this.ports.coolify.bulkUpsertEnvs(appId, envUpdates);
|
|
2616
2691
|
// A running container read its env at start and still holds the old
|
|
2617
|
-
// token, so a fresh one only reaches it through a restart --
|
|
2618
|
-
// old project is deleted below
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2692
|
+
// token, so a fresh one only reaches it through a restart -- after every
|
|
2693
|
+
// write and before the old project is deleted below, so a failure in
|
|
2694
|
+
// between leaves the old project for the next apply to re-mint. A just-created
|
|
2695
|
+
// app is deployed first (#566).
|
|
2696
|
+
let selfRestarted = false;
|
|
2697
|
+
for (const consumer of targets) {
|
|
2698
|
+
const isSelf = consumer.id === self.id;
|
|
2699
|
+
const running = isSelf
|
|
2700
|
+
? self.restart
|
|
2701
|
+
: isRunningOrStarting((await this.ports.coolify.getApplication(consumer.id)).status);
|
|
2702
|
+
if (!running)
|
|
2703
|
+
continue;
|
|
2704
|
+
await this.ports.coolify.restart(consumer.id);
|
|
2705
|
+
if (isSelf)
|
|
2706
|
+
selfRestarted = true;
|
|
2707
|
+
}
|
|
2708
|
+
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, args.platformApex, args.platformLettermintProjectId);
|
|
2622
2709
|
for (const project of allProjects) {
|
|
2623
2710
|
if (project.name === appName &&
|
|
2624
2711
|
project.id !== keepProjectId &&
|
|
2625
|
-
project.id !== platformLettermintProjectId) {
|
|
2712
|
+
project.id !== args.platformLettermintProjectId) {
|
|
2626
2713
|
await this.ports.lettermint.deleteProject(project.id);
|
|
2627
2714
|
}
|
|
2628
2715
|
}
|
|
2716
|
+
return { selfChanged, selfRestarted };
|
|
2629
2717
|
}
|
|
2630
2718
|
/**
|
|
2631
2719
|
* #149: mints this org's own Lettermint relay credential and writes it to
|
|
@@ -4237,7 +4325,14 @@ export class FoundryStepExecutor {
|
|
|
4237
4325
|
// written is the env the plan showed.
|
|
4238
4326
|
const issuer = action.desired.issuer ?? null;
|
|
4239
4327
|
const archonUrl = action.desired.archonUrl ?? null;
|
|
4240
|
-
|
|
4328
|
+
// The plan decides blocked mail (`mailBlocked`); a plan with no
|
|
4329
|
+
// `platformApex` key predates this field and is refused before any mail
|
|
4330
|
+
// env is written.
|
|
4331
|
+
const mailWanted = bouleMailWanted(org, context.recordedIds()) && action.desired.mailBlocked !== true;
|
|
4332
|
+
if (mailWanted && !('platformApex' in action.desired)) {
|
|
4333
|
+
throw new Error(`coolify:boule for ${org.slug}: this step's plan carries no platformApex; plan this org's Boule again`);
|
|
4334
|
+
}
|
|
4335
|
+
const { env, missing } = desiredBouleEnv(org, context.recordedIds(), issuer, archonUrl, mailWanted);
|
|
4241
4336
|
if (missing.length > 0) {
|
|
4242
4337
|
throw new Error(`Boule for ${org.slug} cannot be configured: missing ${missing.join(', ')}. Nothing was written.`);
|
|
4243
4338
|
}
|
|
@@ -4288,6 +4383,12 @@ export class FoundryStepExecutor {
|
|
|
4288
4383
|
if (!dbPush.pushed) {
|
|
4289
4384
|
throw new Error(`Boule for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
4290
4385
|
}
|
|
4386
|
+
// The org's Lettermint token, the same one its hosted app holds. A no-op
|
|
4387
|
+
// for an org with mail off or no mail domain recorded: Boule then shows
|
|
4388
|
+
// the invite link.
|
|
4389
|
+
const mail = mailWanted
|
|
4390
|
+
? await this.ensureBouleMail(app.appId, org, context, action, app.existed && Boolean(context.recordedIds()[BOULE_DEPLOYED_SCOPE_KEY]))
|
|
4391
|
+
: { changed: false, restarted: false };
|
|
4291
4392
|
// #504: see `coolifyAgora`'s own comment just above -- `createGithubAppApplication`
|
|
4292
4393
|
// never sets `instant_deploy`, so a brand new app (or one found by name
|
|
4293
4394
|
// that a previous apply created but crashed before deploying) sat built
|
|
@@ -4300,11 +4401,53 @@ export class FoundryStepExecutor {
|
|
|
4300
4401
|
context.recordScopeId(BOULE_DEPLOYED_SCOPE_KEY, '1');
|
|
4301
4402
|
}
|
|
4302
4403
|
else if (app.existed &&
|
|
4303
|
-
|
|
4404
|
+
!mail.restarted &&
|
|
4405
|
+
(envChanged || cookieSecret !== null || appKey !== null || dbPush.changed || mail.changed)) {
|
|
4406
|
+
// A restart inside the mail rule already came after every env write above.
|
|
4304
4407
|
await this.ports.coolify.restart(app.appId);
|
|
4305
4408
|
}
|
|
4306
4409
|
return { resourceId: app.appId };
|
|
4307
4410
|
}
|
|
4411
|
+
/**
|
|
4412
|
+
* Gives Boule `LETTERMINT_API_TOKEN`: the platform's held key for an org on
|
|
4413
|
+
* the platform's own apex, else the org's `<slug>-app` token through the
|
|
4414
|
+
* shared mint rule ({@link ensureOrgAppMailToken}), which mints that project
|
|
4415
|
+
* when no hosted app made it yet. `MAIL_FROM` is written with the rest of
|
|
4416
|
+
* Boule's env. Returns whether Boule's token changed, so the caller
|
|
4417
|
+
* restarts a running Boule, unless `restarted`: the shared rule then already
|
|
4418
|
+
* restarted it (`running`), before it deleted the old project.
|
|
4419
|
+
*/
|
|
4420
|
+
async ensureBouleMail(appId, org, context, action, running) {
|
|
4421
|
+
const none = { changed: false, restarted: false };
|
|
4422
|
+
const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
|
|
4423
|
+
if (!mailDomain)
|
|
4424
|
+
return none;
|
|
4425
|
+
const platformApex = action.desired.platformApex ?? null;
|
|
4426
|
+
const platformLettermintProjectId = action.desired.platformLettermintProjectId ?? null;
|
|
4427
|
+
if (platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase()) {
|
|
4428
|
+
const heldToken = (await this.ports.heldKeys?.resolve(PLATFORM_AUTH_BROKER_MAIL_KEY_REF)) ?? null;
|
|
4429
|
+
if (!heldToken) {
|
|
4430
|
+
throw new Error(`coolify:boule for ${org.slug}: the platform's own Lettermint token is not held yet (held-key ref ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF})`);
|
|
4431
|
+
}
|
|
4432
|
+
const current = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'LETTERMINT_API_TOKEN' && !e.is_preview)?.value;
|
|
4433
|
+
if (current === heldToken)
|
|
4434
|
+
return none;
|
|
4435
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, [
|
|
4436
|
+
{ key: 'LETTERMINT_API_TOKEN', value: heldToken },
|
|
4437
|
+
]);
|
|
4438
|
+
return { changed: true, restarted: false };
|
|
4439
|
+
}
|
|
4440
|
+
const { selfChanged, selfRestarted } = await this.ensureOrgAppMailToken({
|
|
4441
|
+
org,
|
|
4442
|
+
context,
|
|
4443
|
+
mailDomain,
|
|
4444
|
+
self: { role: 'boule', id: appId, restart: running },
|
|
4445
|
+
mailFrom: null,
|
|
4446
|
+
platformApex,
|
|
4447
|
+
platformLettermintProjectId,
|
|
4448
|
+
});
|
|
4449
|
+
return { changed: selfChanged, restarted: selfRestarted };
|
|
4450
|
+
}
|
|
4308
4451
|
// ---- jobs (#173) ---------------------------------------------------------
|
|
4309
4452
|
/**
|
|
4310
4453
|
* `plan/diff.ts`'s `planJobsRegistration`: registers the org's own app with
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { CloudflareClient } from '@wtfalch/providers/cloudflare';
|
|
2
2
|
import type { CoolifyClient } from '@wtfalch/providers/coolify';
|
|
3
3
|
import { type ZitadelClient } from '@wtfalch/providers/zitadel';
|
|
4
|
-
import type
|
|
4
|
+
import { type OrgId } from '../org.js';
|
|
5
5
|
import type { DeclaredOrgStore } from '../store.js';
|
|
6
6
|
import type { AiReader, CloudflareReader, CmsReader, CoolifyReader, LiveResource, ZitadelReader } from './live.js';
|
|
7
7
|
/** The `CoolifyClient` methods the readers call, so a host's own implementation needs no cast. */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ZitadelError } from '@wtfalch/providers/zitadel';
|
|
2
|
-
import {
|
|
2
|
+
import { APP_ENTRY_KEY } from '../org.js';
|
|
3
|
+
import { BOULE_MAIL_TOKEN_ENV, BOULE_MANAGED_ENV_KEYS } from './boule.js';
|
|
3
4
|
/**
|
|
4
5
|
* A {@link ScopeResolver} backed by `AppliedState.serviceIds` (`../state.ts`)
|
|
5
6
|
* — the one place foundry records a provider's own id for an org today,
|
|
@@ -247,7 +248,7 @@ async function withAgoraEnvConfig(client, resource, recorded) {
|
|
|
247
248
|
* `executor.ts` would write. Gated and failing exactly like
|
|
248
249
|
* {@link withAgoraEnvConfig}.
|
|
249
250
|
*/
|
|
250
|
-
async function withBouleEnvConfig(client, resource, recorded) {
|
|
251
|
+
async function withBouleEnvConfig(client, resource, recorded, liveKeys) {
|
|
251
252
|
if (resource.kind !== 'application' || !recorded[resource.key]?.startsWith('boule:')) {
|
|
252
253
|
return resource;
|
|
253
254
|
}
|
|
@@ -257,7 +258,20 @@ async function withBouleEnvConfig(client, resource, recorded) {
|
|
|
257
258
|
if (!e.is_preview && managed.includes(e.key))
|
|
258
259
|
env[e.key] = e.value;
|
|
259
260
|
}
|
|
260
|
-
|
|
261
|
+
// The hosted app's Lettermint token, as `config.appMailToken`: the one
|
|
262
|
+
// Boule must hold too. In memory only, compared by `planBoule` and never
|
|
263
|
+
// written to a step, description or log.
|
|
264
|
+
const orgId = recorded[resource.key]?.slice('boule:'.length);
|
|
265
|
+
const appUuid = Object.entries(recorded).find(([, value]) => value === `app:${orgId}:${APP_ENTRY_KEY}`)?.[0];
|
|
266
|
+
// A recorded app that is not in the live listing is gone: no app token, and
|
|
267
|
+
// no read of its envs. A listed app whose read fails fails like any read.
|
|
268
|
+
const appToken = appUuid && liveKeys.has(appUuid)
|
|
269
|
+
? (await client.listEnvs(appUuid)).find((e) => !e.is_preview && e.key === BOULE_MAIL_TOKEN_ENV)?.value
|
|
270
|
+
: undefined;
|
|
271
|
+
return {
|
|
272
|
+
...resource,
|
|
273
|
+
config: { ...resource.config, env, ...(appToken ? { appMailToken: appToken } : {}) },
|
|
274
|
+
};
|
|
261
275
|
}
|
|
262
276
|
/** `@wtfalch/providers`' own `LiveResource` (`resource.ts`) has no `system` field — foundry's adds it at this boundary. */
|
|
263
277
|
function toFoundryResources(system, resources) {
|
|
@@ -290,7 +304,8 @@ export class ProvidersCoolifyReader {
|
|
|
290
304
|
const withEnvNames = await Promise.all(withHealthCheck.map((r) => withEnvNamesConfig(this.client, r, recorded)));
|
|
291
305
|
const withDbUrl = await Promise.all(withEnvNames.map((r) => withDatabaseUrlConfig(this.client, r, recorded)));
|
|
292
306
|
const withAgoraConfig = await Promise.all(withDbUrl.map((r) => withAgoraEnvConfig(this.client, r, recorded)));
|
|
293
|
-
const
|
|
307
|
+
const liveKeys = new Set(withAgoraConfig.map((r) => r.key));
|
|
308
|
+
const withBouleConfig = await Promise.all(withAgoraConfig.map((r) => withBouleEnvConfig(this.client, r, recorded, liveKeys)));
|
|
294
309
|
return applyRecordedIds(withBouleConfig, recorded);
|
|
295
310
|
}
|
|
296
311
|
/** The project's resources as Coolify reports them, unmapped: what adopt checks against a declaration. */
|