@caelo-cms/provisioning 0.10.47 → 0.10.48
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/apply.d.ts +45 -0
- package/dist/agent/apply.d.ts.map +1 -0
- package/dist/agent/apply.js +83 -0
- package/dist/agent/apply.js.map +1 -0
- package/dist/agent/cli-provenance.d.ts +42 -0
- package/dist/agent/cli-provenance.d.ts.map +1 -0
- package/dist/agent/cli-provenance.js +138 -0
- package/dist/agent/cli-provenance.js.map +1 -0
- package/dist/agent/command.d.ts +79 -0
- package/dist/agent/command.d.ts.map +1 -0
- package/dist/agent/command.js +454 -0
- package/dist/agent/command.js.map +1 -0
- package/dist/agent/config.d.ts +30 -0
- package/dist/agent/config.d.ts.map +1 -0
- package/dist/agent/config.js +60 -0
- package/dist/agent/config.js.map +1 -0
- package/dist/agent/lock.d.ts +13 -0
- package/dist/agent/lock.d.ts.map +1 -0
- package/dist/agent/lock.js +100 -0
- package/dist/agent/lock.js.map +1 -0
- package/dist/agent/queue.d.ts +35 -0
- package/dist/agent/queue.d.ts.map +1 -0
- package/dist/agent/queue.js +50 -0
- package/dist/agent/queue.js.map +1 -0
- package/dist/agent/run.d.ts +97 -0
- package/dist/agent/run.d.ts.map +1 -0
- package/dist/agent/run.js +198 -0
- package/dist/agent/run.js.map +1 -0
- package/dist/agent/systemd.d.ts +55 -0
- package/dist/agent/systemd.d.ts.map +1 -0
- package/dist/agent/systemd.js +171 -0
- package/dist/agent/systemd.js.map +1 -0
- package/dist/agent/units.d.ts +44 -0
- package/dist/agent/units.d.ts.map +1 -0
- package/dist/agent/units.js +127 -0
- package/dist/agent/units.js.map +1 -0
- package/dist/agent-db.js +18362 -0
- package/dist/cli.js +119 -16
- package/dist/cli.js.map +1 -1
- package/dist/cloudsql-iam-user.d.ts +39 -0
- package/dist/cloudsql-iam-user.d.ts.map +1 -0
- package/dist/cloudsql-iam-user.js +119 -0
- package/dist/cloudsql-iam-user.js.map +1 -0
- package/dist/compose.d.ts +16 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +26 -1
- package/dist/compose.js.map +1 -1
- package/dist/dns/index.d.ts +2 -0
- package/dist/dns/index.d.ts.map +1 -1
- package/dist/dns/index.js +3 -2
- package/dist/dns/index.js.map +1 -1
- package/dist/dns/manual.d.ts +5 -0
- package/dist/dns/manual.d.ts.map +1 -1
- package/dist/dns/manual.js +28 -12
- package/dist/dns/manual.js.map +1 -1
- package/dist/gcloud.d.ts +2 -0
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +1 -1
- package/dist/gcloud.js.map +1 -1
- package/dist/gcp-names.d.ts +15 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +23 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.js +7 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job-runner.d.ts +13 -0
- package/dist/install-job-runner.d.ts.map +1 -0
- package/dist/install-job-runner.js +155 -0
- package/dist/install-job-runner.js.map +1 -0
- package/dist/install-job.d.ts +210 -0
- package/dist/install-job.d.ts.map +1 -0
- package/dist/install-job.js +449 -0
- package/dist/install-job.js.map +1 -0
- package/dist/install-state.d.ts +10 -1
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +10 -2
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +19 -4
- package/dist/lifecycle.js.map +1 -1
- package/dist/migration-runner.d.ts +5 -0
- package/dist/migration-runner.d.ts.map +1 -1
- package/dist/migration-runner.js +1 -1
- package/dist/migration-runner.js.map +1 -1
- package/dist/operator-access.d.ts +14 -8
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +16 -102
- package/dist/operator-access.js.map +1 -1
- package/dist/run-mode.d.ts +27 -0
- package/dist/run-mode.d.ts.map +1 -0
- package/dist/run-mode.js +22 -0
- package/dist/run-mode.js.map +1 -0
- package/dist/runtime-secrets.d.ts +19 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +44 -0
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/self-hosted-roles.d.ts +22 -1
- package/dist/self-hosted-roles.d.ts.map +1 -1
- package/dist/self-hosted-roles.js +81 -12
- package/dist/self-hosted-roles.js.map +1 -1
- package/dist/self-hosted-upgrade.d.ts +17 -0
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +16 -1
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +6 -0
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +10 -1
- package/dist/stack-contract.js.map +1 -1
- package/dist/upgrade/converge.d.ts +6 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +59 -2
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/gcp-lookup.d.ts +6 -0
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +25 -0
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/release-images.d.ts +12 -0
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +31 -0
- package/dist/upgrade/release-images.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts +5 -0
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +28 -1
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-assess.d.ts +82 -0
- package/dist/upgrader-assess.d.ts.map +1 -0
- package/dist/upgrader-assess.js +116 -0
- package/dist/upgrader-assess.js.map +1 -0
- package/dist/upgrader-gcp.d.ts +17 -0
- package/dist/upgrader-gcp.d.ts.map +1 -0
- package/dist/upgrader-gcp.js +87 -0
- package/dist/upgrader-gcp.js.map +1 -0
- package/dist/upgrader-job.d.ts +131 -0
- package/dist/upgrader-job.d.ts.map +1 -0
- package/dist/upgrader-job.js +259 -0
- package/dist/upgrader-job.js.map +1 -0
- package/dist/upgrader-reporter.d.ts +35 -0
- package/dist/upgrader-reporter.d.ts.map +1 -0
- package/dist/upgrader-reporter.js +79 -0
- package/dist/upgrader-reporter.js.map +1 -0
- package/dist/upgrader-setup.d.ts +59 -0
- package/dist/upgrader-setup.d.ts.map +1 -0
- package/dist/upgrader-setup.js +308 -0
- package/dist/upgrader-setup.js.map +1 -0
- package/dist/wizard.d.ts +6 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +32 -4
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp-firebase.d.ts +3 -0
- package/dist/wizards/gcp-firebase.d.ts.map +1 -1
- package/dist/wizards/gcp-firebase.js +1 -0
- package/dist/wizards/gcp-firebase.js.map +1 -1
- package/dist/wizards/gcp-install-job.d.ts +74 -0
- package/dist/wizards/gcp-install-job.d.ts.map +1 -0
- package/dist/wizards/gcp-install-job.js +398 -0
- package/dist/wizards/gcp-install-job.js.map +1 -0
- package/dist/wizards/gcp-prompts.d.ts +3 -0
- package/dist/wizards/gcp-prompts.d.ts.map +1 -0
- package/dist/wizards/gcp-prompts.js +25 -0
- package/dist/wizards/gcp-prompts.js.map +1 -0
- package/dist/wizards/gcp-pulumi.d.ts +4 -2
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +1 -1
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts +40 -0
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +223 -59
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +6 -4
- package/src/adapter.ts +97 -0
- package/src/adc-impersonation.ts +176 -0
- package/src/admin-domain.test.ts +254 -0
- package/src/admin-domain.ts +245 -0
- package/src/admin-memory.test.ts +151 -0
- package/src/agent/apply.test.ts +185 -0
- package/src/agent/apply.ts +124 -0
- package/src/agent/cli-provenance.test.ts +159 -0
- package/src/agent/cli-provenance.ts +191 -0
- package/src/agent/command.test.ts +77 -0
- package/src/agent/command.ts +564 -0
- package/src/agent/config.ts +86 -0
- package/src/agent/lock-config.test.ts +97 -0
- package/src/agent/lock.ts +110 -0
- package/src/agent/queue.test.ts +10 -0
- package/src/agent/queue.ts +91 -0
- package/src/agent/run.test.ts +382 -0
- package/src/agent/run.ts +295 -0
- package/src/agent/systemd.ts +209 -0
- package/src/agent/units.test.ts +223 -0
- package/src/agent/units.ts +163 -0
- package/src/azure-front-door.test.ts +119 -0
- package/src/azure-front-door.ts +56 -0
- package/src/bootstrap-token.ts +20 -0
- package/src/caddy.ts +112 -0
- package/src/cdn-copy.ts +91 -0
- package/src/cert-wait.test.ts +194 -0
- package/src/cert-wait.ts +163 -0
- package/src/checkpoint-secrets.test.ts +380 -0
- package/src/checkpoint-secrets.ts +283 -0
- package/src/cli-routing.test.ts +45 -0
- package/src/cli-routing.ts +43 -0
- package/src/cli.ts +1491 -0
- package/src/cloudsql-iam-user.ts +147 -0
- package/src/compose.ts +334 -0
- package/src/default-firewall.test.ts +411 -0
- package/src/default-firewall.ts +342 -0
- package/src/deployed-release.test.ts +231 -0
- package/src/deployed-release.ts +220 -0
- package/src/dns/cloudflare.ts +182 -0
- package/src/dns/groups.test.ts +76 -0
- package/src/dns/groups.ts +86 -0
- package/src/dns/index.ts +56 -0
- package/src/dns/manual.ts +130 -0
- package/src/dns/types.ts +27 -0
- package/src/gateway-credentials.test.ts +180 -0
- package/src/gateway-credentials.ts +144 -0
- package/src/gcloud-components.test.ts +76 -0
- package/src/gcloud-components.ts +85 -0
- package/src/gcloud-retry.ts +41 -0
- package/src/gcloud-runner.test.ts +61 -0
- package/src/gcloud.test.ts +99 -0
- package/src/gcloud.ts +322 -0
- package/src/gcp-names.ts +135 -0
- package/src/index.test.ts +332 -0
- package/src/index.ts +114 -0
- package/src/infra-contract.ts +32 -0
- package/src/install-job-runner.ts +166 -0
- package/src/install-job.test.ts +566 -0
- package/src/install-job.ts +542 -0
- package/src/install-state.test.ts +239 -0
- package/src/install-state.ts +583 -0
- package/src/internal-token.ts +35 -0
- package/src/lifecycle.ts +900 -0
- package/src/mcp-iap-stacks.test.ts +58 -0
- package/src/mcp-iap.test.ts +149 -0
- package/src/mcp-iap.ts +131 -0
- package/src/media-volume.test.ts +296 -0
- package/src/migration-runner.ts +365 -0
- package/src/operator-access-stacks.test.ts +55 -0
- package/src/operator-access.test.ts +332 -0
- package/src/operator-access.ts +554 -0
- package/src/ops-backend.test.ts +320 -0
- package/src/ops-backend.ts +414 -0
- package/src/provisioner-identity.test.ts +482 -0
- package/src/provisioner-identity.ts +407 -0
- package/src/redirects-emit.ts +175 -0
- package/src/region-data.ts +428 -0
- package/src/regions.test.ts +277 -0
- package/src/regions.ts +370 -0
- package/src/release-contract.ts +57 -0
- package/src/release-manifest.test.ts +293 -0
- package/src/release-manifest.ts +430 -0
- package/src/run-mode.ts +40 -0
- package/src/runtime-secrets.test.ts +627 -0
- package/src/runtime-secrets.ts +754 -0
- package/src/self-command.test.ts +14 -0
- package/src/self-command.ts +21 -0
- package/src/self-hosted-compose.test.ts +177 -0
- package/src/self-hosted-release.ts +240 -0
- package/src/self-hosted-roles.integration.test.ts +161 -0
- package/src/self-hosted-roles.test.ts +124 -0
- package/src/self-hosted-roles.ts +203 -0
- package/src/self-hosted-upgrade.test.ts +394 -0
- package/src/self-hosted-upgrade.ts +548 -0
- package/src/stack-contract-parity.test.ts +199 -0
- package/src/stack-contract.test.ts +391 -0
- package/src/stack-contract.ts +888 -0
- package/src/stack-converge-secrets.test.ts +421 -0
- package/src/stack-converge.test.ts +833 -0
- package/src/stack-converge.ts +909 -0
- package/src/staging-admin.test.ts +246 -0
- package/src/staging-admin.ts +324 -0
- package/src/state-attach.test.ts +211 -0
- package/src/state-attach.ts +132 -0
- package/src/state-backend.test.ts +145 -0
- package/src/state-backend.ts +262 -0
- package/src/state-command.ts +293 -0
- package/src/state-migrate.test.ts +479 -0
- package/src/state-migrate.ts +434 -0
- package/src/state-pulumi.ts +289 -0
- package/src/state-store.test.ts +212 -0
- package/src/state-store.ts +243 -0
- package/src/upgrade/app-roll.ts +486 -0
- package/src/upgrade/backup.ts +109 -0
- package/src/upgrade/converge.ts +437 -0
- package/src/upgrade/gcp-lookup.ts +167 -0
- package/src/upgrade/health.test.ts +279 -0
- package/src/upgrade/health.ts +305 -0
- package/src/upgrade/release-images.test.ts +119 -0
- package/src/upgrade/release-images.ts +391 -0
- package/src/upgrade/reporter.ts +46 -0
- package/src/upgrade/upgrade.test.ts +626 -0
- package/src/upgrade/upgrade.ts +249 -0
- package/src/upgrader-assess.test.ts +265 -0
- package/src/upgrader-assess.ts +233 -0
- package/src/upgrader-gcp.ts +123 -0
- package/src/upgrader-job.test.ts +408 -0
- package/src/upgrader-job.ts +499 -0
- package/src/upgrader-reporter.test.ts +46 -0
- package/src/upgrader-reporter.ts +105 -0
- package/src/upgrader-setup.test.ts +169 -0
- package/src/upgrader-setup.ts +355 -0
- package/src/wizard.ts +425 -0
- package/src/wizards/gcp-cost.ts +188 -0
- package/src/wizards/gcp-firebase.ts +72 -0
- package/src/wizards/gcp-install-job.ts +506 -0
- package/src/wizards/gcp-prompts.ts +26 -0
- package/src/wizards/gcp-pulumi.test.ts +246 -0
- package/src/wizards/gcp-pulumi.ts +516 -0
- package/src/wizards/gcp.ts +1935 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The upgrader job (epic #696 Phase 2): installs one signed Caelo release
|
|
5
|
+
* on a `gcp` / `gcp-firebase` install, from inside the install's own cloud.
|
|
6
|
+
*
|
|
7
|
+
* The admin's "Install update" writes a `platform_update_requests` row and
|
|
8
|
+
* starts this job. The admin holds only `run.jobs.run` on it — not
|
|
9
|
+
* `runWithOverrides` — so it cannot pass arguments, env or another image
|
|
10
|
+
* (upgrader-setup.ts). The job runs as its own service account, which may
|
|
11
|
+
* deploy Cloud Run revisions, run the migration job and back up Cloud SQL,
|
|
12
|
+
* but cannot change IAM, service accounts or secrets. One run:
|
|
13
|
+
*
|
|
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;
|
|
16
|
+
* 3. fetch the release manifest from the FIXED registry and verify its
|
|
17
|
+
* signature (release-manifest.ts) — no registry, identity or digest
|
|
18
|
+
* ever comes from the request, the env or argv;
|
|
19
|
+
* 4. decide (upgrader-assess.ts): refuse a bad signature, a downgrade, a
|
|
20
|
+
* release below its minFromVersion, an automatic run the policy does
|
|
21
|
+
* not allow; hand an infrastructure change to Cloud Shell;
|
|
22
|
+
* 5. take a Cloud SQL backup — no backup, no change;
|
|
23
|
+
* 6. app-roll (upgrade/app-roll.ts) with no converge hooks: migrations
|
|
24
|
+
* with the new admin image, admin then gateway, deep health check,
|
|
25
|
+
* traffic back onto the previous revisions on any failure;
|
|
26
|
+
* 7. record the new digests + version in install.json;
|
|
27
|
+
* 8. move this job onto the release's own ops image, last.
|
|
28
|
+
*
|
|
29
|
+
* Every step is narrated into `platform_update_events` (upgrader-reporter.ts).
|
|
30
|
+
* All side effects come in through {@link UpgraderJobDeps}, so the whole
|
|
31
|
+
* run is tested against fakes; the real wiring is the
|
|
32
|
+
* `@caelo-cms/platform-updater` package (`caelo-ops upgrader`).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type {
|
|
36
|
+
PlatformUpdateEventLevel,
|
|
37
|
+
PlatformUpdatePhase,
|
|
38
|
+
PlatformUpdateTrigger,
|
|
39
|
+
StoredUpdatePolicy,
|
|
40
|
+
} from "@caelo-cms/shared";
|
|
41
|
+
import type { GcloudRunner, Sleep } from "./gcloud-retry.js";
|
|
42
|
+
import { gcpNamePrefix, upgraderJobName } from "./gcp-names.js";
|
|
43
|
+
import type { InstallMetadata } from "./install-state.js";
|
|
44
|
+
import type { ReleaseManifest, ReleaseManifestResult } from "./release-manifest.js";
|
|
45
|
+
import {
|
|
46
|
+
type AppRollPlan,
|
|
47
|
+
executeAppRoll,
|
|
48
|
+
planAppRoll,
|
|
49
|
+
type ReleaseImage,
|
|
50
|
+
type RollSlug,
|
|
51
|
+
} from "./upgrade/app-roll.js";
|
|
52
|
+
import { restoreCommand, takeSqlBackup } from "./upgrade/backup.js";
|
|
53
|
+
import { findServingImageDigest, resolveGcpResourceName } from "./upgrade/gcp-lookup.js";
|
|
54
|
+
import type { HealthProbe } from "./upgrade/health.js";
|
|
55
|
+
import { releaseImageRef } from "./upgrade/release-images.js";
|
|
56
|
+
import { assessUpgradeRequest, type RefusalCode } from "./upgrader-assess.js";
|
|
57
|
+
import { eventReporter, type JobLog } from "./upgrader-reporter.js";
|
|
58
|
+
|
|
59
|
+
/** A request the job claimed, with the policy as it stood at claim time. */
|
|
60
|
+
export interface ClaimedUpdate {
|
|
61
|
+
readonly id: string;
|
|
62
|
+
readonly targetVersion: string;
|
|
63
|
+
readonly trigger: PlatformUpdateTrigger;
|
|
64
|
+
readonly policy: StoredUpdatePolicy;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Facts the job learns and stores on the request for the admin to show. */
|
|
68
|
+
export interface UpdateFacts {
|
|
69
|
+
readonly manifestSecurity?: boolean;
|
|
70
|
+
readonly contractMigrations?: boolean;
|
|
71
|
+
readonly infraContractRequired?: number;
|
|
72
|
+
readonly infraContractInstalled?: number;
|
|
73
|
+
readonly adminDigest?: string;
|
|
74
|
+
readonly gatewayDigest?: string;
|
|
75
|
+
readonly opsDigest?: string;
|
|
76
|
+
readonly backupId?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** How a request ended. */
|
|
80
|
+
export type UpdateEndStatus =
|
|
81
|
+
| "succeeded"
|
|
82
|
+
| "rolled_back"
|
|
83
|
+
| "failed"
|
|
84
|
+
| "refused"
|
|
85
|
+
| "needs_cloud_shell";
|
|
86
|
+
|
|
87
|
+
export interface UpdateEnd {
|
|
88
|
+
readonly status: UpdateEndStatus;
|
|
89
|
+
readonly reason: string | null;
|
|
90
|
+
readonly codes: readonly string[];
|
|
91
|
+
/** A failed automatic install pauses automatic installs (Phase 5). */
|
|
92
|
+
readonly pauseAutoUpdates: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The job's view of the database (the platform_updater role, via the Query API). */
|
|
96
|
+
export interface UpdateQueue {
|
|
97
|
+
/** End a run that died mid-way, then claim the oldest pending request. */
|
|
98
|
+
claim(execution: string | null): Promise<ClaimedUpdate | null>;
|
|
99
|
+
event(
|
|
100
|
+
requestId: string,
|
|
101
|
+
phase: PlatformUpdatePhase,
|
|
102
|
+
level: PlatformUpdateEventLevel,
|
|
103
|
+
message: string,
|
|
104
|
+
): Promise<void>;
|
|
105
|
+
record(requestId: string, facts: UpdateFacts): Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* End a running request. Resolves false when it was no longer running
|
|
108
|
+
* (another process already ended it), so nothing changed.
|
|
109
|
+
*/
|
|
110
|
+
finish(requestId: string, end: UpdateEnd): Promise<boolean>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface UpgraderJobDeps {
|
|
114
|
+
readonly queue: UpdateQueue;
|
|
115
|
+
readonly run: GcloudRunner;
|
|
116
|
+
readonly sleep?: Sleep;
|
|
117
|
+
/** From the metadata server — never from env or argv. */
|
|
118
|
+
readonly projectId: string;
|
|
119
|
+
readonly region: string;
|
|
120
|
+
/** Pulumi stack name (`production`). */
|
|
121
|
+
readonly env: string;
|
|
122
|
+
/** This Cloud Run execution, for the log link; null outside Cloud Run. */
|
|
123
|
+
readonly execution: string | null;
|
|
124
|
+
/** fetchVerifiedReleaseManifest against the fixed registry. */
|
|
125
|
+
readonly fetchManifest: (version: string) => Promise<ReleaseManifestResult>;
|
|
126
|
+
/**
|
|
127
|
+
* Take the install's state lock (the one `pulumi up` and `state migrate`
|
|
128
|
+
* hold) for the whole run; returns its release. Throws when another run
|
|
129
|
+
* holds it.
|
|
130
|
+
*/
|
|
131
|
+
readonly lockState: () => () => void;
|
|
132
|
+
/** install.json from the install's ops bucket (null when absent). */
|
|
133
|
+
readonly readInstall: () => InstallMetadata | null;
|
|
134
|
+
readonly writeInstall: (meta: InstallMetadata) => void;
|
|
135
|
+
/** Runs both migration targets with the NEW admin image. */
|
|
136
|
+
readonly migrate: (
|
|
137
|
+
imageRef: string,
|
|
138
|
+
) => Promise<{ readonly ok: boolean; readonly error?: string }>;
|
|
139
|
+
readonly healthProbe: (
|
|
140
|
+
plan: AppRollPlan,
|
|
141
|
+
) => Promise<
|
|
142
|
+
| { readonly ok: true; readonly probe: HealthProbe }
|
|
143
|
+
| { readonly ok: false; readonly error: string }
|
|
144
|
+
>;
|
|
145
|
+
readonly now: () => Date;
|
|
146
|
+
readonly log: JobLog;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** What one run did: the exit code, and the request it handled (if any). */
|
|
150
|
+
export interface UpgraderRunResult {
|
|
151
|
+
readonly exitCode: number;
|
|
152
|
+
readonly requestId: string | null;
|
|
153
|
+
readonly status: UpdateEndStatus | null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const ROLLED_PHASES = new Set(["roll", "health"]);
|
|
157
|
+
|
|
158
|
+
/** Attempts at recording a request's end state before the run fails. */
|
|
159
|
+
const FINISH_ATTEMPTS = 3;
|
|
160
|
+
|
|
161
|
+
const defaultSleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
162
|
+
|
|
163
|
+
/** Run the job once. Never throws; see the file comment for the sequence. */
|
|
164
|
+
export async function runUpgraderJob(deps: UpgraderJobDeps): Promise<UpgraderRunResult> {
|
|
165
|
+
const { queue, log } = deps;
|
|
166
|
+
let claimed: ClaimedUpdate | null;
|
|
167
|
+
try {
|
|
168
|
+
claimed = await queue.claim(deps.execution);
|
|
169
|
+
} catch (e) {
|
|
170
|
+
log({ severity: "ERROR", message: `could not read update requests: ${errorText(e)}` });
|
|
171
|
+
return { exitCode: 1, requestId: null, status: null };
|
|
172
|
+
}
|
|
173
|
+
if (!claimed) {
|
|
174
|
+
log({ severity: "INFO", message: "no pending update request; nothing to do" });
|
|
175
|
+
return { exitCode: 0, requestId: null, status: null };
|
|
176
|
+
}
|
|
177
|
+
const request = claimed;
|
|
178
|
+
const r = eventReporter(
|
|
179
|
+
(phase, level, message) => queue.event(request.id, phase, level, message),
|
|
180
|
+
log,
|
|
181
|
+
request.id,
|
|
182
|
+
);
|
|
183
|
+
let releaseLock: (() => void) | null = null;
|
|
184
|
+
const finish = async (end: UpdateEnd): Promise<UpgraderRunResult> => {
|
|
185
|
+
r.phase("finish");
|
|
186
|
+
if (end.status === "succeeded") r.success(`Caelo ${request.targetVersion} is installed.`);
|
|
187
|
+
else r.error(end.reason ?? end.status);
|
|
188
|
+
await r.flush();
|
|
189
|
+
if (releaseLock) {
|
|
190
|
+
try {
|
|
191
|
+
releaseLock();
|
|
192
|
+
} catch (e) {
|
|
193
|
+
r.warn(errorText(e));
|
|
194
|
+
}
|
|
195
|
+
releaseLock = null;
|
|
196
|
+
}
|
|
197
|
+
const recorded = await recordUpdateEnd(queue, request.id, end, log, deps.sleep);
|
|
198
|
+
const failed = end.status === "failed" || end.status === "rolled_back" || !recorded;
|
|
199
|
+
return { exitCode: failed ? 1 : 0, requestId: request.id, status: end.status };
|
|
200
|
+
};
|
|
201
|
+
const stopWith = (
|
|
202
|
+
status: UpdateEndStatus,
|
|
203
|
+
codes: readonly (RefusalCode | string)[],
|
|
204
|
+
reason: string,
|
|
205
|
+
) =>
|
|
206
|
+
finish({
|
|
207
|
+
status,
|
|
208
|
+
reason,
|
|
209
|
+
codes,
|
|
210
|
+
pauseAutoUpdates:
|
|
211
|
+
request.trigger === "auto" && (status === "failed" || status === "rolled_back"),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
r.phase("start");
|
|
216
|
+
r.info(
|
|
217
|
+
`Upgrader started${deps.execution ? ` (run ${deps.execution})` : ""}: installing Caelo ${request.targetVersion}.`,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
// Exclusive against the installer's own state writers for the whole run.
|
|
221
|
+
try {
|
|
222
|
+
releaseLock = deps.lockState();
|
|
223
|
+
} catch (e) {
|
|
224
|
+
return stopWith(
|
|
225
|
+
"failed",
|
|
226
|
+
["state-locked"],
|
|
227
|
+
`Nothing was changed: another install operation is running on this site (${errorText(e)}). Try again when it has finished.`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 2. What the install runs — from its own state, never from the admin.
|
|
232
|
+
r.phase("verify");
|
|
233
|
+
const meta = deps.readInstall();
|
|
234
|
+
if (!meta || meta.projectId !== deps.projectId) {
|
|
235
|
+
return stopWith(
|
|
236
|
+
"needs_cloud_shell",
|
|
237
|
+
["install-state"],
|
|
238
|
+
"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.",
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
if (meta.provider !== "gcp" && meta.provider !== "gcp-firebase") {
|
|
242
|
+
return stopWith(
|
|
243
|
+
"refused",
|
|
244
|
+
["provider"],
|
|
245
|
+
`The upgrader job only upgrades Google Cloud installs; this one is ${meta.provider}.`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// install.json must describe what actually serves: if a previous run
|
|
250
|
+
// rolled but could not record it, the recorded version is stale and
|
|
251
|
+
// the downgrade / minFromVersion checks would judge against it.
|
|
252
|
+
const serving = await servingDigests(deps);
|
|
253
|
+
if (!serving.ok) {
|
|
254
|
+
return stopWith("failed", ["lookup"], `Nothing was changed: ${serving.error}.`);
|
|
255
|
+
}
|
|
256
|
+
const recorded = meta.imageDigests;
|
|
257
|
+
if (
|
|
258
|
+
!recorded ||
|
|
259
|
+
recorded.admin !== serving.digests.admin ||
|
|
260
|
+
recorded.gateway !== serving.digests.gateway
|
|
261
|
+
) {
|
|
262
|
+
return stopWith(
|
|
263
|
+
"needs_cloud_shell",
|
|
264
|
+
["install-state-drift"],
|
|
265
|
+
"The release that serves now is not the one the install's records (install.json) name, so the upgrader cannot tell safely what it runs. Run the upgrade once with Cloud Shell; it records the serving release again.",
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 3 + 4. The signed manifest decides.
|
|
270
|
+
const manifest = await deps.fetchManifest(request.targetVersion);
|
|
271
|
+
const decision = assessUpgradeRequest({
|
|
272
|
+
request,
|
|
273
|
+
manifest,
|
|
274
|
+
installed: {
|
|
275
|
+
version: meta.releaseVersion ?? null,
|
|
276
|
+
infraContractVersion: meta.infraContractVersion ?? 0,
|
|
277
|
+
},
|
|
278
|
+
policy: request.policy,
|
|
279
|
+
now: deps.now(),
|
|
280
|
+
});
|
|
281
|
+
const verified = decision.manifest;
|
|
282
|
+
if (verified) {
|
|
283
|
+
r.success(
|
|
284
|
+
`Release information of ${verified.version} verified: signed by Caelo's release workflow.`,
|
|
285
|
+
);
|
|
286
|
+
await recordFacts(queue, request.id, verified, meta);
|
|
287
|
+
}
|
|
288
|
+
if (decision.kind === "stop") {
|
|
289
|
+
r.phase("assess");
|
|
290
|
+
return stopWith(decision.status, decision.codes, decision.reason);
|
|
291
|
+
}
|
|
292
|
+
r.phase("assess");
|
|
293
|
+
for (const w of decision.warnings) r.warn(w);
|
|
294
|
+
r.info(
|
|
295
|
+
`${meta.releaseVersion} → ${decision.manifest.version}: no cloud-setup change needed; installing.`,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const m = decision.manifest;
|
|
299
|
+
const images: Record<RollSlug, ReleaseImage> = {
|
|
300
|
+
admin: { digest: m.digests.admin, imageRef: releaseImageRef("admin", m.digests.admin) },
|
|
301
|
+
gateway: {
|
|
302
|
+
digest: m.digests.gateway,
|
|
303
|
+
imageRef: releaseImageRef("gateway", m.digests.gateway),
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
const planned = await planAppRoll(
|
|
307
|
+
{
|
|
308
|
+
provider: meta.provider,
|
|
309
|
+
projectId: deps.projectId,
|
|
310
|
+
region: deps.region,
|
|
311
|
+
env: deps.env,
|
|
312
|
+
domain: meta.domain,
|
|
313
|
+
stagingHost: meta.stagingDomain ?? `staging.${meta.domain}`,
|
|
314
|
+
},
|
|
315
|
+
images,
|
|
316
|
+
deps.run,
|
|
317
|
+
);
|
|
318
|
+
if (!planned.ok) {
|
|
319
|
+
return stopWith("failed", ["plan"], `Nothing was changed: ${planned.error}.`);
|
|
320
|
+
}
|
|
321
|
+
for (const w of planned.plan.warnings) r.warn(w);
|
|
322
|
+
|
|
323
|
+
// 5. No backup, no change.
|
|
324
|
+
r.phase("backup");
|
|
325
|
+
const sBak = r.step("Backing up the database before anything changes...");
|
|
326
|
+
const backup = await takeSqlBackup(
|
|
327
|
+
deps.run,
|
|
328
|
+
{ projectId: deps.projectId, env: deps.env },
|
|
329
|
+
`pre-upgrade to ${m.version}`,
|
|
330
|
+
deps.now,
|
|
331
|
+
);
|
|
332
|
+
if (!backup.ok) {
|
|
333
|
+
sBak.failed(`Backup failed: ${backup.error}`);
|
|
334
|
+
return stopWith(
|
|
335
|
+
"failed",
|
|
336
|
+
["backup"],
|
|
337
|
+
`The pre-upgrade database backup failed, so nothing was changed (${backup.error}). Try again later.`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
sBak.done(`Backup ${backup.backup.backupId} taken.`);
|
|
341
|
+
await queue.record(request.id, { backupId: backup.backup.backupId });
|
|
342
|
+
|
|
343
|
+
const probe = await deps.healthProbe(planned.plan);
|
|
344
|
+
if (!probe.ok) {
|
|
345
|
+
return stopWith(
|
|
346
|
+
"failed",
|
|
347
|
+
["health-check"],
|
|
348
|
+
`The health check could not be prepared, so nothing was changed: ${probe.error}`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// 6 + 7. The roll; it rolls back by itself on failure.
|
|
353
|
+
const rolled = await executeAppRoll(
|
|
354
|
+
{
|
|
355
|
+
plan: planned.plan,
|
|
356
|
+
backup: backup.backup,
|
|
357
|
+
targetVersion: m.version,
|
|
358
|
+
migrationsContract: m.migrations.contract,
|
|
359
|
+
},
|
|
360
|
+
{},
|
|
361
|
+
{
|
|
362
|
+
run: deps.run,
|
|
363
|
+
...(deps.sleep ? { sleep: deps.sleep } : {}),
|
|
364
|
+
reporter: r,
|
|
365
|
+
migrate: deps.migrate,
|
|
366
|
+
health: probe.probe,
|
|
367
|
+
recordDigests: async (digests) => {
|
|
368
|
+
r.phase("record");
|
|
369
|
+
try {
|
|
370
|
+
const fresh = deps.readInstall() ?? meta;
|
|
371
|
+
deps.writeInstall({ ...fresh, imageDigests: digests, releaseVersion: m.version });
|
|
372
|
+
r.info(`Recorded ${m.version} as this install's release.`);
|
|
373
|
+
} catch (e) {
|
|
374
|
+
r.warn(
|
|
375
|
+
`The new release serves, but install.json could not be updated (${errorText(e)}). The next update will stop and ask for one upgrade with Cloud Shell, which records it.`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
);
|
|
381
|
+
if (!rolled.ok) {
|
|
382
|
+
const restore = rolled.migrated
|
|
383
|
+
? ` The database backup ${backup.backup.backupId} is there if you need it (${restoreCommand(backup.backup)}).`
|
|
384
|
+
: "";
|
|
385
|
+
if (ROLLED_PHASES.has(rolled.phase)) {
|
|
386
|
+
return stopWith(
|
|
387
|
+
"rolled_back",
|
|
388
|
+
[rolled.phase],
|
|
389
|
+
`Caelo ${m.version} did not pass its checks, so the previous release serves again.${restore}`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
return stopWith(
|
|
393
|
+
"failed",
|
|
394
|
+
[rolled.phase],
|
|
395
|
+
`Stopped during ${rolled.phase === "migrations" ? "the database migrations" : "the pre-check"}; the previous release keeps serving.${restore}`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// 8. Self-update, last: the next run uses this release's upgrader.
|
|
400
|
+
r.phase("self-update");
|
|
401
|
+
const job = upgraderJobName(deps.env);
|
|
402
|
+
const self = await deps.run([
|
|
403
|
+
"run",
|
|
404
|
+
"jobs",
|
|
405
|
+
"update",
|
|
406
|
+
job,
|
|
407
|
+
`--image=${releaseImageRef("ops", m.digests.ops)}`,
|
|
408
|
+
`--region=${deps.region}`,
|
|
409
|
+
`--project=${deps.projectId}`,
|
|
410
|
+
"--quiet",
|
|
411
|
+
]);
|
|
412
|
+
if (self.ok) r.info(`The upgrader itself now runs ${m.version}.`);
|
|
413
|
+
else {
|
|
414
|
+
r.warn(
|
|
415
|
+
`The upgrader could not update itself (${self.stderr.trim().slice(0, 300)}); the next update still works, with this run's upgrader.`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
return finish({ status: "succeeded", reason: null, codes: [], pauseAutoUpdates: false });
|
|
419
|
+
} catch (e) {
|
|
420
|
+
return stopWith(
|
|
421
|
+
"failed",
|
|
422
|
+
["internal"],
|
|
423
|
+
`The upgrader stopped unexpectedly: ${errorText(e)}. If the site misbehaves, check Settings → Updates and the job's log.`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* End request `requestId` as `end`, retrying a failed write: the end state
|
|
430
|
+
* is what unblocks the next update, so a transient database error must not
|
|
431
|
+
* leave the request "running" while the run reports success.
|
|
432
|
+
*
|
|
433
|
+
* @returns whether the end was written (false after the last attempt failed)
|
|
434
|
+
*/
|
|
435
|
+
export async function recordUpdateEnd(
|
|
436
|
+
queue: UpdateQueue,
|
|
437
|
+
requestId: string,
|
|
438
|
+
end: UpdateEnd,
|
|
439
|
+
log: JobLog,
|
|
440
|
+
sleep: Sleep = defaultSleep,
|
|
441
|
+
): Promise<boolean> {
|
|
442
|
+
for (let attempt = 1; attempt <= FINISH_ATTEMPTS; attempt++) {
|
|
443
|
+
try {
|
|
444
|
+
await queue.finish(requestId, end);
|
|
445
|
+
return true;
|
|
446
|
+
} catch (e) {
|
|
447
|
+
log({
|
|
448
|
+
severity: "ERROR",
|
|
449
|
+
message: `could not record the end of request ${requestId} (attempt ${attempt}/${FINISH_ATTEMPTS}): ${errorText(e)}`,
|
|
450
|
+
});
|
|
451
|
+
if (attempt < FINISH_ATTEMPTS) await sleep(attempt * 5_000);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** The digests the revisions serving traffic run, per service. */
|
|
458
|
+
async function servingDigests(
|
|
459
|
+
deps: UpgraderJobDeps,
|
|
460
|
+
): Promise<{ ok: true; digests: Record<RollSlug, string> } | { ok: false; error: string }> {
|
|
461
|
+
const digests: Partial<Record<RollSlug, string>> = {};
|
|
462
|
+
for (const slug of ["admin", "gateway"] as const) {
|
|
463
|
+
const prefix = `${gcpNamePrefix(deps.env)}-${slug}`;
|
|
464
|
+
const service = await resolveGcpResourceName(
|
|
465
|
+
deps.run,
|
|
466
|
+
"run-service",
|
|
467
|
+
prefix,
|
|
468
|
+
deps.projectId,
|
|
469
|
+
deps.region,
|
|
470
|
+
);
|
|
471
|
+
const digest = service
|
|
472
|
+
? await findServingImageDigest(deps.run, deps.projectId, deps.region, service)
|
|
473
|
+
: null;
|
|
474
|
+
if (!digest) return { ok: false, error: `could not read which image the ${slug} serves` };
|
|
475
|
+
digests[slug] = digest;
|
|
476
|
+
}
|
|
477
|
+
return { ok: true, digests: digests as Record<RollSlug, string> };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function recordFacts(
|
|
481
|
+
queue: UpdateQueue,
|
|
482
|
+
requestId: string,
|
|
483
|
+
m: ReleaseManifest,
|
|
484
|
+
meta: InstallMetadata,
|
|
485
|
+
): Promise<void> {
|
|
486
|
+
await queue.record(requestId, {
|
|
487
|
+
manifestSecurity: m.security,
|
|
488
|
+
contractMigrations: m.migrations.contract,
|
|
489
|
+
infraContractRequired: m.infraContractVersion,
|
|
490
|
+
infraContractInstalled: meta.infraContractVersion ?? 0,
|
|
491
|
+
adminDigest: m.digests.admin,
|
|
492
|
+
gatewayDigest: m.digests.gateway,
|
|
493
|
+
opsDigest: m.digests.ops,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function errorText(e: unknown): string {
|
|
498
|
+
return e instanceof Error ? e.message : String(e);
|
|
499
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import { eventReporter, phaseOfStep } from "./upgrader-reporter.js";
|
|
5
|
+
|
|
6
|
+
describe("phaseOfStep", () => {
|
|
7
|
+
it.each([
|
|
8
|
+
["Checking the health check can reach admin + gateway...", "health-baseline"],
|
|
9
|
+
["Applying DB migrations (idempotent)...", "migrations"],
|
|
10
|
+
["Rolling admin → sha256:aaaa...", "roll"],
|
|
11
|
+
["Deep health check (database, migrations, version)...", "health"],
|
|
12
|
+
["Something else", null],
|
|
13
|
+
])("%s → %s", (label, phase) => {
|
|
14
|
+
expect(phaseOfStep(label)).toBe(phase as never);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("eventReporter", () => {
|
|
19
|
+
it("writes events in order with their phase, and survives a failing write", async () => {
|
|
20
|
+
const written: string[] = [];
|
|
21
|
+
const logs: string[] = [];
|
|
22
|
+
let n = 0;
|
|
23
|
+
const r = eventReporter(
|
|
24
|
+
async (phase, level, message) => {
|
|
25
|
+
n += 1;
|
|
26
|
+
if (n === 2) throw new Error("db down");
|
|
27
|
+
await new Promise((res) => setTimeout(res, (6 - n) * 2));
|
|
28
|
+
written.push(`${phase}/${level}: ${message}`);
|
|
29
|
+
},
|
|
30
|
+
(e) => logs.push(`${e.severity} ${e.message}`),
|
|
31
|
+
"req-1",
|
|
32
|
+
);
|
|
33
|
+
r.phase("verify");
|
|
34
|
+
r.info("checking");
|
|
35
|
+
const s = r.step("Applying DB migrations (idempotent)...");
|
|
36
|
+
s.done("Migrations applied");
|
|
37
|
+
r.warn("Rolling back 1 service(s) to the revisions that served before...");
|
|
38
|
+
r.error("x".repeat(5000));
|
|
39
|
+
await r.flush();
|
|
40
|
+
expect(written[0]).toBe("verify/info: checking");
|
|
41
|
+
expect(written[1]).toBe("migrations/success: Migrations applied");
|
|
42
|
+
expect(written[2]).toStartWith("rollback/warn: Rolling back");
|
|
43
|
+
expect(written[3]?.length).toBeLessThanOrEqual("rollback/error: ".length + 4000);
|
|
44
|
+
expect(logs.some((l) => l.includes("could not record a timeline event: db down"))).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The upgrader job's {@link UpgradeReporter}: every line the app-roll
|
|
5
|
+
* (upgrade/app-roll.ts) reports becomes a `platform_update_events` row the
|
|
6
|
+
* admin renders as a live timeline, and a structured Cloud Logging entry.
|
|
7
|
+
*
|
|
8
|
+
* The reporter interface is synchronous (the CLI's spinners are), but each
|
|
9
|
+
* event is a database write, so writes are chained in order and the job
|
|
10
|
+
* awaits {@link EventReporter.flush} before it finishes. A failed event
|
|
11
|
+
* write never fails the upgrade — the roll matters more than its narration —
|
|
12
|
+
* but it lands in the job log.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
PLATFORM_UPDATE_EVENT_MAX,
|
|
17
|
+
type PlatformUpdateEventLevel,
|
|
18
|
+
type PlatformUpdatePhase,
|
|
19
|
+
} from "@caelo-cms/shared";
|
|
20
|
+
import type { ReportStep, UpgradeReporter } from "./upgrade/reporter.js";
|
|
21
|
+
|
|
22
|
+
/** Appends one timeline line (the job's database queue). */
|
|
23
|
+
export type EventSink = (
|
|
24
|
+
phase: PlatformUpdatePhase,
|
|
25
|
+
level: PlatformUpdateEventLevel,
|
|
26
|
+
message: string,
|
|
27
|
+
) => Promise<void>;
|
|
28
|
+
|
|
29
|
+
/** One structured Cloud Logging line. */
|
|
30
|
+
export type JobLog = (entry: {
|
|
31
|
+
readonly severity: "INFO" | "WARNING" | "ERROR";
|
|
32
|
+
readonly message: string;
|
|
33
|
+
readonly [field: string]: unknown;
|
|
34
|
+
}) => void;
|
|
35
|
+
|
|
36
|
+
export interface EventReporter extends UpgradeReporter {
|
|
37
|
+
/** Set the phase following lines belong to (the job's own stages). */
|
|
38
|
+
phase(phase: PlatformUpdatePhase): void;
|
|
39
|
+
/** Wait until every event so far is written. */
|
|
40
|
+
flush(): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The phase an app-roll step belongs to, from its label (app-roll.ts names
|
|
45
|
+
* its steps). Unknown labels keep the current phase.
|
|
46
|
+
*/
|
|
47
|
+
export function phaseOfStep(label: string): PlatformUpdatePhase | null {
|
|
48
|
+
if (label.startsWith("Checking the health check")) return "health-baseline";
|
|
49
|
+
if (label.startsWith("Applying DB migrations")) return "migrations";
|
|
50
|
+
if (label.startsWith("Rolling ")) return "roll";
|
|
51
|
+
if (label.startsWith("Deep health check")) return "health";
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const SEVERITY: Record<PlatformUpdateEventLevel, "INFO" | "WARNING" | "ERROR"> = {
|
|
56
|
+
info: "INFO",
|
|
57
|
+
success: "INFO",
|
|
58
|
+
warn: "WARNING",
|
|
59
|
+
error: "ERROR",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Build the reporter for one request. */
|
|
63
|
+
export function eventReporter(sink: EventSink, log: JobLog, requestId: string): EventReporter {
|
|
64
|
+
let current: PlatformUpdatePhase = "start";
|
|
65
|
+
let chain: Promise<void> = Promise.resolve();
|
|
66
|
+
const emit = (level: PlatformUpdateEventLevel, message: string, phase = current) => {
|
|
67
|
+
const text = message.trim().slice(0, PLATFORM_UPDATE_EVENT_MAX) || "(no message)";
|
|
68
|
+
log({ severity: SEVERITY[level], message: text, requestId, phase });
|
|
69
|
+
chain = chain.then(() =>
|
|
70
|
+
sink(phase, level, text).catch((e: unknown) => {
|
|
71
|
+
log({
|
|
72
|
+
severity: "ERROR",
|
|
73
|
+
message: `could not record a timeline event: ${e instanceof Error ? e.message : String(e)}`,
|
|
74
|
+
requestId,
|
|
75
|
+
});
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
phase(p) {
|
|
81
|
+
current = p;
|
|
82
|
+
},
|
|
83
|
+
async flush() {
|
|
84
|
+
await chain;
|
|
85
|
+
},
|
|
86
|
+
step(label): ReportStep {
|
|
87
|
+
const p = phaseOfStep(label);
|
|
88
|
+
if (p) current = p;
|
|
89
|
+
emit("info", label);
|
|
90
|
+
return {
|
|
91
|
+
done: (m) => emit("success", m),
|
|
92
|
+
failed: (m) => emit("error", m),
|
|
93
|
+
warned: (m) => emit("warn", m),
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
info: (m) => emit("info", m),
|
|
97
|
+
warn: (m) => {
|
|
98
|
+
// app-roll announces a rollback with a warning before it starts.
|
|
99
|
+
if (m.startsWith("Rolling back")) current = "rollback";
|
|
100
|
+
emit("warn", m);
|
|
101
|
+
},
|
|
102
|
+
error: (m) => emit("error", m),
|
|
103
|
+
success: (m) => emit("success", m),
|
|
104
|
+
};
|
|
105
|
+
}
|