@wtfalch/foundry 0.16.15 → 0.16.16
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/org.d.ts +28 -5
- package/dist/org.js +19 -0
- package/dist/plan/deploy-health.d.ts +136 -0
- package/dist/plan/deploy-health.js +153 -0
- package/dist/plan/diff.js +50 -9
- package/dist/plan/entry-files-key.d.ts +3 -1
- package/dist/plan/entry-files-key.js +7 -10
- package/dist/plan/executor.d.ts +52 -5
- package/dist/plan/executor.js +79 -6
- package/dist/plan/hostnames.d.ts +6 -0
- package/dist/plan/hostnames.js +13 -0
- package/dist/plan/index.d.ts +1 -1
- package/dist/plan/index.js +1 -1
- package/dist/plan/plan-apply.d.ts +17 -0
- package/dist/plan/plan-apply.js +48 -3
- package/dist/plan/platform-diff.d.ts +89 -9
- package/dist/plan/platform-diff.js +176 -13
- package/dist/plan/platform-org.d.ts +9 -0
- package/dist/plan/platform-providers.d.ts +38 -5
- package/dist/plan/platform-providers.js +520 -99
- package/package.json +2 -2
package/dist/org.d.ts
CHANGED
|
@@ -333,8 +333,10 @@ export declare const AI_ENTRY_ENV_NAMES: readonly ["AI_URL", "AI_ORGANISATION_ID
|
|
|
333
333
|
* env and one collection per name in `collections`. A name is uppercase
|
|
334
334
|
* `[A-Z0-9_]`; its id is delivered as `FILES_COLLECTION_<NAME>_ID`.
|
|
335
335
|
* `permissions` is a subset of {@link FILES_ENTRY_PERMISSIONS}, which is also
|
|
336
|
-
* the default. `shareHost` asks for `share.<domain
|
|
337
|
-
*
|
|
336
|
+
* the default. `shareHost` asks for `share.<domain>`: foundry routes and
|
|
337
|
+
* publishes that name through the org's tunnel, delivers `FILES_SHARE_ORIGIN`
|
|
338
|
+
* and passes it to files. The platform files runner must still serve the
|
|
339
|
+
* host (a Coolify domain and `FILES_PUBLIC_HOSTS`); the plan warns of that.
|
|
338
340
|
*/
|
|
339
341
|
export interface EntryFiles {
|
|
340
342
|
collections: string[];
|
|
@@ -356,8 +358,18 @@ export interface HostingEntryUses {
|
|
|
356
358
|
* looks like a secret. `{ from }` names a value the plan already mints.
|
|
357
359
|
*/
|
|
358
360
|
export type EnvValue = string | {
|
|
359
|
-
from: AppEnvSource
|
|
360
|
-
};
|
|
361
|
+
from: Exclude<AppEnvSource, 'sharedSecret'>;
|
|
362
|
+
} | EnvSharedSecret;
|
|
363
|
+
/**
|
|
364
|
+
* A secret foundry generates once and holds under an org-scoped ref. Every
|
|
365
|
+
* entry of the same org that names the same `name` gets the same value; an
|
|
366
|
+
* entry of another org never does. It is written to the entry's env only, and
|
|
367
|
+
* never reaches a plan, a log or an error.
|
|
368
|
+
*/
|
|
369
|
+
export interface EnvSharedSecret {
|
|
370
|
+
from: 'sharedSecret';
|
|
371
|
+
name: string;
|
|
372
|
+
}
|
|
361
373
|
/** The org's `app` entry: today's one app. */
|
|
362
374
|
export declare function appEntry(hosting: HostingConfig): HostingEntry | undefined;
|
|
363
375
|
/**
|
|
@@ -367,8 +379,19 @@ export declare function appEntry(hosting: HostingConfig): HostingEntry | undefin
|
|
|
367
379
|
* sends from. Each is read from `StepExecutionContext.recordedIds()` in
|
|
368
380
|
* `executor.ts`'s `pushAppEnv`, never minted again there.
|
|
369
381
|
*/
|
|
370
|
-
export type AppEnvSource = 'filesOrganisationId' | 'aiOrganisationId' | 'mailDomain';
|
|
382
|
+
export type AppEnvSource = 'filesOrganisationId' | 'aiOrganisationId' | 'mailDomain' | 'sharedSecret' | 'platformMailKey';
|
|
383
|
+
/**
|
|
384
|
+
* Two sources are secrets. `sharedSecret` is {@link EnvSharedSecret}.
|
|
385
|
+
* `platformMailKey` is the platform's held Lettermint key, handed over as the
|
|
386
|
+
* value of the declared env name (the platform's own apps call it
|
|
387
|
+
* `LETTERMINT_API_TOKEN`). Any hosting entry of any customer org may name it
|
|
388
|
+
* (William, 2026-10-04): mail is Lettermint, and the key is the platform's.
|
|
389
|
+
*/
|
|
371
390
|
export declare const APP_ENV_SOURCES: ReadonlySet<AppEnvSource>;
|
|
391
|
+
/** The sources whose value is a secret: resolved at apply time, never recorded. */
|
|
392
|
+
export declare const SECRET_APP_ENV_SOURCES: ReadonlySet<AppEnvSource>;
|
|
393
|
+
/** The held-key ref of an org's shared secret. */
|
|
394
|
+
export declare const sharedSecretRef: (orgId: string, name: string) => string;
|
|
372
395
|
/**
|
|
373
396
|
* `null` when `hosting` is valid; otherwise the reason it is not (#71), same
|
|
374
397
|
* shape as `validateBuyerIdentity` below.
|
package/dist/org.js
CHANGED
|
@@ -39,11 +39,27 @@ export const filesCollectionEnvName = (name) => `FILES_COLLECTION_${name}_ID`;
|
|
|
39
39
|
export function appEntry(hosting) {
|
|
40
40
|
return hosting.entries.find((entry) => entry.key === APP_ENTRY_KEY);
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Two sources are secrets. `sharedSecret` is {@link EnvSharedSecret}.
|
|
44
|
+
* `platformMailKey` is the platform's held Lettermint key, handed over as the
|
|
45
|
+
* value of the declared env name (the platform's own apps call it
|
|
46
|
+
* `LETTERMINT_API_TOKEN`). Any hosting entry of any customer org may name it
|
|
47
|
+
* (William, 2026-10-04): mail is Lettermint, and the key is the platform's.
|
|
48
|
+
*/
|
|
42
49
|
export const APP_ENV_SOURCES = new Set([
|
|
43
50
|
'filesOrganisationId',
|
|
44
51
|
'aiOrganisationId',
|
|
45
52
|
'mailDomain',
|
|
53
|
+
'sharedSecret',
|
|
54
|
+
'platformMailKey',
|
|
46
55
|
]);
|
|
56
|
+
/** The sources whose value is a secret: resolved at apply time, never recorded. */
|
|
57
|
+
export const SECRET_APP_ENV_SOURCES = new Set([
|
|
58
|
+
'sharedSecret',
|
|
59
|
+
'platformMailKey',
|
|
60
|
+
]);
|
|
61
|
+
/** The held-key ref of an org's shared secret. */
|
|
62
|
+
export const sharedSecretRef = (orgId, name) => `foundry:shared-secret:${orgId}:${name}`;
|
|
47
63
|
const ISSUER_APP_KINDS = new Set(['web', 'native', 'api']);
|
|
48
64
|
const HOSTING_BUILD_PACKS = new Set([
|
|
49
65
|
'dockerfile',
|
|
@@ -267,6 +283,9 @@ function validateHostingEntry(entry, enabled) {
|
|
|
267
283
|
if (!APP_ENV_SOURCES.has(value?.from)) {
|
|
268
284
|
return `${at} env '${name}' has an unknown source '${value?.from}'`;
|
|
269
285
|
}
|
|
286
|
+
if (value.from === 'sharedSecret' && !ENTRY_KEY.test(value.name ?? '')) {
|
|
287
|
+
return `${at} env '${name}' sharedSecret needs a name (lowercase letters, digits and hyphens)`;
|
|
288
|
+
}
|
|
270
289
|
}
|
|
271
290
|
else if (SECRET_ENV_NAME.test(name) || (name.endsWith('_URL') && urlHasPassword(value))) {
|
|
272
291
|
return `${at} env '${name}' looks like a secret: ${HELD_SECRETS}`;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #252: the one shared helper every platform step calls between deploying a
|
|
3
|
+
* new version and recording it as the applied ref.
|
|
4
|
+
*
|
|
5
|
+
* Before this, a step that records `platform:*`'s applied hostname/gitRef
|
|
6
|
+
* called `CoolifyPort.deploy` -- fire-and-forget by design (see its own doc
|
|
7
|
+
* comment) -- and recorded the new ref right after, with nothing in between
|
|
8
|
+
* proving the new version had actually landed. A failed deploy leaves the
|
|
9
|
+
* previous container running and healthy (`@wtfalch/providers`'
|
|
10
|
+
* `CoolifyDeploymentRecord` doc comment: "`status` reports on the container
|
|
11
|
+
* that is *running*, and a failed build leaves the previous one running and
|
|
12
|
+
* healthy"), so the old container's healthy status was being read as the
|
|
13
|
+
* new deployment's -- exactly what `CLAUDE.md`'s platform-step rule 5 ("no
|
|
14
|
+
* waiting forever... an old healthy status is never read as the new one")
|
|
15
|
+
* exists to forbid.
|
|
16
|
+
*
|
|
17
|
+
* {@link waitForNewDeployment} closes that gap in two steps, always in this
|
|
18
|
+
* order:
|
|
19
|
+
*
|
|
20
|
+
* 1. `coolify.deployAndWait` -- blocks until *Coolify's own deployment
|
|
21
|
+
* record* (not `getApplication`'s live container status) reaches a
|
|
22
|
+
* terminal state. Anything but `'finished'` throws immediately: a build
|
|
23
|
+
* or deploy failure is a hard stop no health check below could ever
|
|
24
|
+
* paper over, and it is the step that actually catches "the old
|
|
25
|
+
* container stays healthy while the deployment fails" -- the container's
|
|
26
|
+
* own status never moves in that scenario, so only the deployment record
|
|
27
|
+
* itself tells the two apart.
|
|
28
|
+
* 2. The health check named by `health.kind`, decided per call site and
|
|
29
|
+
* stated there (#252's own decision):
|
|
30
|
+
* - `'version'` -- the service's health response carries a version
|
|
31
|
+
* string this service actually exposes; polled until it matches
|
|
32
|
+
* `health.expected` exactly. Unambiguous proof it is the *new*
|
|
33
|
+
* version specifically, but only usable where the service has one.
|
|
34
|
+
* - `'ready'` -- the health url answers 2xx. Works for every service
|
|
35
|
+
* with a url this helper's caller can actually reach, including ones
|
|
36
|
+
* with no version field (the fallback, not the preferred mode), and
|
|
37
|
+
* the old-container loophole this mode alone cannot close ("can be
|
|
38
|
+
* fooled by the old container staying up") is already closed by step
|
|
39
|
+
* 1 having confirmed Coolify's own deployment record finished first.
|
|
40
|
+
* - `'deployment-only'` -- no health check at all; step 1 is the whole
|
|
41
|
+
* answer. For a step whose app has nothing public wired (no Coolify
|
|
42
|
+
* `domains`, tunnel, DNS record -- `platform:ai`/`platform:files`'
|
|
43
|
+
* own doc comments say so explicitly) there is no url this helper's
|
|
44
|
+
* caller, running outside that app's own Docker network, could ever
|
|
45
|
+
* reach, so inventing one to poll would just be a slower, flakier way
|
|
46
|
+
* of saying "I called deployAndWait". A step that can only use this
|
|
47
|
+
* mode is not unverified the way `'ready'` is *within* `'ready'`'s own
|
|
48
|
+
* guarantee -- it is strictly weaker than the other two (a deployment
|
|
49
|
+
* that "finished" with a crash-looping container is read as success),
|
|
50
|
+
* but it is the one honest answer this helper can give it, and it is
|
|
51
|
+
* still what catches #252's named failure (the old container staying
|
|
52
|
+
* healthy while the deployment record itself fails), since that
|
|
53
|
+
* record is exactly what step 1 reads.
|
|
54
|
+
*
|
|
55
|
+
* Throws on any failure -- the deployment itself, or the health check
|
|
56
|
+
* timing out -- naming `appliedRefDescription` (what is still live, for
|
|
57
|
+
* whoever reads the error) so the caller's own "record applied ref" line is
|
|
58
|
+
* never reached. Rule 1 from `CLAUDE.md` applies here too: never call this
|
|
59
|
+
* from inside a retry loop that could mint a one-time secret again, since a
|
|
60
|
+
* failed wait is meant to be repaired by re-running the whole step, not
|
|
61
|
+
* silently retried from inside it.
|
|
62
|
+
*/
|
|
63
|
+
export interface DeployAndWaitPort {
|
|
64
|
+
deployAndWait(uuid: string, options?: {
|
|
65
|
+
force?: boolean;
|
|
66
|
+
attempts?: number;
|
|
67
|
+
intervalMs?: number;
|
|
68
|
+
}): Promise<{
|
|
69
|
+
deploymentUuid: string;
|
|
70
|
+
status: string;
|
|
71
|
+
}>;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* How a call site proves the new version is the one that answers (or that
|
|
75
|
+
* there is nothing for it to reach at all). See this module's own doc
|
|
76
|
+
* comment for why there are exactly three and no "pick one per call"
|
|
77
|
+
* default.
|
|
78
|
+
*/
|
|
79
|
+
export type DeployHealthCheck = {
|
|
80
|
+
readonly kind: 'version';
|
|
81
|
+
readonly url: string;
|
|
82
|
+
/** Exact match against the health body's version field. */
|
|
83
|
+
readonly expected: string;
|
|
84
|
+
/** Field name in the parsed JSON body (default `'version'`). */
|
|
85
|
+
readonly field?: string;
|
|
86
|
+
} | {
|
|
87
|
+
readonly kind: 'ready';
|
|
88
|
+
readonly url: string;
|
|
89
|
+
} | {
|
|
90
|
+
/** No reachable url; `coolify.deployAndWait` alone is the check. See this module's doc comment. */
|
|
91
|
+
readonly kind: 'deployment-only';
|
|
92
|
+
};
|
|
93
|
+
export interface DeployHealthRuntime {
|
|
94
|
+
fetch?: typeof fetch;
|
|
95
|
+
sleep?: (ms: number) => Promise<void>;
|
|
96
|
+
now?: () => number;
|
|
97
|
+
/** Passed through to `deployAndWait` (default: its own, 90 * 10s = 15 minutes). */
|
|
98
|
+
deployAttempts?: number;
|
|
99
|
+
deployIntervalMs?: number;
|
|
100
|
+
/** `deployAndWait`'s own `force` (default `true` -- see this module's doc comment). */
|
|
101
|
+
deployForce?: boolean;
|
|
102
|
+
/** How long to wait for the health check to confirm the new version (default 5 minutes). */
|
|
103
|
+
readyTimeoutMs?: number;
|
|
104
|
+
/** Pause between health polls (default 5 seconds). */
|
|
105
|
+
readyIntervalMs?: number;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Phase 1 alone: deploys `appId` and blocks until Coolify's own deployment
|
|
109
|
+
* record reaches a terminal state, throwing unless it is `'finished'`. Split
|
|
110
|
+
* out of {@link waitForNewDeployment} for a step whose health url only
|
|
111
|
+
* resolves once *later* work in the same step (archon's/mail's own tunnel
|
|
112
|
+
* and DNS wiring) has run -- that work must happen between this call and
|
|
113
|
+
* {@link waitForHealthyVersion}, so the two cannot be one call there. A step
|
|
114
|
+
* with no such ordering constraint calls {@link waitForNewDeployment}
|
|
115
|
+
* instead, never this plus {@link waitForHealthyVersion} by hand.
|
|
116
|
+
*/
|
|
117
|
+
export declare function waitForDeploymentFinished(coolify: DeployAndWaitPort, appId: string, runtime: DeployHealthRuntime, label: string, appliedRefDescription: string): Promise<void>;
|
|
118
|
+
/**
|
|
119
|
+
* Phase 2 alone: the health check named by `health.kind`, with no deploy and
|
|
120
|
+
* no Coolify polling -- see {@link waitForDeploymentFinished}'s own doc
|
|
121
|
+
* comment for when a step calls this separately instead of
|
|
122
|
+
* {@link waitForNewDeployment}. A `'deployment-only'` check is a no-op here
|
|
123
|
+
* by design: phase 1 was already the whole answer for it.
|
|
124
|
+
*/
|
|
125
|
+
export declare function waitForHealthyVersion(appId: string, health: DeployHealthCheck, runtime: DeployHealthRuntime, sleep: (ms: number) => Promise<void>, label: string, appliedRefDescription: string): Promise<void>;
|
|
126
|
+
/**
|
|
127
|
+
* Deploys `appId` and does not return until Coolify's own deployment record
|
|
128
|
+
* says it finished *and* `health` confirms the new version is answering --
|
|
129
|
+
* {@link waitForDeploymentFinished} then {@link waitForHealthyVersion}, in
|
|
130
|
+
* that order, for a step with no reason to run anything between them. See
|
|
131
|
+
* this module's own doc comment for why each half exists; `label` names the
|
|
132
|
+
* step in every error (`platform:mail`, …) and `appliedRefDescription` names
|
|
133
|
+
* what is still live so a caller's error is actionable without the helper
|
|
134
|
+
* itself knowing any step's scope keys.
|
|
135
|
+
*/
|
|
136
|
+
export declare function waitForNewDeployment(coolify: DeployAndWaitPort, appId: string, health: DeployHealthCheck, runtime: DeployHealthRuntime, sleep: (ms: number) => Promise<void>, label: string, appliedRefDescription: string): Promise<void>;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #252: the one shared helper every platform step calls between deploying a
|
|
3
|
+
* new version and recording it as the applied ref.
|
|
4
|
+
*
|
|
5
|
+
* Before this, a step that records `platform:*`'s applied hostname/gitRef
|
|
6
|
+
* called `CoolifyPort.deploy` -- fire-and-forget by design (see its own doc
|
|
7
|
+
* comment) -- and recorded the new ref right after, with nothing in between
|
|
8
|
+
* proving the new version had actually landed. A failed deploy leaves the
|
|
9
|
+
* previous container running and healthy (`@wtfalch/providers`'
|
|
10
|
+
* `CoolifyDeploymentRecord` doc comment: "`status` reports on the container
|
|
11
|
+
* that is *running*, and a failed build leaves the previous one running and
|
|
12
|
+
* healthy"), so the old container's healthy status was being read as the
|
|
13
|
+
* new deployment's -- exactly what `CLAUDE.md`'s platform-step rule 5 ("no
|
|
14
|
+
* waiting forever... an old healthy status is never read as the new one")
|
|
15
|
+
* exists to forbid.
|
|
16
|
+
*
|
|
17
|
+
* {@link waitForNewDeployment} closes that gap in two steps, always in this
|
|
18
|
+
* order:
|
|
19
|
+
*
|
|
20
|
+
* 1. `coolify.deployAndWait` -- blocks until *Coolify's own deployment
|
|
21
|
+
* record* (not `getApplication`'s live container status) reaches a
|
|
22
|
+
* terminal state. Anything but `'finished'` throws immediately: a build
|
|
23
|
+
* or deploy failure is a hard stop no health check below could ever
|
|
24
|
+
* paper over, and it is the step that actually catches "the old
|
|
25
|
+
* container stays healthy while the deployment fails" -- the container's
|
|
26
|
+
* own status never moves in that scenario, so only the deployment record
|
|
27
|
+
* itself tells the two apart.
|
|
28
|
+
* 2. The health check named by `health.kind`, decided per call site and
|
|
29
|
+
* stated there (#252's own decision):
|
|
30
|
+
* - `'version'` -- the service's health response carries a version
|
|
31
|
+
* string this service actually exposes; polled until it matches
|
|
32
|
+
* `health.expected` exactly. Unambiguous proof it is the *new*
|
|
33
|
+
* version specifically, but only usable where the service has one.
|
|
34
|
+
* - `'ready'` -- the health url answers 2xx. Works for every service
|
|
35
|
+
* with a url this helper's caller can actually reach, including ones
|
|
36
|
+
* with no version field (the fallback, not the preferred mode), and
|
|
37
|
+
* the old-container loophole this mode alone cannot close ("can be
|
|
38
|
+
* fooled by the old container staying up") is already closed by step
|
|
39
|
+
* 1 having confirmed Coolify's own deployment record finished first.
|
|
40
|
+
* - `'deployment-only'` -- no health check at all; step 1 is the whole
|
|
41
|
+
* answer. For a step whose app has nothing public wired (no Coolify
|
|
42
|
+
* `domains`, tunnel, DNS record -- `platform:ai`/`platform:files`'
|
|
43
|
+
* own doc comments say so explicitly) there is no url this helper's
|
|
44
|
+
* caller, running outside that app's own Docker network, could ever
|
|
45
|
+
* reach, so inventing one to poll would just be a slower, flakier way
|
|
46
|
+
* of saying "I called deployAndWait". A step that can only use this
|
|
47
|
+
* mode is not unverified the way `'ready'` is *within* `'ready'`'s own
|
|
48
|
+
* guarantee -- it is strictly weaker than the other two (a deployment
|
|
49
|
+
* that "finished" with a crash-looping container is read as success),
|
|
50
|
+
* but it is the one honest answer this helper can give it, and it is
|
|
51
|
+
* still what catches #252's named failure (the old container staying
|
|
52
|
+
* healthy while the deployment record itself fails), since that
|
|
53
|
+
* record is exactly what step 1 reads.
|
|
54
|
+
*
|
|
55
|
+
* Throws on any failure -- the deployment itself, or the health check
|
|
56
|
+
* timing out -- naming `appliedRefDescription` (what is still live, for
|
|
57
|
+
* whoever reads the error) so the caller's own "record applied ref" line is
|
|
58
|
+
* never reached. Rule 1 from `CLAUDE.md` applies here too: never call this
|
|
59
|
+
* from inside a retry loop that could mint a one-time secret again, since a
|
|
60
|
+
* failed wait is meant to be repaired by re-running the whole step, not
|
|
61
|
+
* silently retried from inside it.
|
|
62
|
+
*/
|
|
63
|
+
const DEFAULT_READY_TIMEOUT_MS = 300_000;
|
|
64
|
+
const DEFAULT_READY_INTERVAL_MS = 5_000;
|
|
65
|
+
/** The one terminal `CoolifyDeploymentStatus` that means the new version landed. */
|
|
66
|
+
const DEPLOYMENT_SUCCEEDED = 'finished';
|
|
67
|
+
/**
|
|
68
|
+
* Phase 1 alone: deploys `appId` and blocks until Coolify's own deployment
|
|
69
|
+
* record reaches a terminal state, throwing unless it is `'finished'`. Split
|
|
70
|
+
* out of {@link waitForNewDeployment} for a step whose health url only
|
|
71
|
+
* resolves once *later* work in the same step (archon's/mail's own tunnel
|
|
72
|
+
* and DNS wiring) has run -- that work must happen between this call and
|
|
73
|
+
* {@link waitForHealthyVersion}, so the two cannot be one call there. A step
|
|
74
|
+
* with no such ordering constraint calls {@link waitForNewDeployment}
|
|
75
|
+
* instead, never this plus {@link waitForHealthyVersion} by hand.
|
|
76
|
+
*/
|
|
77
|
+
export async function waitForDeploymentFinished(coolify, appId, runtime, label, appliedRefDescription) {
|
|
78
|
+
let result;
|
|
79
|
+
try {
|
|
80
|
+
result = await coolify.deployAndWait(appId, {
|
|
81
|
+
force: runtime.deployForce ?? true,
|
|
82
|
+
attempts: runtime.deployAttempts,
|
|
83
|
+
intervalMs: runtime.deployIntervalMs,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
88
|
+
throw new Error(`${label}: the deployment of ${appId} did not finish (${reason}); ${appliedRefDescription}. Apply again`);
|
|
89
|
+
}
|
|
90
|
+
const { status, deploymentUuid } = result;
|
|
91
|
+
if (status !== DEPLOYMENT_SUCCEEDED) {
|
|
92
|
+
throw new Error(`${label}: the deployment (${deploymentUuid}) of ${appId} did not finish (status: ${status}); ${appliedRefDescription}. Apply again`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Phase 2 alone: the health check named by `health.kind`, with no deploy and
|
|
97
|
+
* no Coolify polling -- see {@link waitForDeploymentFinished}'s own doc
|
|
98
|
+
* comment for when a step calls this separately instead of
|
|
99
|
+
* {@link waitForNewDeployment}. A `'deployment-only'` check is a no-op here
|
|
100
|
+
* by design: phase 1 was already the whole answer for it.
|
|
101
|
+
*/
|
|
102
|
+
export async function waitForHealthyVersion(appId, health, runtime, sleep, label, appliedRefDescription) {
|
|
103
|
+
if (health.kind === 'deployment-only')
|
|
104
|
+
return;
|
|
105
|
+
const fetchImpl = runtime.fetch ?? globalThis.fetch;
|
|
106
|
+
const now = runtime.now ?? Date.now;
|
|
107
|
+
const timeout = runtime.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
|
|
108
|
+
const interval = runtime.readyIntervalMs ?? DEFAULT_READY_INTERVAL_MS;
|
|
109
|
+
const deadline = now() + timeout;
|
|
110
|
+
for (;;) {
|
|
111
|
+
if (await matchesHealth(fetchImpl, health))
|
|
112
|
+
return;
|
|
113
|
+
if (now() >= deadline) {
|
|
114
|
+
const detail = health.kind === 'version'
|
|
115
|
+
? `never answered version "${health.expected}" at ${health.url}`
|
|
116
|
+
: `never answered 2xx at ${health.url}`;
|
|
117
|
+
throw new Error(`${label}: ${appId} ${detail} within ${Math.round(timeout / 1000)}s; ${appliedRefDescription}. Apply again`);
|
|
118
|
+
}
|
|
119
|
+
await sleep(interval);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Deploys `appId` and does not return until Coolify's own deployment record
|
|
124
|
+
* says it finished *and* `health` confirms the new version is answering --
|
|
125
|
+
* {@link waitForDeploymentFinished} then {@link waitForHealthyVersion}, in
|
|
126
|
+
* that order, for a step with no reason to run anything between them. See
|
|
127
|
+
* this module's own doc comment for why each half exists; `label` names the
|
|
128
|
+
* step in every error (`platform:mail`, …) and `appliedRefDescription` names
|
|
129
|
+
* what is still live so a caller's error is actionable without the helper
|
|
130
|
+
* itself knowing any step's scope keys.
|
|
131
|
+
*/
|
|
132
|
+
export async function waitForNewDeployment(coolify, appId, health, runtime, sleep, label, appliedRefDescription) {
|
|
133
|
+
await waitForDeploymentFinished(coolify, appId, runtime, label, appliedRefDescription);
|
|
134
|
+
await waitForHealthyVersion(appId, health, runtime, sleep, label, appliedRefDescription);
|
|
135
|
+
}
|
|
136
|
+
async function matchesHealth(fetchImpl, health) {
|
|
137
|
+
try {
|
|
138
|
+
const response = await fetchImpl(health.url);
|
|
139
|
+
if (!response.ok)
|
|
140
|
+
return false;
|
|
141
|
+
if (health.kind === 'ready')
|
|
142
|
+
return true;
|
|
143
|
+
const body = await response.json().catch(() => undefined);
|
|
144
|
+
const value = body && typeof body === 'object'
|
|
145
|
+
? body[health.field ?? 'version']
|
|
146
|
+
: undefined;
|
|
147
|
+
return value === health.expected;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// Not up yet: DNS, the tunnel, the container, or a transient network error.
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
package/dist/plan/diff.js
CHANGED
|
@@ -10,6 +10,7 @@ import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHo
|
|
|
10
10
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
|
|
11
11
|
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appEnvAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, versionNotAppliedLine, } from './platform-diff.js';
|
|
12
12
|
import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
|
|
13
|
+
import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
|
|
13
14
|
import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec } from './proxy-labels.js';
|
|
14
15
|
/** The `Plan` (#19's public contract) a `PlanDetail` carries. */
|
|
15
16
|
export function toPlan(detail) {
|
|
@@ -82,7 +83,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
82
83
|
const issuer = planIssuer(org, byKey, claimed, platformReadiness.authReady);
|
|
83
84
|
if (issuer.step)
|
|
84
85
|
plannedSteps.push(issuer.step);
|
|
85
|
-
plannedSteps.push(...planOwners(org, recordedIds, platformReadiness.authReady));
|
|
86
|
+
plannedSteps.push(...planOwners(org, recordedIds, platformReadiness.authReady, 'error' in agoraHost ? null : agoraHost.issuer));
|
|
86
87
|
// #267: after the ZITADEL org step, before any app's own issuer
|
|
87
88
|
// application. Apps created later in this run are not in the binding yet;
|
|
88
89
|
// the next run replans this step and repoints them.
|
|
@@ -188,7 +189,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
188
189
|
if (database)
|
|
189
190
|
plannedSteps.push(database);
|
|
190
191
|
const authIssuer = 'error' in agoraHost ? null : agoraHost.issuer;
|
|
191
|
-
const app = planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer);
|
|
192
|
+
const app = planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, platformReadiness);
|
|
192
193
|
plannedSteps.push(...app.steps);
|
|
193
194
|
// After the apps: the broker entry's key is minted into each app's env.
|
|
194
195
|
const brokerEntries = planBrokerEntries(org, recordedIds, plannedSteps, options.broker ?? false);
|
|
@@ -433,7 +434,7 @@ function planIssuer(org, byKey, claimed, authReady) {
|
|
|
433
434
|
* nothing here puts it anywhere beyond what audit already records for the
|
|
434
435
|
* org.
|
|
435
436
|
*/
|
|
436
|
-
function planOwners(org, recordedIds, authReady) {
|
|
437
|
+
function planOwners(org, recordedIds, authReady, authIssuer) {
|
|
437
438
|
// #177: nothing to invite into yet -- `planIssuer` already reports the
|
|
438
439
|
// platform's sign-in as blocked, so these steps stay silent rather than
|
|
439
440
|
// repeating it.
|
|
@@ -455,6 +456,8 @@ function planOwners(org, recordedIds, authReady) {
|
|
|
455
456
|
email: owner.email,
|
|
456
457
|
givenName: owner.givenName,
|
|
457
458
|
familyName: owner.familyName,
|
|
459
|
+
// The platform's login app, where the invite link must land (it serves `/verify`).
|
|
460
|
+
...(authIssuer ? { authIssuer } : {}),
|
|
458
461
|
},
|
|
459
462
|
},
|
|
460
463
|
step: {
|
|
@@ -1004,6 +1007,16 @@ function notReadyReason(service, keyName, missingKeyRef, noKeyReader) {
|
|
|
1004
1007
|
* own files host (#171) is off or not yet applied, so there is nothing here
|
|
1005
1008
|
* for `FilesOrganisationPort.provisionOrganisation` to reach.
|
|
1006
1009
|
*
|
|
1010
|
+
* F6b: `shareHost` is `share.<domain>` when any entry declares
|
|
1011
|
+
* `uses.files.shareHost`, else `null`. It is passed to the provision call
|
|
1012
|
+
* every time, so files always knows the name an org wants. The route and
|
|
1013
|
+
* DNS come from `tunnelIngressHostnames`; `planFilesKeys` warns that the
|
|
1014
|
+
* platform files runner must serve the host too. Computed once, at the only point `provisionOrganisation`
|
|
1015
|
+
* is ever planned (nothing to diff once the organisation is live, same as
|
|
1016
|
+
* `ceilingBytes`/`ceilingMicros` above): a `shareHost` declared after the
|
|
1017
|
+
* organisation already exists needs its own follow-up, matching every other
|
|
1018
|
+
* field on this singleton step.
|
|
1019
|
+
*
|
|
1007
1020
|
* #273: `desired.agoraOrigin` is every org's own Agora origin (#164's
|
|
1008
1021
|
* `agoraHostnameFor`, always on, never blocked on anything this step
|
|
1009
1022
|
* doesn't already wait for) -- `executor.ts`'s `filesOrganisation` adds it
|
|
@@ -1026,6 +1039,9 @@ function planFilesOrganisation(org, byKey, claimed, filesReady, missingKeyRef, n
|
|
|
1026
1039
|
blocked: `${org.slug}'s Files organisation is blocked, not planned: ${notReadyReason('files', 'files management key', missingKeyRef, noKeyReader)}`,
|
|
1027
1040
|
};
|
|
1028
1041
|
}
|
|
1042
|
+
const shareHost = org.domain && org.hosting.entries.some((e) => e.uses?.files?.shareHost)
|
|
1043
|
+
? `share.${org.domain}`
|
|
1044
|
+
: null;
|
|
1029
1045
|
return {
|
|
1030
1046
|
step: {
|
|
1031
1047
|
action: {
|
|
@@ -1036,6 +1052,7 @@ function planFilesOrganisation(org, byKey, claimed, filesReady, missingKeyRef, n
|
|
|
1036
1052
|
provision: true,
|
|
1037
1053
|
ceilingBytes: null,
|
|
1038
1054
|
ceilingMicros: null,
|
|
1055
|
+
shareHost,
|
|
1039
1056
|
agoraOrigin: `https://${agoraHostnameFor(org)}`,
|
|
1040
1057
|
},
|
|
1041
1058
|
},
|
|
@@ -1636,7 +1653,7 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
|
|
|
1636
1653
|
* resource's `enabled` field -- a live resource nothing has actually
|
|
1637
1654
|
* checked yet is assumed fine rather than replanned every time.
|
|
1638
1655
|
*/
|
|
1639
|
-
function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authIssuer) {
|
|
1656
|
+
function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authIssuer, readiness) {
|
|
1640
1657
|
// #243: an `appEnv` source with no recorded value and no step planned to
|
|
1641
1658
|
// record one this run has nothing to push, now or after this apply.
|
|
1642
1659
|
const sourceStep = {
|
|
@@ -1651,9 +1668,12 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
1651
1668
|
// other entry is named in them.
|
|
1652
1669
|
const label = entryKey === APP_ENTRY_KEY ? 'app' : `${entryKey} app`;
|
|
1653
1670
|
const blockedKey = (rest) => entryKey === APP_ENTRY_KEY ? `coolify:app:${rest}` : `coolify:app:${entryKey}:${rest}`;
|
|
1654
|
-
const {
|
|
1655
|
-
? resolveAppEnv(org, entry, recordedIds
|
|
1656
|
-
|
|
1671
|
+
const { marker: wantedEnv, unresolved, unresolvedSecrets, } = entry
|
|
1672
|
+
? resolveAppEnv(org, entry, recordedIds, {
|
|
1673
|
+
platformMailKey: readiness.mailKeyHeld === true,
|
|
1674
|
+
sharedSecret: readiness.canHoldSecrets === true,
|
|
1675
|
+
})
|
|
1676
|
+
: { marker: {}, unresolved: [], unresolvedSecrets: [] };
|
|
1657
1677
|
const stepKey = appStepKey(org, entryKey);
|
|
1658
1678
|
const blocked = entry
|
|
1659
1679
|
? unresolved
|
|
@@ -1663,6 +1683,14 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
1663
1683
|
description: `${org.slug}'s ${label} env ${name} is blocked, not planned: its source ${source} has no recorded value yet`,
|
|
1664
1684
|
key: blockedKey(`env:${name}`),
|
|
1665
1685
|
}))
|
|
1686
|
+
.concat(
|
|
1687
|
+
// Never a value: the ref of the missing key, or what the host lacks.
|
|
1688
|
+
unresolvedSecrets.map(([name, source]) => ({
|
|
1689
|
+
description: `${org.slug}'s ${label} env ${name} is blocked, not planned: ${source === 'platformMailKey'
|
|
1690
|
+
? `the platform's Lettermint key is not held yet (held-key ref ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF})`
|
|
1691
|
+
: `its source ${source} needs a held-key store that can put, and the host has none`}`,
|
|
1692
|
+
key: blockedKey(`env:${name}`),
|
|
1693
|
+
})))
|
|
1666
1694
|
: [];
|
|
1667
1695
|
const toggled = planHostingToggle('coolify', 'app', entry !== undefined, org, byKey, claimed, () => `provision ${org.slug}'s ${label}`, () => `park ${org.slug}'s ${label} (stops serving; app data is retained, not deleted -- #7 corrections 1)`, stepKey);
|
|
1668
1696
|
if (toggled)
|
|
@@ -1825,7 +1853,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
1825
1853
|
* {@link planApp} once per hosting entry. A key with no declared entry still
|
|
1826
1854
|
* runs for `app`, so an org that turns hosting off parks the app it has live.
|
|
1827
1855
|
*/
|
|
1828
|
-
function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer) {
|
|
1856
|
+
function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, readiness) {
|
|
1829
1857
|
const steps = [];
|
|
1830
1858
|
const blocked = [];
|
|
1831
1859
|
// An entry removed from the list while its app is recorded is parked like a
|
|
@@ -1846,8 +1874,21 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer) {
|
|
|
1846
1874
|
if (!resource || (typeof status === 'string' && /^(exited|stopped)/.test(status)))
|
|
1847
1875
|
continue;
|
|
1848
1876
|
}
|
|
1849
|
-
const app = planApp(org, key, byKey, claimed, recordedIds, plannedSteps, authIssuer);
|
|
1877
|
+
const app = planApp(org, key, byKey, claimed, recordedIds, plannedSteps, authIssuer, readiness);
|
|
1850
1878
|
// The executor pushes the issuer the plan read, the way Agora gets its host values.
|
|
1879
|
+
// The same availability the plan's marker used: the executor records its
|
|
1880
|
+
// marker from this, never from its own port, so the two cannot disagree.
|
|
1881
|
+
const usesSecrets = org.hosting.entries.some((e) => e.key === key &&
|
|
1882
|
+
Object.values(e.env).some((v) => typeof v !== 'string' && (v.from === 'sharedSecret' || v.from === 'platformMailKey')));
|
|
1883
|
+
if (usesSecrets && app.step && app.step.action.desired.enabled !== false) {
|
|
1884
|
+
app.step.action.desired = {
|
|
1885
|
+
...app.step.action.desired,
|
|
1886
|
+
secretSources: {
|
|
1887
|
+
platformMailKey: readiness.mailKeyHeld === true,
|
|
1888
|
+
sharedSecret: readiness.canHoldSecrets === true,
|
|
1889
|
+
},
|
|
1890
|
+
};
|
|
1891
|
+
}
|
|
1851
1892
|
if (app.step && app.step.action.desired.enabled !== false) {
|
|
1852
1893
|
const signsIn = org.hosting.entries.some((e) => e.key === key && e.uses?.auth);
|
|
1853
1894
|
if (signsIn)
|
|
@@ -56,8 +56,10 @@ export interface EntryFilesWant {
|
|
|
56
56
|
permissions: string[];
|
|
57
57
|
collections: string[];
|
|
58
58
|
filesUrl: string;
|
|
59
|
+
/** `https://share.<domain>`, set only when the entry declares `shareHost`. */
|
|
60
|
+
shareOrigin?: string;
|
|
59
61
|
}
|
|
60
|
-
export declare function wantedEntryFiles(entry: HostingEntry, filesUrl: string): EntryFilesWant;
|
|
62
|
+
export declare function wantedEntryFiles(entry: HostingEntry, filesUrl: string, shareHostname?: string | null): EntryFilesWant;
|
|
61
63
|
export type EntryFilesMode = 'mint' | 'renew' | 'settings' | 'finish' | 'retire';
|
|
62
64
|
type Blocked = Array<{
|
|
63
65
|
description: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FILES_ENTRY_PERMISSIONS } from '../org.js';
|
|
2
|
+
import { filesShareHostname } from './hostnames.js';
|
|
2
3
|
import { appStepKey } from './platform-diff.js';
|
|
3
4
|
/**
|
|
4
5
|
* F6b: the org's own files key, minted below the files organisation's
|
|
@@ -67,12 +68,13 @@ export function filesOwnerUserId(org, recordedIds) {
|
|
|
67
68
|
const first = org.owners[0];
|
|
68
69
|
return first ? recordedIds[`zitadel:owner:${org.id}:${first.email.toLowerCase()}`] : undefined;
|
|
69
70
|
}
|
|
70
|
-
export function wantedEntryFiles(entry, filesUrl) {
|
|
71
|
+
export function wantedEntryFiles(entry, filesUrl, shareHostname = null) {
|
|
71
72
|
const files = entry.uses?.files;
|
|
72
73
|
return {
|
|
73
74
|
permissions: [...(files?.permissions ?? FILES_ENTRY_PERMISSIONS)],
|
|
74
75
|
collections: [...(files?.collections ?? [])],
|
|
75
76
|
filesUrl,
|
|
77
|
+
...(files?.shareHost && shareHostname ? { shareOrigin: `https://${shareHostname}` } : {}),
|
|
76
78
|
};
|
|
77
79
|
}
|
|
78
80
|
/**
|
|
@@ -157,14 +159,9 @@ export function planFilesKeys(org, recordedIds, plannedSteps, readiness, now) {
|
|
|
157
159
|
}
|
|
158
160
|
if (entries.length === 0 || !readiness.filesReady)
|
|
159
161
|
return { steps, blocked };
|
|
160
|
-
for
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
description: `${org.slug}'s ${entry.key} share host is blocked: the platform's files host has no route for share.<domain> yet, so FILES_SHARE_ORIGIN is not delivered`,
|
|
164
|
-
key: `files:share:${org.id}:${entry.key}`,
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
}
|
|
162
|
+
// The route and DNS for share.<domain> are planned with the tunnel; the org
|
|
163
|
+
// apply then gives the platform files runner the host (`FoundryPlanApply.apply`).
|
|
164
|
+
const shareHostname = filesShareHostname(org);
|
|
168
165
|
// The org's own key: minted, or renewed when it is short or its expiry is unknown.
|
|
169
166
|
const rootBlock = (why) => blocked.push({
|
|
170
167
|
description: `${org.slug}'s own files key is blocked: ${why}`,
|
|
@@ -244,7 +241,7 @@ export function planFilesKeys(org, recordedIds, plannedSteps, readiness, now) {
|
|
|
244
241
|
block("the entry's app is not recorded yet");
|
|
245
242
|
continue;
|
|
246
243
|
}
|
|
247
|
-
const want = wantedEntryFiles(entry, readiness.filesUrl);
|
|
244
|
+
const want = wantedEntryFiles(entry, readiness.filesUrl, shareHostname);
|
|
248
245
|
const applied = recordedIds[entryFilesAppliedScopeKey(entry.key)];
|
|
249
246
|
const wantJson = JSON.stringify(want);
|
|
250
247
|
let mode = null;
|