@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
|
@@ -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.d.ts
CHANGED
|
@@ -26,6 +26,15 @@ export interface PlannedStep {
|
|
|
26
26
|
step: PlanStep;
|
|
27
27
|
action: StepAction;
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* A {@link PlannedStep} before `system` is set. Planners build these; the one
|
|
31
|
+
* place a plan is returned (`withStepSystem`, `step-system.ts`) sets `system`
|
|
32
|
+
* from the action, so no planner names it by hand.
|
|
33
|
+
*/
|
|
34
|
+
export interface DraftStep {
|
|
35
|
+
step: Omit<PlanStep, 'system'>;
|
|
36
|
+
action: StepAction;
|
|
37
|
+
}
|
|
29
38
|
/**
|
|
30
39
|
* Facts about the host's executor ports that an org's plan needs, so a step
|
|
31
40
|
* whose port is not wired is reported blocked instead of planned (rule 2).
|
|
@@ -70,9 +79,8 @@ export declare function toPlan(detail: PlanDetail): Plan;
|
|
|
70
79
|
* `hosting.issuerApps[]` entry rather than a single singleton, since an org
|
|
71
80
|
* can declare more than one ZITADEL app. #80 adds two more singletons,
|
|
72
81
|
* modeled on {@link planIssuer}: `planFilesOrganisation` and
|
|
73
|
-
* `planAiOrganisation`,
|
|
74
|
-
*
|
|
75
|
-
* limit to land, and AI use is switched on, before either limit is ever set.
|
|
82
|
+
* `planAiOrganisation`, the second planned before {@link planAiBudget} so AI
|
|
83
|
+
* use is switched on before its own limit is ever set.
|
|
76
84
|
* `planOwners` below adds one more singleton, in a different shape from the
|
|
77
85
|
* rest: the org's owner, invited by ZITADEL email, idempotent on the
|
|
78
86
|
* *recorded* resource id (`recordedIds`, sourced from
|
package/dist/plan/diff.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
2
2
|
import { APP_ENTRY_KEY, ownerMailboxRecorded, ownerMailboxes } from '../org.js';
|
|
3
|
-
import { CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
|
|
3
|
+
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
|
|
4
4
|
import { BOULE_BROKER_ENTRY_KEY, bouleEnvDrift, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
5
5
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
|
|
6
6
|
import { planEntryAiKeys } from './entry-ai-key.js';
|
|
@@ -10,7 +10,9 @@ 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';
|
|
15
|
+
import { withStepSystem } from './step-system.js';
|
|
14
16
|
/** The `Plan` (#19's public contract) a `PlanDetail` carries. */
|
|
15
17
|
export function toPlan(detail) {
|
|
16
18
|
return {
|
|
@@ -41,9 +43,8 @@ function resourceKey(system, kind, key) {
|
|
|
41
43
|
* `hosting.issuerApps[]` entry rather than a single singleton, since an org
|
|
42
44
|
* can declare more than one ZITADEL app. #80 adds two more singletons,
|
|
43
45
|
* modeled on {@link planIssuer}: `planFilesOrganisation` and
|
|
44
|
-
* `planAiOrganisation`,
|
|
45
|
-
*
|
|
46
|
-
* limit to land, and AI use is switched on, before either limit is ever set.
|
|
46
|
+
* `planAiOrganisation`, the second planned before {@link planAiBudget} so AI
|
|
47
|
+
* use is switched on before its own limit is ever set.
|
|
47
48
|
* `planOwners` below adds one more singleton, in a different shape from the
|
|
48
49
|
* rest: the org's owner, invited by ZITADEL email, idempotent on the
|
|
49
50
|
* *recorded* resource id (`recordedIds`, sourced from
|
|
@@ -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.
|
|
@@ -141,9 +142,6 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
141
142
|
const filesOrganisation = planFilesOrganisation(org, byKey, claimed, platformReadiness.filesReady, platformReadiness.missingKeys.files, platformReadiness.noKeyReader);
|
|
142
143
|
if (filesOrganisation.step)
|
|
143
144
|
plannedSteps.push(filesOrganisation.step);
|
|
144
|
-
const storage = planStorage(org, byKey, claimed, platformReadiness.filesReady, platformReadiness.missingKeys.files, platformReadiness.noKeyReader);
|
|
145
|
-
if (storage.step)
|
|
146
|
-
plannedSteps.push(storage.step);
|
|
147
145
|
const aiOrganisation = planAiOrganisation(org, byKey, claimed, platformReadiness.aiReady, platformReadiness.missingKeys.ai, platformReadiness.noKeyReader);
|
|
148
146
|
if (aiOrganisation.step)
|
|
149
147
|
plannedSteps.push(aiOrganisation.step);
|
|
@@ -188,7 +186,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
188
186
|
if (database)
|
|
189
187
|
plannedSteps.push(database);
|
|
190
188
|
const authIssuer = 'error' in agoraHost ? null : agoraHost.issuer;
|
|
191
|
-
const app = planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer);
|
|
189
|
+
const app = planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, platformReadiness);
|
|
192
190
|
plannedSteps.push(...app.steps);
|
|
193
191
|
// After the apps: the broker entry's key is minted into each app's env.
|
|
194
192
|
const brokerEntries = planBrokerEntries(org, recordedIds, plannedSteps, options.broker ?? false);
|
|
@@ -312,8 +310,6 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
312
310
|
if (aiOrganisation.blocked) {
|
|
313
311
|
unmanaged.push({ description: aiOrganisation.blocked, key: 'ai:organisation' });
|
|
314
312
|
}
|
|
315
|
-
if (storage.blocked)
|
|
316
|
-
unmanaged.push({ description: storage.blocked, key: 'files:storage' });
|
|
317
313
|
if (aiBudget.blocked)
|
|
318
314
|
unmanaged.push({ description: aiBudget.blocked, key: 'ai:budget' });
|
|
319
315
|
// #238: a pending zone, reported here with its nameservers rather than
|
|
@@ -325,7 +321,12 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
325
321
|
unmanaged.push(...app.blocked);
|
|
326
322
|
unmanaged.push(...brokerEntries.blocked);
|
|
327
323
|
unmanaged.push(...bouleBroker.blocked);
|
|
328
|
-
return {
|
|
324
|
+
return {
|
|
325
|
+
orgId: org.id,
|
|
326
|
+
plannedSteps: plannedSteps.map(withStepSystem),
|
|
327
|
+
unmanaged,
|
|
328
|
+
versionChanges,
|
|
329
|
+
};
|
|
329
330
|
}
|
|
330
331
|
/**
|
|
331
332
|
* The org's own ZITADEL org (issuer), a per-org singleton like every other
|
|
@@ -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: {
|
|
@@ -987,11 +990,8 @@ function notReadyReason(service, keyName, missingKeyRef, noKeyReader) {
|
|
|
987
990
|
: `the platform's ${service} host is off or not yet applied`;
|
|
988
991
|
}
|
|
989
992
|
/**
|
|
990
|
-
* #80: every org's own organisation on Files, a per-org singleton resource
|
|
991
|
-
*
|
|
992
|
-
* org Files has never provisioned (`files/packages/sdk/src/client.ts`'s
|
|
993
|
-
* tenant routes need the row `provisionOrganisation` creates). Modeled on
|
|
994
|
-
* {@link planIssuer}: nothing to diff once it exists -- Files' own
|
|
993
|
+
* #80: every org's own organisation on Files, a per-org singleton resource.
|
|
994
|
+
* Modeled on {@link planIssuer}: nothing to diff once it exists -- Files' own
|
|
995
995
|
* `provisionOrganisation` is safely repeatable (`executor.ts`'s
|
|
996
996
|
* `filesOrganisation` calls it every time regardless), so the only planned
|
|
997
997
|
* state is "not provisioned yet". The operator-driven `ceilingBytes`/
|
|
@@ -1004,6 +1004,16 @@ function notReadyReason(service, keyName, missingKeyRef, noKeyReader) {
|
|
|
1004
1004
|
* own files host (#171) is off or not yet applied, so there is nothing here
|
|
1005
1005
|
* for `FilesOrganisationPort.provisionOrganisation` to reach.
|
|
1006
1006
|
*
|
|
1007
|
+
* F6b: `shareHost` is `share.<domain>` when any entry declares
|
|
1008
|
+
* `uses.files.shareHost`, else `null`. It is passed to the provision call
|
|
1009
|
+
* every time, so files always knows the name an org wants. The route and
|
|
1010
|
+
* DNS come from `tunnelIngressHostnames`; `planFilesKeys` warns that the
|
|
1011
|
+
* platform files runner must serve the host too. Computed once, at the only point `provisionOrganisation`
|
|
1012
|
+
* is ever planned (nothing to diff once the organisation is live, same as
|
|
1013
|
+
* `ceilingBytes`/`ceilingMicros` above): a `shareHost` declared after the
|
|
1014
|
+
* organisation already exists needs its own follow-up, matching every other
|
|
1015
|
+
* field on this singleton step.
|
|
1016
|
+
*
|
|
1007
1017
|
* #273: `desired.agoraOrigin` is every org's own Agora origin (#164's
|
|
1008
1018
|
* `agoraHostnameFor`, always on, never blocked on anything this step
|
|
1009
1019
|
* doesn't already wait for) -- `executor.ts`'s `filesOrganisation` adds it
|
|
@@ -1026,6 +1036,9 @@ function planFilesOrganisation(org, byKey, claimed, filesReady, missingKeyRef, n
|
|
|
1026
1036
|
blocked: `${org.slug}'s Files organisation is blocked, not planned: ${notReadyReason('files', 'files management key', missingKeyRef, noKeyReader)}`,
|
|
1027
1037
|
};
|
|
1028
1038
|
}
|
|
1039
|
+
const shareHost = org.domain && org.hosting.entries.some((e) => e.uses?.files?.shareHost)
|
|
1040
|
+
? `share.${org.domain}`
|
|
1041
|
+
: null;
|
|
1029
1042
|
return {
|
|
1030
1043
|
step: {
|
|
1031
1044
|
action: {
|
|
@@ -1036,6 +1049,7 @@ function planFilesOrganisation(org, byKey, claimed, filesReady, missingKeyRef, n
|
|
|
1036
1049
|
provision: true,
|
|
1037
1050
|
ceilingBytes: null,
|
|
1038
1051
|
ceilingMicros: null,
|
|
1052
|
+
shareHost,
|
|
1039
1053
|
agoraOrigin: `https://${agoraHostnameFor(org)}`,
|
|
1040
1054
|
},
|
|
1041
1055
|
},
|
|
@@ -1100,53 +1114,9 @@ function planAiOrganisation(org, byKey, claimed, aiReady, missingKeyRef, noKeyRe
|
|
|
1100
1114
|
blocked: null,
|
|
1101
1115
|
};
|
|
1102
1116
|
}
|
|
1103
|
-
/**
|
|
1104
|
-
* #27: the org's own spend limit on Files, a per-org singleton resource in
|
|
1105
|
-
* the same shape as issuer/mail/chat above. Setting a limit is a config
|
|
1106
|
-
* write, never destructive or migrating (#27 "Notes": "safe, idempotent and
|
|
1107
|
-
* reversible"). A missing live resource reads as uncapped (`null`), the same
|
|
1108
|
-
* value a freshly-declared org's `storage` starts at, so a brand new org with
|
|
1109
|
-
* no limits declared produces no step.
|
|
1110
|
-
*
|
|
1111
|
-
* #177: silently plans nothing while there is no organisation to size a limit
|
|
1112
|
-
* on yet and `filesReady` is false -- {@link planFilesOrganisation} already
|
|
1113
|
-
* reports the platform's files host as missing once.
|
|
1114
|
-
*/
|
|
1115
|
-
function planStorage(org, byKey, claimed, filesReady, missingKeyRef, noKeyReader = false) {
|
|
1116
|
-
const key = resourceKey('files', 'storage', org.id);
|
|
1117
|
-
const live = byKey.get(key);
|
|
1118
|
-
if (live)
|
|
1119
|
-
claimed.add(key);
|
|
1120
|
-
if (!filesReady && !live)
|
|
1121
|
-
return { step: null, blocked: null };
|
|
1122
|
-
const desired = { limitBytes: org.storage.limitBytes, limitMicros: org.storage.limitMicros };
|
|
1123
|
-
const liveLimitBytes = live ? (live.config.limitBytes ?? null) : null;
|
|
1124
|
-
const liveLimitMicros = live ? (live.config.limitMicros ?? null) : null;
|
|
1125
|
-
if (liveLimitBytes === desired.limitBytes && liveLimitMicros === desired.limitMicros) {
|
|
1126
|
-
return { step: null, blocked: null };
|
|
1127
|
-
}
|
|
1128
|
-
// A live resource with drift needs the platform's key to change; blocked, not planned to throw at apply.
|
|
1129
|
-
if (!filesReady) {
|
|
1130
|
-
return {
|
|
1131
|
-
step: null,
|
|
1132
|
-
blocked: `${org.slug}'s storage limit change is blocked, not planned: ${notReadyReason('files', 'files management key', missingKeyRef, noKeyReader)}`,
|
|
1133
|
-
};
|
|
1134
|
-
}
|
|
1135
|
-
return {
|
|
1136
|
-
blocked: null,
|
|
1137
|
-
step: {
|
|
1138
|
-
action: { system: 'files', kind: 'storage', key: org.id, desired },
|
|
1139
|
-
step: {
|
|
1140
|
-
description: `set ${org.slug}'s storage limit to ${desired.limitBytes ?? 'uncapped'} bytes / ${desired.limitMicros ?? 'uncapped'} micros`,
|
|
1141
|
-
destroys: false,
|
|
1142
|
-
migrates: false,
|
|
1143
|
-
},
|
|
1144
|
-
},
|
|
1145
|
-
};
|
|
1146
|
-
}
|
|
1147
1117
|
/**
|
|
1148
1118
|
* #27: the org's own spend limit on AI, a per-org singleton resource in the
|
|
1149
|
-
* same shape as
|
|
1119
|
+
* same singleton shape as issuer/mail/chat above. Planned after {@link planAiOrganisation}
|
|
1150
1120
|
* (#80), which already seeds a brand new org's first budget row from this
|
|
1151
1121
|
* same `org.ai.limitMicros` -- this planner only fires again once that row's
|
|
1152
1122
|
* live value has drifted from what is declared.
|
|
@@ -1452,13 +1422,25 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
|
|
|
1452
1422
|
if (liveEnv.AGORA_CONFIG === undefined && !drift.includes('AGORA_CONFIG')) {
|
|
1453
1423
|
drift.push('AGORA_CONFIG');
|
|
1454
1424
|
}
|
|
1455
|
-
|
|
1425
|
+
// #442: `drift` alone, once this step has ever run, is comparing `env`
|
|
1426
|
+
// against Coolify's own env store -- which the executor's `bulkUpsertEnvs`
|
|
1427
|
+
// leaves matching `env` the moment the write succeeds, restarted or not.
|
|
1428
|
+
// So once applied, drift reads empty forever even if the running process
|
|
1429
|
+
// was never restarted onto it (a crash between the write and the restart,
|
|
1430
|
+
// or simply never restarting at all). `AGORA_ACTIVATED_ENV_SCOPE_KEY` is
|
|
1431
|
+
// the executor's own record that a restart confirmed the process is
|
|
1432
|
+
// actually serving this exact `env`; while it disagrees, the step stays
|
|
1433
|
+
// planned no matter what Coolify's env store already shows.
|
|
1434
|
+
const activated = recordedIds[AGORA_ACTIVATED_ENV_SCOPE_KEY] === agoraEnvRevision(env);
|
|
1435
|
+
if (drift.length === 0 && activated)
|
|
1456
1436
|
return { step: null, blocked: null };
|
|
1457
1437
|
return {
|
|
1458
1438
|
step: {
|
|
1459
1439
|
action,
|
|
1460
1440
|
step: {
|
|
1461
|
-
description:
|
|
1441
|
+
description: drift.length > 0
|
|
1442
|
+
? `update ${org.slug}'s Agora config (${drift.join(', ')} missing or different)`
|
|
1443
|
+
: `activate ${org.slug}'s Agora config -- written but not yet confirmed restarted`,
|
|
1462
1444
|
destroys: false,
|
|
1463
1445
|
migrates: false,
|
|
1464
1446
|
},
|
|
@@ -1636,7 +1618,7 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
|
|
|
1636
1618
|
* resource's `enabled` field -- a live resource nothing has actually
|
|
1637
1619
|
* checked yet is assumed fine rather than replanned every time.
|
|
1638
1620
|
*/
|
|
1639
|
-
function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authIssuer) {
|
|
1621
|
+
function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authIssuer, readiness) {
|
|
1640
1622
|
// #243: an `appEnv` source with no recorded value and no step planned to
|
|
1641
1623
|
// record one this run has nothing to push, now or after this apply.
|
|
1642
1624
|
const sourceStep = {
|
|
@@ -1651,9 +1633,12 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
1651
1633
|
// other entry is named in them.
|
|
1652
1634
|
const label = entryKey === APP_ENTRY_KEY ? 'app' : `${entryKey} app`;
|
|
1653
1635
|
const blockedKey = (rest) => entryKey === APP_ENTRY_KEY ? `coolify:app:${rest}` : `coolify:app:${entryKey}:${rest}`;
|
|
1654
|
-
const {
|
|
1655
|
-
? resolveAppEnv(org, entry, recordedIds
|
|
1656
|
-
|
|
1636
|
+
const { marker: wantedEnv, unresolved, unresolvedSecrets, } = entry
|
|
1637
|
+
? resolveAppEnv(org, entry, recordedIds, {
|
|
1638
|
+
platformMailKey: readiness.mailKeyHeld === true,
|
|
1639
|
+
sharedSecret: readiness.canHoldSecrets === true,
|
|
1640
|
+
})
|
|
1641
|
+
: { marker: {}, unresolved: [], unresolvedSecrets: [] };
|
|
1657
1642
|
const stepKey = appStepKey(org, entryKey);
|
|
1658
1643
|
const blocked = entry
|
|
1659
1644
|
? unresolved
|
|
@@ -1663,6 +1648,14 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
1663
1648
|
description: `${org.slug}'s ${label} env ${name} is blocked, not planned: its source ${source} has no recorded value yet`,
|
|
1664
1649
|
key: blockedKey(`env:${name}`),
|
|
1665
1650
|
}))
|
|
1651
|
+
.concat(
|
|
1652
|
+
// Never a value: the ref of the missing key, or what the host lacks.
|
|
1653
|
+
unresolvedSecrets.map(([name, source]) => ({
|
|
1654
|
+
description: `${org.slug}'s ${label} env ${name} is blocked, not planned: ${source === 'platformMailKey'
|
|
1655
|
+
? `the platform's Lettermint key is not held yet (held-key ref ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF})`
|
|
1656
|
+
: `its source ${source} needs a held-key store that can put, and the host has none`}`,
|
|
1657
|
+
key: blockedKey(`env:${name}`),
|
|
1658
|
+
})))
|
|
1666
1659
|
: [];
|
|
1667
1660
|
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
1661
|
if (toggled)
|
|
@@ -1825,7 +1818,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
1825
1818
|
* {@link planApp} once per hosting entry. A key with no declared entry still
|
|
1826
1819
|
* runs for `app`, so an org that turns hosting off parks the app it has live.
|
|
1827
1820
|
*/
|
|
1828
|
-
function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer) {
|
|
1821
|
+
function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, readiness) {
|
|
1829
1822
|
const steps = [];
|
|
1830
1823
|
const blocked = [];
|
|
1831
1824
|
// An entry removed from the list while its app is recorded is parked like a
|
|
@@ -1846,8 +1839,21 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer) {
|
|
|
1846
1839
|
if (!resource || (typeof status === 'string' && /^(exited|stopped)/.test(status)))
|
|
1847
1840
|
continue;
|
|
1848
1841
|
}
|
|
1849
|
-
const app = planApp(org, key, byKey, claimed, recordedIds, plannedSteps, authIssuer);
|
|
1842
|
+
const app = planApp(org, key, byKey, claimed, recordedIds, plannedSteps, authIssuer, readiness);
|
|
1850
1843
|
// The executor pushes the issuer the plan read, the way Agora gets its host values.
|
|
1844
|
+
// The same availability the plan's marker used: the executor records its
|
|
1845
|
+
// marker from this, never from its own port, so the two cannot disagree.
|
|
1846
|
+
const usesSecrets = org.hosting.entries.some((e) => e.key === key &&
|
|
1847
|
+
Object.values(e.env).some((v) => typeof v !== 'string' && (v.from === 'sharedSecret' || v.from === 'platformMailKey')));
|
|
1848
|
+
if (usesSecrets && app.step && app.step.action.desired.enabled !== false) {
|
|
1849
|
+
app.step.action.desired = {
|
|
1850
|
+
...app.step.action.desired,
|
|
1851
|
+
secretSources: {
|
|
1852
|
+
platformMailKey: readiness.mailKeyHeld === true,
|
|
1853
|
+
sharedSecret: readiness.canHoldSecrets === true,
|
|
1854
|
+
},
|
|
1855
|
+
};
|
|
1856
|
+
}
|
|
1851
1857
|
if (app.step && app.step.action.desired.enabled !== false) {
|
|
1852
1858
|
const signsIn = org.hosting.entries.some((e) => e.key === key && e.uses?.auth);
|
|
1853
1859
|
if (signsIn)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type DeclaredOrg, type HostingEntry } from '../org.js';
|
|
2
|
-
import type {
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
3
|
import type { OrgPlatformReadiness } from './platform-org.js';
|
|
4
4
|
/**
|
|
5
5
|
* F6a: one ai key per entry with `uses.ai`, minted below the org's own ai key
|
|
@@ -49,8 +49,8 @@ export type EntryAiMode = 'mint' | 'renew' | 'settings' | 'finish' | 'retire';
|
|
|
49
49
|
* entry's app or the ai address is not recorded, or after a mint whose
|
|
50
50
|
* delivery failed: a second mint would leave two live keys.
|
|
51
51
|
*/
|
|
52
|
-
export declare function planEntryAiKeys(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly
|
|
53
|
-
steps:
|
|
52
|
+
export declare function planEntryAiKeys(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], readiness: OrgPlatformReadiness, now: number): {
|
|
53
|
+
steps: DraftStep[];
|
|
54
54
|
blocked: Array<{
|
|
55
55
|
description: string;
|
|
56
56
|
key: string;
|