@caelo-cms/provisioning 0.10.53 → 0.10.57
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/agent-db.js +272 -128
- package/dist/audit-job-setup.d.ts +69 -0
- package/dist/audit-job-setup.d.ts.map +1 -0
- package/dist/audit-job-setup.js +317 -0
- package/dist/audit-job-setup.js.map +1 -0
- package/dist/caddy.d.ts +9 -0
- package/dist/caddy.d.ts.map +1 -1
- package/dist/caddy.js +38 -0
- package/dist/caddy.js.map +1 -1
- package/dist/cli-routing.js +1 -1
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +36 -9
- package/dist/cli.js.map +1 -1
- package/dist/compose.js +5 -0
- package/dist/compose.js.map +1 -1
- package/dist/converge-checkpoint.d.ts +66 -0
- package/dist/converge-checkpoint.d.ts.map +1 -0
- package/dist/converge-checkpoint.js +61 -0
- package/dist/converge-checkpoint.js.map +1 -0
- package/dist/converge-job-gcp.d.ts +46 -0
- package/dist/converge-job-gcp.d.ts.map +1 -0
- package/dist/converge-job-gcp.js +368 -0
- package/dist/converge-job-gcp.js.map +1 -0
- package/dist/converge-job-setup.d.ts +62 -0
- package/dist/converge-job-setup.d.ts.map +1 -0
- package/dist/converge-job-setup.js +228 -0
- package/dist/converge-job-setup.js.map +1 -0
- package/dist/converge-job.d.ts +141 -0
- package/dist/converge-job.d.ts.map +1 -0
- package/dist/converge-job.js +325 -0
- package/dist/converge-job.js.map +1 -0
- package/dist/gcp-names.d.ts +25 -1
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +36 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.d.ts.map +1 -1
- package/dist/infra-contract.js +28 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job.d.ts +11 -3
- package/dist/install-job.d.ts.map +1 -1
- package/dist/install-job.js +22 -12
- package/dist/install-job.js.map +1 -1
- package/dist/lifecycle.d.ts +13 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +115 -58
- package/dist/lifecycle.js.map +1 -1
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +13 -3
- package/dist/operator-access.js.map +1 -1
- package/dist/private-file.d.ts +26 -0
- package/dist/private-file.d.ts.map +1 -0
- package/dist/private-file.js +49 -0
- package/dist/private-file.js.map +1 -0
- package/dist/release-contract.d.ts +3 -2
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +8 -2
- package/dist/release-contract.js.map +1 -1
- package/dist/release-manifest.d.ts +2 -0
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js.map +1 -1
- package/dist/remote-mcp-edge.d.ts +114 -0
- package/dist/remote-mcp-edge.d.ts.map +1 -0
- package/dist/remote-mcp-edge.js +116 -0
- package/dist/remote-mcp-edge.js.map +1 -0
- package/dist/remote-mcp.d.ts +117 -0
- package/dist/remote-mcp.d.ts.map +1 -0
- package/dist/remote-mcp.js +379 -0
- package/dist/remote-mcp.js.map +1 -0
- package/dist/runtime-secrets.d.ts +12 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +32 -4
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/seal-key-rotation.d.ts +38 -0
- package/dist/seal-key-rotation.d.ts.map +1 -0
- package/dist/seal-key-rotation.js +206 -0
- package/dist/seal-key-rotation.js.map +1 -0
- package/dist/stack-contract.d.ts +175 -3
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +183 -3
- package/dist/stack-contract.js.map +1 -1
- package/dist/stack-converge.d.ts +38 -1
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +74 -4
- package/dist/stack-converge.js.map +1 -1
- package/dist/state-lock-lease.d.ts +142 -0
- package/dist/state-lock-lease.d.ts.map +1 -0
- package/dist/state-lock-lease.js +347 -0
- package/dist/state-lock-lease.js.map +1 -0
- package/dist/state-store.d.ts +66 -9
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js +249 -30
- package/dist/state-store.js.map +1 -1
- package/dist/tar-entries.d.ts.map +1 -1
- package/dist/tar-entries.js +84 -9
- package/dist/tar-entries.js.map +1 -1
- package/dist/upgrade/app-roll.d.ts +36 -7
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +88 -19
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/audit-job-roll.d.ts +38 -0
- package/dist/upgrade/audit-job-roll.d.ts.map +1 -0
- package/dist/upgrade/audit-job-roll.js +43 -0
- package/dist/upgrade/audit-job-roll.js.map +1 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +141 -8
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/gcp-lookup.d.ts +27 -10
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +46 -7
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/record-rolled.d.ts +40 -0
- package/dist/upgrade/record-rolled.d.ts.map +1 -0
- package/dist/upgrade/record-rolled.js +46 -0
- package/dist/upgrade/record-rolled.js.map +1 -0
- package/dist/upgrade/upgrade.d.ts +45 -1
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +60 -17
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-assess.d.ts +15 -1
- package/dist/upgrader-assess.d.ts.map +1 -1
- package/dist/upgrader-assess.js +36 -6
- package/dist/upgrader-assess.js.map +1 -1
- package/dist/upgrader-gcp.d.ts +15 -0
- package/dist/upgrader-gcp.d.ts.map +1 -1
- package/dist/upgrader-gcp.js +42 -7
- package/dist/upgrader-gcp.js.map +1 -1
- package/dist/upgrader-iam-preflight.d.ts +123 -0
- package/dist/upgrader-iam-preflight.d.ts.map +1 -0
- package/dist/upgrader-iam-preflight.js +414 -0
- package/dist/upgrader-iam-preflight.js.map +1 -0
- package/dist/upgrader-job.d.ts +33 -9
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.js +169 -27
- package/dist/upgrader-job.js.map +1 -1
- package/dist/upgrader-records.d.ts +58 -0
- package/dist/upgrader-records.d.ts.map +1 -0
- package/dist/upgrader-records.js +87 -0
- package/dist/upgrader-records.js.map +1 -0
- package/dist/upgrader-reporter.d.ts +3 -2
- package/dist/upgrader-reporter.d.ts.map +1 -1
- package/dist/upgrader-reporter.js +10 -2
- package/dist/upgrader-reporter.js.map +1 -1
- package/dist/upgrader-setup.d.ts +25 -3
- package/dist/upgrader-setup.d.ts.map +1 -1
- package/dist/upgrader-setup.js +132 -14
- package/dist/upgrader-setup.js.map +1 -1
- package/dist/wizards/gcp-cost.d.ts.map +1 -1
- package/dist/wizards/gcp-cost.js +14 -6
- package/dist/wizards/gcp-cost.js.map +1 -1
- package/dist/wizards/gcp-pulumi.d.ts +6 -0
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +7 -0
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +130 -4
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +4 -4
- package/src/agent/run.test.ts +15 -1
- package/src/audit-job-setup.test.ts +234 -0
- package/src/audit-job-setup.ts +360 -0
- package/src/caddy-redirects.docker.test.ts +19 -6
- package/src/caddy.test.ts +30 -0
- package/src/caddy.ts +43 -0
- package/src/cli-init-validation.test.ts +44 -0
- package/src/cli-routing.ts +1 -1
- package/src/cli.ts +40 -8
- package/src/compose.ts +5 -0
- package/src/converge-checkpoint.ts +75 -0
- package/src/converge-entrypoint.test.ts +213 -0
- package/src/converge-job-gcp.test.ts +222 -0
- package/src/converge-job-gcp.ts +443 -0
- package/src/converge-job-setup.test.ts +152 -0
- package/src/converge-job-setup.ts +268 -0
- package/src/converge-job.test.ts +486 -0
- package/src/converge-job.ts +542 -0
- package/src/gateway-env-isolation.test.ts +8 -1
- package/src/gcp-names.ts +43 -1
- package/src/gcp-url-map.test.ts +9 -1
- package/src/index.ts +8 -0
- package/src/infra-contract.test.ts +7 -4
- package/src/infra-contract.ts +28 -1
- package/src/install-job.test.ts +3 -1
- package/src/install-job.ts +31 -14
- package/src/lifecycle.ts +137 -71
- package/src/operator-access.test.ts +14 -0
- package/src/operator-access.ts +13 -3
- package/src/private-file.test.ts +80 -0
- package/src/private-file.ts +50 -0
- package/src/release-contract.ts +8 -2
- package/src/release-manifest.test.ts +42 -0
- package/src/release-manifest.ts +2 -0
- package/src/remote-mcp-edge.ts +137 -0
- package/src/remote-mcp.test.ts +517 -0
- package/src/remote-mcp.ts +460 -0
- package/src/runtime-secrets.test.ts +31 -6
- package/src/runtime-secrets.ts +41 -3
- package/src/seal-key-rotation.test.ts +131 -0
- package/src/seal-key-rotation.ts +258 -0
- package/src/stack-contract-parity.test.ts +20 -3
- package/src/stack-contract.test.ts +34 -2
- package/src/stack-contract.ts +305 -3
- package/src/stack-converge-secrets.test.ts +3 -0
- package/src/stack-converge.test.ts +21 -5
- package/src/stack-converge.ts +101 -7
- package/src/state-attach.test.ts +7 -1
- package/src/state-lock-lease.test.ts +302 -0
- package/src/state-lock-lease.ts +423 -0
- package/src/state-lock.test.ts +568 -0
- package/src/state-store.ts +347 -27
- package/src/tar-entries.test.ts +149 -7
- package/src/tar-entries.ts +90 -9
- package/src/upgrade/app-roll.ts +135 -21
- package/src/upgrade/audit-job-roll.ts +68 -0
- package/src/upgrade/converge.ts +170 -7
- package/src/upgrade/gcp-lookup.test.ts +80 -0
- package/src/upgrade/gcp-lookup.ts +65 -8
- package/src/upgrade/record-rolled.test.ts +105 -0
- package/src/upgrade/record-rolled.ts +91 -0
- package/src/upgrade/upgrade.test.ts +279 -5
- package/src/upgrade/upgrade.ts +120 -24
- package/src/upgrader-assess.test.ts +93 -3
- package/src/upgrader-assess.ts +58 -9
- package/src/upgrader-gcp.ts +47 -8
- package/src/upgrader-iam-preflight.test.ts +393 -0
- package/src/upgrader-iam-preflight.ts +510 -0
- package/src/upgrader-job.test.ts +587 -15
- package/src/upgrader-job.ts +237 -53
- package/src/upgrader-records.ts +151 -0
- package/src/upgrader-reporter.test.ts +6 -0
- package/src/upgrader-reporter.ts +7 -2
- package/src/upgrader-setup.test.ts +4 -3
- package/src/upgrader-setup.ts +137 -13
- package/src/wizards/gcp-cost.ts +15 -6
- package/src/wizards/gcp-pulumi.ts +7 -0
- package/src/wizards/gcp.ts +162 -4
- package/stacks/aws/index.ts +18 -0
- package/stacks/azure/index.ts +18 -0
- package/stacks/gcp/index.ts +59 -7
- package/stacks/gcp-firebase/index.ts +50 -6
package/src/upgrader-job.ts
CHANGED
|
@@ -12,18 +12,34 @@
|
|
|
12
12
|
* but cannot change IAM, service accounts or secrets. One run:
|
|
13
13
|
*
|
|
14
14
|
* 1. claim the oldest pending request (ending a run that died mid-way);
|
|
15
|
-
* 2. read what the install runs from install.json in its ops bucket;
|
|
15
|
+
* 2. read what the install runs from install.json in its ops bucket; when
|
|
16
|
+
* it names other images than the ones serving, judge against the
|
|
17
|
+
* serving release if a signed release manifest names both its images,
|
|
18
|
+
* else stop (upgrader-records.ts, #860);
|
|
16
19
|
* 3. fetch the release manifest from the FIXED registry and verify its
|
|
17
20
|
* signature (release-manifest.ts) — no registry, identity or digest
|
|
18
21
|
* ever comes from the request, the env or argv;
|
|
19
22
|
* 4. decide (upgrader-assess.ts): refuse a bad signature, a downgrade, a
|
|
20
23
|
* release below its minFromVersion, an automatic run the policy does
|
|
21
|
-
* not allow; hand an infrastructure change
|
|
24
|
+
* not allow; hand an infrastructure change back (the admin routes
|
|
25
|
+
* it to the converge job, #819; Cloud Shell on installs without one);
|
|
26
|
+
* -- ask IAM whether the job's account holds every permission the rest
|
|
27
|
+
* of the run needs (upgrader-iam-preflight.ts, #818); any missing one
|
|
28
|
+
* ends the run as needs-Cloud-Shell, all of them named, nothing changed;
|
|
29
|
+
* -- only now take the install's state lock (#815), confirm install.json
|
|
30
|
+
* is unchanged since step 2, and write step 2's correction. Everything
|
|
31
|
+
* above only reads, so no lock problem can hide the verdict; a stop
|
|
32
|
+
* verdict still corrects the records when the lock is free. Only a
|
|
33
|
+
* verdict judged on drift asks for the lock even when it stops: the
|
|
34
|
+
* serving images were read without it, and a run that holds it may be
|
|
35
|
+
* half-way through its own roll — then the answer is "another install
|
|
36
|
+
* operation is running", never a drift or Cloud Shell verdict;
|
|
22
37
|
* 5. take a Cloud SQL backup — no backup, no change;
|
|
23
38
|
* 6. app-roll (upgrade/app-roll.ts) with no converge hooks: migrations
|
|
24
39
|
* with the new admin image, admin then gateway, deep health check,
|
|
25
40
|
* traffic back onto the previous revisions on any failure;
|
|
26
|
-
* 7. record the new digests + version in install.json
|
|
41
|
+
* 7. record the new digests + version in install.json (a failed write
|
|
42
|
+
* ends the run as failed, never as succeeded);
|
|
27
43
|
* 8. move this job onto the release's own ops image, last.
|
|
28
44
|
*
|
|
29
45
|
* Every step is narrated into `platform_update_events` (upgrader-reporter.ts).
|
|
@@ -52,10 +68,26 @@ import {
|
|
|
52
68
|
type RollSlug,
|
|
53
69
|
} from "./upgrade/app-roll.js";
|
|
54
70
|
import { restoreCommand, takeSqlBackup } from "./upgrade/backup.js";
|
|
55
|
-
import {
|
|
71
|
+
import {
|
|
72
|
+
findServingImage,
|
|
73
|
+
resolveGcpResourceName,
|
|
74
|
+
servingImageDigests,
|
|
75
|
+
} from "./upgrade/gcp-lookup.js";
|
|
56
76
|
import type { HealthProbe } from "./upgrade/health.js";
|
|
57
77
|
import { releaseImageRef } from "./upgrade/release-images.js";
|
|
58
78
|
import { assessUpgradeRequest, type RefusalCode } from "./upgrader-assess.js";
|
|
79
|
+
import {
|
|
80
|
+
type IamTester,
|
|
81
|
+
preflightErrorReason,
|
|
82
|
+
preflightMissingReason,
|
|
83
|
+
runIamPreflight,
|
|
84
|
+
upgraderAccessChecks,
|
|
85
|
+
} from "./upgrader-iam-preflight.js";
|
|
86
|
+
import {
|
|
87
|
+
findServingRelease,
|
|
88
|
+
recordMatchesServing,
|
|
89
|
+
type ServingDigests,
|
|
90
|
+
} from "./upgrader-records.js";
|
|
59
91
|
import { eventReporter, type JobLog } from "./upgrader-reporter.js";
|
|
60
92
|
|
|
61
93
|
/** A request the job claimed, with the policy as it stood at claim time. */
|
|
@@ -125,15 +157,22 @@ export interface UpgraderJobDeps {
|
|
|
125
157
|
readonly env: string;
|
|
126
158
|
/** This Cloud Run execution, for the log link; null outside Cloud Run. */
|
|
127
159
|
readonly execution: string | null;
|
|
160
|
+
/**
|
|
161
|
+
* Asks IAM which permissions the job's own account holds (#818), for the
|
|
162
|
+
* self-check right after the decision. Never the action itself.
|
|
163
|
+
*/
|
|
164
|
+
readonly iam: IamTester;
|
|
128
165
|
/** fetchVerifiedReleaseManifest against the fixed registry. */
|
|
129
166
|
readonly fetchManifest: (version: string) => Promise<ReleaseManifestResult>;
|
|
130
167
|
/**
|
|
131
|
-
* Take the install's state lock (the one `pulumi up
|
|
132
|
-
* hold)
|
|
133
|
-
*
|
|
168
|
+
* Take the install's state lock (the one `pulumi up`, `state migrate` and
|
|
169
|
+
* the CLI `upgrade` hold) right before the run's first write; returns its
|
|
170
|
+
* release. Breaks a lock whose holder has ended and reports that through
|
|
171
|
+
* `onBreak` (state-store.ts). Throws StateLockHeldError when a live run
|
|
172
|
+
* holds it, any other error when it cannot be taken or judged.
|
|
134
173
|
*/
|
|
135
|
-
readonly lockState: () => () => void;
|
|
136
|
-
/** install.json
|
|
174
|
+
readonly lockState: (onBreak: (message: string) => void) => () => void;
|
|
175
|
+
/** install.json as the ops bucket holds it NOW (null when absent) — never cached. */
|
|
137
176
|
readonly readInstall: () => InstallMetadata | null;
|
|
138
177
|
readonly writeInstall: (meta: InstallMetadata) => void;
|
|
139
178
|
/** Runs both migration targets with the NEW admin image. */
|
|
@@ -157,8 +196,24 @@ export interface UpgraderRunResult {
|
|
|
157
196
|
readonly status: UpdateEndStatus | null;
|
|
158
197
|
}
|
|
159
198
|
|
|
199
|
+
/** Why the run cannot go on: how its request ends. */
|
|
200
|
+
interface Blocked {
|
|
201
|
+
readonly status: UpdateEndStatus;
|
|
202
|
+
readonly codes: readonly string[];
|
|
203
|
+
readonly reason: string;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
type LockOutcome = { readonly ok: true } | ({ readonly ok: false } & Blocked);
|
|
207
|
+
|
|
160
208
|
const ROLLED_PHASES = new Set(["roll", "health"]);
|
|
161
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Lock outcomes that mean another run is changing the install right now
|
|
212
|
+
* (#815): a verdict judged from serving images read without the lock may
|
|
213
|
+
* describe that run's half-finished roll, so it is not reported.
|
|
214
|
+
*/
|
|
215
|
+
const STALE_VERDICT: ReadonlySet<string> = new Set(["state-locked", "install-state-changed"]);
|
|
216
|
+
|
|
162
217
|
/** Attempts at recording a request's end state before the run fails. */
|
|
163
218
|
const FINISH_ATTEMPTS = 3;
|
|
164
219
|
|
|
@@ -221,43 +276,104 @@ export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRun
|
|
|
221
276
|
`Upgrader started${deps.execution ? ` (run ${deps.execution})` : ""}: installing Caelo ${request.targetVersion}.`,
|
|
222
277
|
);
|
|
223
278
|
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
)
|
|
279
|
+
// #815 — the state lock is taken only right before the first write
|
|
280
|
+
// (lockBeforeWrite below), never before the decision: any lock problem
|
|
281
|
+
// must not hide the verdict (needs Cloud Shell, refused), and a run
|
|
282
|
+
// that changes nothing has no business blocking another.
|
|
283
|
+
const lockBeforeWrite = (): LockOutcome => {
|
|
284
|
+
try {
|
|
285
|
+
releaseLock = deps.lockState((message) => r.info(message));
|
|
286
|
+
return { ok: true };
|
|
287
|
+
} catch (e) {
|
|
288
|
+
if (e instanceof StateLockHeldError) {
|
|
289
|
+
return {
|
|
290
|
+
ok: false,
|
|
291
|
+
status: "failed",
|
|
292
|
+
codes: ["state-locked"],
|
|
293
|
+
reason: `Nothing was changed: another install operation is running on this site (${errorText(e)}). Try again when it has finished.`,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
// Not contention: the job cannot reach its own ops bucket or ask
|
|
297
|
+
// Cloud Run about the holder. Retrying will not help; a Cloud Shell
|
|
298
|
+
// upgrade re-converges the job's grants.
|
|
299
|
+
return {
|
|
300
|
+
ok: false,
|
|
301
|
+
status: "needs_cloud_shell",
|
|
302
|
+
codes: ["state-access"],
|
|
303
|
+
reason: `Nothing was changed: the upgrader could not take the install's state lock (${errorText(e)}). Run the upgrade once with Cloud Shell; it repairs the upgrader's access.`,
|
|
304
|
+
};
|
|
234
305
|
}
|
|
235
|
-
|
|
236
|
-
// will not help; a Cloud Shell upgrade re-converges the job's grants.
|
|
237
|
-
return stopWith(
|
|
238
|
-
"needs_cloud_shell",
|
|
239
|
-
["state-access"],
|
|
240
|
-
`Nothing was changed: the upgrader could not take the install's state lock (${errorText(e)}). Run the upgrade once with Cloud Shell; it repairs the upgrader's access.`,
|
|
241
|
-
);
|
|
242
|
-
}
|
|
306
|
+
};
|
|
243
307
|
|
|
244
308
|
// 2. What the install runs — from its own state, never from the admin.
|
|
245
309
|
r.phase("verify");
|
|
246
|
-
const
|
|
247
|
-
if (!
|
|
310
|
+
const read = deps.readInstall();
|
|
311
|
+
if (!read || read.projectId !== deps.projectId) {
|
|
248
312
|
return stopWith(
|
|
249
313
|
"needs_cloud_shell",
|
|
250
314
|
["install-state"],
|
|
251
315
|
"The install's records (install.json) are not in its own cloud project yet, so the upgrader cannot tell what it runs. Run the upgrade once with Cloud Shell; it moves them there.",
|
|
252
316
|
);
|
|
253
317
|
}
|
|
254
|
-
if (
|
|
318
|
+
if (read.provider !== "gcp" && read.provider !== "gcp-firebase") {
|
|
255
319
|
return stopWith(
|
|
256
320
|
"refused",
|
|
257
321
|
["provider"],
|
|
258
|
-
`The upgrader job only upgrades Google Cloud installs; this one is ${
|
|
322
|
+
`The upgrader job only upgrades Google Cloud installs; this one is ${read.provider}.`,
|
|
259
323
|
);
|
|
260
324
|
}
|
|
325
|
+
// Rebound when the records are refreshed below; `provider` is narrowed.
|
|
326
|
+
let meta: InstallMetadata & { provider: "gcp" | "gcp-firebase" } = {
|
|
327
|
+
...read,
|
|
328
|
+
provider: read.provider,
|
|
329
|
+
};
|
|
330
|
+
/** The corrected records (#860), when install.json is stale; written under the lock. */
|
|
331
|
+
let heal: InstallMetadata | null = null;
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Take the lock, confirm install.json is still what was judged, and
|
|
335
|
+
* write the pending correction. Returns null when all of that held.
|
|
336
|
+
*/
|
|
337
|
+
const lockAndHeal = (): Blocked | null => {
|
|
338
|
+
const locked = lockBeforeWrite();
|
|
339
|
+
if (!locked.ok) return locked;
|
|
340
|
+
// Another writer may have changed the records between our read and
|
|
341
|
+
// the lock; everything above was judged against the old ones.
|
|
342
|
+
// A failed re-read is a Blocked like any other, never a throw: on the
|
|
343
|
+
// stop-verdict path a throw here would replace the verdict with
|
|
344
|
+
// failed/internal.
|
|
345
|
+
let now: InstallMetadata | null;
|
|
346
|
+
try {
|
|
347
|
+
now = deps.readInstall();
|
|
348
|
+
} catch (e) {
|
|
349
|
+
return {
|
|
350
|
+
status: "failed",
|
|
351
|
+
codes: ["install-state-read"],
|
|
352
|
+
reason: `Nothing was changed: the install's records (install.json) could not be read again once the lock was held (${errorText(e)}). Try again.`,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
if (JSON.stringify(now) !== JSON.stringify(read)) {
|
|
356
|
+
return {
|
|
357
|
+
status: "failed",
|
|
358
|
+
codes: ["install-state-changed"],
|
|
359
|
+
reason:
|
|
360
|
+
"Nothing was changed: another install operation changed the install's records (install.json) while this update was being checked. Try again.",
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
if (!heal) return null;
|
|
364
|
+
const v = heal.releaseVersion;
|
|
365
|
+
try {
|
|
366
|
+
deps.writeInstall(heal);
|
|
367
|
+
} catch (e) {
|
|
368
|
+
return {
|
|
369
|
+
status: "failed",
|
|
370
|
+
codes: ["install-state-write"],
|
|
371
|
+
reason: `Nothing was changed: the install's records (install.json) name another release than the one that serves (v${v}), and they could not be corrected (${errorText(e)}).`,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
r.success(`Caelo refreshed its install records: v${v} serves.`);
|
|
375
|
+
return null;
|
|
376
|
+
};
|
|
261
377
|
|
|
262
378
|
// install.json must describe what actually serves: if a previous run
|
|
263
379
|
// rolled but could not record it, the recorded version is stale and
|
|
@@ -266,17 +382,34 @@ export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRun
|
|
|
266
382
|
if (!serving.ok) {
|
|
267
383
|
return stopWith("failed", ["lookup"], `Nothing was changed: ${serving.error}.`);
|
|
268
384
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
385
|
+
if (!recordMatchesServing(meta.imageDigests, serving.digests)) {
|
|
386
|
+
// #860 — re-record the serving release ourselves, but only one whose
|
|
387
|
+
// admin AND gateway images a signed release manifest names.
|
|
388
|
+
const found = await findServingRelease(meta, serving.digests, deps);
|
|
389
|
+
if (!found.ok) {
|
|
390
|
+
if (found.retryable) return stopWith("failed", ["registry"], found.reason);
|
|
391
|
+
// #815 — the serving images were read without the lock: a run that
|
|
392
|
+
// holds it may be half-way through its roll, and that is not drift.
|
|
393
|
+
// Only once this run holds the lock (or cannot take it at all) is
|
|
394
|
+
// the mismatch the install's real state.
|
|
395
|
+
const blocked = lockAndHeal();
|
|
396
|
+
if (blocked && STALE_VERDICT.has(blocked.codes[0] ?? "")) {
|
|
397
|
+
return stopWith(blocked.status, blocked.codes, blocked.reason);
|
|
398
|
+
}
|
|
399
|
+
return stopWith("needs_cloud_shell", ["install-state-drift"], found.reason);
|
|
400
|
+
}
|
|
401
|
+
// The manifest's digests, not the serving ones: the signed form is the
|
|
402
|
+
// one every later comparison and deploy uses (gcp-lookup.ts ServingImage).
|
|
403
|
+
meta = {
|
|
404
|
+
...meta,
|
|
405
|
+
imageDigests: {
|
|
406
|
+
admin: found.manifest.digests.admin,
|
|
407
|
+
gateway: found.manifest.digests.gateway,
|
|
408
|
+
},
|
|
409
|
+
releaseVersion: found.manifest.version,
|
|
410
|
+
};
|
|
411
|
+
// Judged against now, written under the lock once decided (#815).
|
|
412
|
+
heal = meta;
|
|
280
413
|
}
|
|
281
414
|
|
|
282
415
|
// 3 + 4. The signed manifest decides.
|
|
@@ -300,6 +433,21 @@ export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRun
|
|
|
300
433
|
}
|
|
301
434
|
if (decision.kind === "stop") {
|
|
302
435
|
r.phase("assess");
|
|
436
|
+
if (heal) {
|
|
437
|
+
// The records still get corrected, but the verdict is the answer:
|
|
438
|
+
// a lock or write problem here must not replace it (#815).
|
|
439
|
+
// Except when another run holds the lock or changed the records:
|
|
440
|
+
// then the drift this verdict was judged on may be that run's
|
|
441
|
+
// half-finished work, and "try again once it has finished" is the
|
|
442
|
+
// true answer.
|
|
443
|
+
const blocked = lockAndHeal();
|
|
444
|
+
if (blocked && STALE_VERDICT.has(blocked.codes[0] ?? "")) {
|
|
445
|
+
return stopWith(blocked.status, blocked.codes, blocked.reason);
|
|
446
|
+
}
|
|
447
|
+
if (blocked) {
|
|
448
|
+
r.warn(`The install's records could not be corrected this time: ${blocked.reason}`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
303
451
|
return stopWith(decision.status, decision.codes, decision.reason);
|
|
304
452
|
}
|
|
305
453
|
r.phase("assess");
|
|
@@ -308,6 +456,25 @@ export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRun
|
|
|
308
456
|
`${meta.releaseVersion} → ${decision.manifest.version}: no cloud-setup change needed; installing.`,
|
|
309
457
|
);
|
|
310
458
|
|
|
459
|
+
// #818 — decided to run: ask IAM for every permission the rest needs
|
|
460
|
+
// before the lock, the backup or the roll. A grant that looks right but
|
|
461
|
+
// cannot match (#799) ends the run here, all of them named, not halfway.
|
|
462
|
+
const access = await runIamPreflight(
|
|
463
|
+
upgraderAccessChecks({ projectId: deps.projectId, region: deps.region, env: deps.env }),
|
|
464
|
+
deps.iam,
|
|
465
|
+
);
|
|
466
|
+
if (access.kind === "missing") {
|
|
467
|
+
return stopWith(
|
|
468
|
+
"needs_cloud_shell",
|
|
469
|
+
["iam-preflight"],
|
|
470
|
+
preflightMissingReason(access.missing),
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
if (access.kind === "error") {
|
|
474
|
+
return stopWith("failed", ["iam-preflight"], preflightErrorReason(access.errors));
|
|
475
|
+
}
|
|
476
|
+
r.info("The upgrader has every permission this update needs.");
|
|
477
|
+
|
|
311
478
|
const m = decision.manifest;
|
|
312
479
|
const images: Record<RollSlug, ReleaseImage> = {
|
|
313
480
|
admin: { digest: m.digests.admin, imageRef: releaseImageRef("admin", m.digests.admin) },
|
|
@@ -333,6 +500,11 @@ export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRun
|
|
|
333
500
|
}
|
|
334
501
|
for (const w of planned.plan.warnings) r.warn(w);
|
|
335
502
|
|
|
503
|
+
// Decided, planned: the backup (or the records correction) is the first
|
|
504
|
+
// write. Held through the roll, install.json and the self-update.
|
|
505
|
+
const blocked = lockAndHeal();
|
|
506
|
+
if (blocked) return stopWith(blocked.status, blocked.codes, blocked.reason);
|
|
507
|
+
|
|
336
508
|
// 5. No backup, no change.
|
|
337
509
|
r.phase("backup");
|
|
338
510
|
const sBak = r.step("Backing up the database before anything changes...");
|
|
@@ -363,6 +535,7 @@ export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRun
|
|
|
363
535
|
}
|
|
364
536
|
|
|
365
537
|
// 6 + 7. The roll; it rolls back by itself on failure.
|
|
538
|
+
let recordError: string | null = null;
|
|
366
539
|
const rolled = await executeAppRoll(
|
|
367
540
|
{
|
|
368
541
|
plan: planned.plan,
|
|
@@ -377,6 +550,9 @@ export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRun
|
|
|
377
550
|
reporter: r,
|
|
378
551
|
migrate: deps.migrate,
|
|
379
552
|
health: probe.probe,
|
|
553
|
+
// The roll already succeeded, so a failed write must not undo it;
|
|
554
|
+
// it is held here and ends the run as failed below (no fallbacks:
|
|
555
|
+
// never "succeeded" while the records name the previous release).
|
|
380
556
|
recordDigests: async (digests) => {
|
|
381
557
|
r.phase("record");
|
|
382
558
|
try {
|
|
@@ -384,8 +560,9 @@ export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRun
|
|
|
384
560
|
deps.writeInstall({ ...fresh, imageDigests: digests, releaseVersion: m.version });
|
|
385
561
|
r.info(`Recorded ${m.version} as this install's release.`);
|
|
386
562
|
} catch (e) {
|
|
387
|
-
|
|
388
|
-
|
|
563
|
+
recordError = errorText(e);
|
|
564
|
+
r.error(
|
|
565
|
+
`The new release serves, but install.json could not be updated (${recordError}).`,
|
|
389
566
|
);
|
|
390
567
|
}
|
|
391
568
|
},
|
|
@@ -408,6 +585,13 @@ export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRun
|
|
|
408
585
|
`Stopped during ${rolled.phase === "migrations" ? "the database migrations" : "the pre-check"}; the previous release keeps serving.${restore}`,
|
|
409
586
|
);
|
|
410
587
|
}
|
|
588
|
+
if (recordError !== null) {
|
|
589
|
+
return stopWith(
|
|
590
|
+
"failed",
|
|
591
|
+
["record"],
|
|
592
|
+
`Caelo ${m.version} serves and passed its checks, but the install's records (install.json) could not be updated (${recordError}), so they still name the previous release. The next update corrects them by itself once the upgrader can write its ops bucket again; check the upgrader's access to it.`,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
411
595
|
|
|
412
596
|
// 8. Self-update, last: the next run uses this release's upgrader.
|
|
413
597
|
r.phase("self-update");
|
|
@@ -446,7 +630,7 @@ export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRun
|
|
|
446
630
|
* @returns whether the end was written (false after the last attempt failed)
|
|
447
631
|
*/
|
|
448
632
|
export async function recordUpdateEnd(
|
|
449
|
-
queue: UpdateQueue,
|
|
633
|
+
queue: Pick<UpdateQueue, "finish">,
|
|
450
634
|
requestId: string,
|
|
451
635
|
end: UpdateEnd,
|
|
452
636
|
log: JobLog,
|
|
@@ -467,11 +651,11 @@ export async function recordUpdateEnd(
|
|
|
467
651
|
return false;
|
|
468
652
|
}
|
|
469
653
|
|
|
470
|
-
/**
|
|
654
|
+
/** Every digest the revisions serving traffic are known by, per service. */
|
|
471
655
|
async function servingDigests(
|
|
472
656
|
deps: UpgraderJobDeps,
|
|
473
|
-
): Promise<{ ok: true; digests:
|
|
474
|
-
const digests: Partial<Record<RollSlug, string>> = {};
|
|
657
|
+
): Promise<{ ok: true; digests: ServingDigests } | { ok: false; error: string }> {
|
|
658
|
+
const digests: Partial<Record<RollSlug, readonly string[]>> = {};
|
|
475
659
|
for (const slug of ["admin", "gateway"] as const) {
|
|
476
660
|
const prefix = `${gcpNamePrefix(deps.env)}-${slug}`;
|
|
477
661
|
const service = await resolveGcpResourceName(
|
|
@@ -481,13 +665,13 @@ async function servingDigests(
|
|
|
481
665
|
deps.projectId,
|
|
482
666
|
deps.region,
|
|
483
667
|
);
|
|
484
|
-
const
|
|
485
|
-
? await
|
|
668
|
+
const image = service
|
|
669
|
+
? await findServingImage(deps.run, deps.projectId, deps.region, service)
|
|
486
670
|
: null;
|
|
487
|
-
if (!
|
|
488
|
-
digests[slug] =
|
|
671
|
+
if (!image) return { ok: false, error: `could not read which image the ${slug} serves` };
|
|
672
|
+
digests[slug] = servingImageDigests(image);
|
|
489
673
|
}
|
|
490
|
-
return { ok: true, digests: digests as
|
|
674
|
+
return { ok: true, digests: digests as ServingDigests };
|
|
491
675
|
}
|
|
492
676
|
|
|
493
677
|
async function recordFacts(
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Does install.json describe what serves, and if not, which signed release
|
|
5
|
+
* does (#860)?
|
|
6
|
+
*
|
|
7
|
+
* The upgrader judges a request (downgrade, minFromVersion, infra contract)
|
|
8
|
+
* against the version install.json records, so it must first know the
|
|
9
|
+
* record is true. When the recorded digests differ from the serving ones,
|
|
10
|
+
* the only repair a human could make is "record the release that serves",
|
|
11
|
+
* and the job can do that itself without widening its trust: it records a
|
|
12
|
+
* release only when BOTH serving images (admin + gateway) are the images of
|
|
13
|
+
* one release manifest that verified against Caelo's release workflow
|
|
14
|
+
* (fetchVerifiedReleaseManifest, the same cosign check every install takes).
|
|
15
|
+
* Images that belong to no signed release are a deployment nobody can
|
|
16
|
+
* vouch for, so the run stops and says so.
|
|
17
|
+
*
|
|
18
|
+
* Which releases to try comes from untrusted hints — the version
|
|
19
|
+
* install.json names, and the release tags pointing at the serving digests
|
|
20
|
+
* in the fixed mirror (releaseVersionForDigest). A wrong hint costs one
|
|
21
|
+
* manifest fetch; only the signed manifest decides.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { GcloudRunner } from "./gcloud-retry.js";
|
|
25
|
+
import type { ImageDigests, InstallMetadata } from "./install-state.js";
|
|
26
|
+
import type { ReleaseManifest, ReleaseManifestResult } from "./release-manifest.js";
|
|
27
|
+
import type { RollSlug } from "./upgrade/app-roll.js";
|
|
28
|
+
import { releaseVersionForDigest } from "./upgrade/release-images.js";
|
|
29
|
+
|
|
30
|
+
/** Every digest each serving image is known by (gcp-lookup.ts servingImageDigests). */
|
|
31
|
+
export type ServingDigests = Readonly<Record<RollSlug, readonly string[]>>;
|
|
32
|
+
|
|
33
|
+
const SLUGS: readonly RollSlug[] = ["admin", "gateway"];
|
|
34
|
+
|
|
35
|
+
/** Whether `recorded` names the images that serve, in any form they are known by. */
|
|
36
|
+
export function recordMatchesServing(
|
|
37
|
+
recorded: ImageDigests | undefined,
|
|
38
|
+
serving: ServingDigests,
|
|
39
|
+
): boolean {
|
|
40
|
+
if (!recorded) return false;
|
|
41
|
+
return SLUGS.every((slug) => serving[slug].includes(recorded[slug]));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What {@link findServingRelease} learned. */
|
|
45
|
+
export type ServingRelease =
|
|
46
|
+
| { readonly ok: true; readonly manifest: ReleaseManifest }
|
|
47
|
+
| {
|
|
48
|
+
readonly ok: false;
|
|
49
|
+
/**
|
|
50
|
+
* True when no release matched but a candidate's manifest could not be
|
|
51
|
+
* fetched because the registry was unreachable or failing (5xx, 429,
|
|
52
|
+
* network): an outage, not a verdict, so the run must end `failed` and
|
|
53
|
+
* be retried, not send the operator to Cloud Shell. A manifest the
|
|
54
|
+
* registry says does not exist (404) never appears on retry, so it
|
|
55
|
+
* counts as rejected like a bad signature.
|
|
56
|
+
*/
|
|
57
|
+
readonly retryable: boolean;
|
|
58
|
+
readonly reason: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export interface ServingReleaseDeps {
|
|
62
|
+
readonly run: GcloudRunner;
|
|
63
|
+
/** fetchVerifiedReleaseManifest against the fixed registry. */
|
|
64
|
+
readonly fetchManifest: (version: string) => Promise<ReleaseManifestResult>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The signed release whose admin AND gateway images both serve, or why there
|
|
69
|
+
* is none (an operator-facing sentence naming the serving digests).
|
|
70
|
+
*
|
|
71
|
+
* @param meta - install.json as read; its `releaseVersion` is the first hint.
|
|
72
|
+
*/
|
|
73
|
+
export async function findServingRelease(
|
|
74
|
+
meta: InstallMetadata,
|
|
75
|
+
serving: ServingDigests,
|
|
76
|
+
deps: ServingReleaseDeps,
|
|
77
|
+
): Promise<ServingRelease> {
|
|
78
|
+
const partial: string[] = [];
|
|
79
|
+
const unverified: string[] = [];
|
|
80
|
+
const unreachable: string[] = [];
|
|
81
|
+
const tried: string[] = [];
|
|
82
|
+
for await (const version of candidateVersions(meta, serving, deps.run)) {
|
|
83
|
+
if (tried.includes(version)) continue;
|
|
84
|
+
tried.push(version);
|
|
85
|
+
const fetched = await deps.fetchManifest(version);
|
|
86
|
+
if (!fetched.ok) {
|
|
87
|
+
if (fetched.error.code === "registry" && fetched.error.transient) {
|
|
88
|
+
unreachable.push(`v${version} (${fetched.error.message.slice(0, 200)})`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
unverified.push(`v${version} (${fetched.error.message.slice(0, 200)})`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const m = fetched.manifest;
|
|
95
|
+
const matching = SLUGS.filter((slug) => serving[slug].includes(m.digests[slug]));
|
|
96
|
+
if (matching.length === SLUGS.length) return { ok: true, manifest: m };
|
|
97
|
+
if (matching.length > 0) {
|
|
98
|
+
const other = SLUGS.filter((s) => !matching.includes(s));
|
|
99
|
+
partial.push(
|
|
100
|
+
`the ${matching.join(", ")} serves v${m.version}'s image, the ${other.join(", ")} does not`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const servingText = SLUGS.map((s) => `${s} ${serving[s][0] ?? "(unknown)"}`).join(", ");
|
|
105
|
+
if (unreachable.length > 0) {
|
|
106
|
+
// A release we could not read may be the serving one, so this is no
|
|
107
|
+
// verdict on the images: retry later rather than escalate.
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
retryable: true,
|
|
111
|
+
reason: `Nothing was changed: the images that serve now (${servingText}) are not the ones install.json names, and the Caelo release registry could not be reached to check which release they belong to (${unreachable.join("; ")}). Try again later.`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
retryable: false,
|
|
117
|
+
reason: [
|
|
118
|
+
`The images that serve now (${servingText}) are not the ones install.json names, and they do not belong to one signed Caelo release${tried.length === 0 ? " (no release tag could be found for them)" : ""}, so the upgrader cannot tell what this install runs.`,
|
|
119
|
+
...(partial.length > 0 ? [`${capitalize(partial.join("; "))}.`] : []),
|
|
120
|
+
...(unverified.length > 0
|
|
121
|
+
? [`These release records could not be verified: ${unverified.join("; ")}.`]
|
|
122
|
+
: []),
|
|
123
|
+
"Nothing was changed. Check who deployed these images (the Cloud Run revision history of both services); an upgrade with Cloud Shell puts a signed release back.",
|
|
124
|
+
].join(" "),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Release versions worth checking, most likely first. Lazy: the version
|
|
130
|
+
* install.json names usually is the serving one, and then no tag listing
|
|
131
|
+
* runs at all.
|
|
132
|
+
*/
|
|
133
|
+
async function* candidateVersions(
|
|
134
|
+
meta: InstallMetadata,
|
|
135
|
+
serving: ServingDigests,
|
|
136
|
+
run: GcloudRunner,
|
|
137
|
+
): AsyncGenerator<string> {
|
|
138
|
+
if (meta.releaseVersion) yield meta.releaseVersion;
|
|
139
|
+
// Release tags point at the index digest a deploy pins (the deployed form,
|
|
140
|
+
// listed first); the platform digest Cloud Run resolved has no tag.
|
|
141
|
+
for (const slug of SLUGS) {
|
|
142
|
+
for (const digest of serving[slug]) {
|
|
143
|
+
const version = await releaseVersionForDigest(run, slug, digest);
|
|
144
|
+
if (version) yield version;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function capitalize(s: string): string {
|
|
150
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
151
|
+
}
|
|
@@ -9,6 +9,12 @@ describe("phaseOfStep", () => {
|
|
|
9
9
|
["Applying DB migrations (idempotent)...", "migrations"],
|
|
10
10
|
["Rolling admin → sha256:aaaa...", "roll"],
|
|
11
11
|
["Deep health check (database, migrations, version)...", "health"],
|
|
12
|
+
// #819 — the full upgrade the converge job runs.
|
|
13
|
+
["Pre-flight: resolving services + image digests + current revisions...", "verify"],
|
|
14
|
+
["Taking a pre-upgrade Cloud SQL backup...", "backup"],
|
|
15
|
+
["Ensuring the gateway service account + generated runtime secrets...", "converge"],
|
|
16
|
+
["Removing public_role from the Cloud SQL superuser group...", "converge"],
|
|
17
|
+
["Preparing the gateway's database login (public_role)...", "converge"],
|
|
12
18
|
["Something else", null],
|
|
13
19
|
])("%s → %s", (label, phase) => {
|
|
14
20
|
expect(phaseOfStep(label)).toBe(phase as never);
|
package/src/upgrader-reporter.ts
CHANGED
|
@@ -41,10 +41,15 @@ export interface EventReporter extends UpgradeReporter {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
-
* The phase an
|
|
45
|
-
*
|
|
44
|
+
* The phase an upgrade step belongs to, from its label (app-roll.ts,
|
|
45
|
+
* converge.ts and upgrade.ts name their steps). Unknown labels keep the
|
|
46
|
+
* current phase.
|
|
46
47
|
*/
|
|
47
48
|
export function phaseOfStep(label: string): PlatformUpdatePhase | null {
|
|
49
|
+
// The full upgrade (upgrade/upgrade.ts) the converge job runs (#819).
|
|
50
|
+
if (label.startsWith("Pre-flight")) return "verify";
|
|
51
|
+
if (label.startsWith("Taking a pre-upgrade")) return "backup";
|
|
52
|
+
if (/^(Ensuring|Preparing|Removing) /.test(label)) return "converge";
|
|
48
53
|
if (label.startsWith("Checking the health check")) return "health-baseline";
|
|
49
54
|
if (label.startsWith("Applying DB migrations")) return "migrations";
|
|
50
55
|
if (label.startsWith("Rolling ")) return "roll";
|
|
@@ -75,7 +75,7 @@ describe("ensureUpgraderJob", () => {
|
|
|
75
75
|
const mine = grants(calls).filter((g) => g.member === UPGRADER);
|
|
76
76
|
expect(mine.map((g) => `${g.where} → ${g.role}`).sort()).toEqual(
|
|
77
77
|
[
|
|
78
|
-
"projects add-iam-policy-binding acme → roles/
|
|
78
|
+
"projects add-iam-policy-binding acme → projects/acme/roles/caeloUpgraderRun",
|
|
79
79
|
"projects add-iam-policy-binding acme → roles/cloudsql.instanceUser",
|
|
80
80
|
"projects add-iam-policy-binding acme → roles/logging.logWriter",
|
|
81
81
|
"projects add-iam-policy-binding acme → projects/acme/roles/caeloUpgraderSqlBackup",
|
|
@@ -83,6 +83,7 @@ describe("ensureUpgraderJob", () => {
|
|
|
83
83
|
"iam service-accounts add-iam-policy-binding caelo-production-gateway-sa@acme.iam.gserviceaccount.com → roles/iam.serviceAccountUser",
|
|
84
84
|
"iam service-accounts add-iam-policy-binding caelo-production-upgrader@acme.iam.gserviceaccount.com → roles/iam.serviceAccountUser",
|
|
85
85
|
"iam service-accounts add-iam-policy-binding caelo-mcp@acme.iam.gserviceaccount.com → roles/iam.serviceAccountTokenCreator",
|
|
86
|
+
"iam service-accounts add-iam-policy-binding caelo-production-audit@acme.iam.gserviceaccount.com → roles/iam.serviceAccountUser",
|
|
86
87
|
"storage buckets add-iam-policy-binding gs://acme-caelo-ops → roles/storage.objectUser",
|
|
87
88
|
"storage buckets add-iam-policy-binding gs://acme-caelo-ops → projects/acme/roles/caeloUpgraderOpsList",
|
|
88
89
|
].sort(),
|
|
@@ -169,11 +170,11 @@ describe("ensureUpgraderJob", () => {
|
|
|
169
170
|
|
|
170
171
|
it("stops at the first failed grant and says which", async () => {
|
|
171
172
|
const { run, calls } = fakeGcloud([
|
|
172
|
-
[/add-iam-policy-binding acme .*
|
|
173
|
+
[/add-iam-policy-binding acme .*caeloUpgraderRun/, fail("PERMISSION_DENIED")],
|
|
173
174
|
]);
|
|
174
175
|
const r = await ensureUpgraderJob(TARGET, { run, sleep: async () => {} });
|
|
175
176
|
expect(r.ok ? "" : r.error).toContain(
|
|
176
|
-
"grant the upgrader roles/
|
|
177
|
+
"grant the upgrader projects/acme/roles/caeloUpgraderRun: PERMISSION_DENIED",
|
|
177
178
|
);
|
|
178
179
|
expect(calls.some((c) => c.slice(0, 3).join(" ") === "run jobs deploy")).toBe(false);
|
|
179
180
|
});
|