@caelo-cms/provisioning 0.10.47 → 0.10.49
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/caddy/Caddyfile.production +1 -1
- package/caddy/Caddyfile.staging +1 -1
- 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 +18402 -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/release-manifest.d.ts +2 -41
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +18 -121
- package/dist/release-manifest.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 +26 -2
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +35 -2
- 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 +85 -0
- package/dist/upgrader-assess.d.ts.map +1 -0
- package/dist/upgrader-assess.js +122 -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 +133 -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 +437 -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 +300 -0
- package/src/release-manifest.ts +310 -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 +393 -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 +306 -0
- package/src/upgrader-assess.ts +250 -0
- package/src/upgrader-gcp.ts +123 -0
- package/src/upgrader-job.test.ts +411 -0
- package/src/upgrader-job.ts +502 -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,626 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The upgrade split (epic #696) against a fake gcloud: the converge half and
|
|
5
|
+
* the app-roll half each stay on their side, the backup is the first change
|
|
6
|
+
* and stops everything when it fails, and a failed deep health check rolls
|
|
7
|
+
* traffic back and prints the restore command.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from "bun:test";
|
|
11
|
+
import type { GcloudResult } from "../gcloud.js";
|
|
12
|
+
import type { GcloudRunner } from "../gcloud-retry.js";
|
|
13
|
+
import type { HttpFetch } from "../runtime-secrets.js";
|
|
14
|
+
import {
|
|
15
|
+
type AppRollInstall,
|
|
16
|
+
type AppRollPlan,
|
|
17
|
+
executeAppRoll,
|
|
18
|
+
planAppRoll,
|
|
19
|
+
type ReleaseImage,
|
|
20
|
+
type RollSlug,
|
|
21
|
+
} from "./app-roll.js";
|
|
22
|
+
import { restoreCommand, takeSqlBackup } from "./backup.js";
|
|
23
|
+
import { needsConverge, startConverge } from "./converge.js";
|
|
24
|
+
import type { HealthOutcome, HealthProbe } from "./health.js";
|
|
25
|
+
import type { UpgradeReporter } from "./reporter.js";
|
|
26
|
+
import { type GcpUpgradeDeps, type GcpUpgradeTarget, upgradeGcpInstall } from "./upgrade.js";
|
|
27
|
+
|
|
28
|
+
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
29
|
+
const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
|
|
30
|
+
|
|
31
|
+
const ADMIN = "caelo-production-admin-aaa";
|
|
32
|
+
const GATEWAY = "caelo-production-gateway-bbb";
|
|
33
|
+
const SQL = "caelo-production-pg-abc";
|
|
34
|
+
const BACKUP_ID = "1760000000000";
|
|
35
|
+
const DIGEST = { admin: `sha256:${"a".repeat(64)}`, gateway: `sha256:${"b".repeat(64)}` };
|
|
36
|
+
const IMAGES: Record<RollSlug, ReleaseImage> = {
|
|
37
|
+
admin: { digest: DIGEST.admin, imageRef: `registry/admin@${DIGEST.admin}` },
|
|
38
|
+
gateway: { digest: DIGEST.gateway, imageRef: `registry/gateway@${DIGEST.gateway}` },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const secretRef = (name: string, secret: string) => ({
|
|
42
|
+
name,
|
|
43
|
+
valueFrom: { secretKeyRef: { name: `caelo-production-${secret}`, key: "latest" } },
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** A described service, as `gcloud run services describe --format=json` returns it. */
|
|
47
|
+
const serviceJson = (image: string, env: unknown[]) =>
|
|
48
|
+
JSON.stringify({
|
|
49
|
+
spec: {
|
|
50
|
+
template: {
|
|
51
|
+
metadata: {
|
|
52
|
+
annotations: {
|
|
53
|
+
"run.googleapis.com/network-interfaces": JSON.stringify([
|
|
54
|
+
{ network: "caelo-production-vpc", subnetwork: "caelo-production-subnet" },
|
|
55
|
+
]),
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
spec: { containers: [{ image, env }] },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const ADMIN_JSON = serviceJson("registry/admin@sha256:old", [
|
|
64
|
+
{ name: "CAELO_FIREBASE_SITE", value: "caelo-production-site-abc123" },
|
|
65
|
+
{
|
|
66
|
+
name: "ADMIN_DATABASE_URL",
|
|
67
|
+
value: "postgresql://admin_role@10.20.0.3:5432/cms_admin?sslmode=require",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "PUBLIC_ADMIN_DATABASE_URL",
|
|
71
|
+
value: "postgresql://admin_role@10.20.0.3:5432/cms_public?sslmode=require",
|
|
72
|
+
},
|
|
73
|
+
secretRef("ADMIN_DATABASE_PASSWORD", "postgres-password"),
|
|
74
|
+
secretRef("PUBLIC_ADMIN_DATABASE_PASSWORD", "postgres-password"),
|
|
75
|
+
]);
|
|
76
|
+
const GATEWAY_JSON = serviceJson("registry/gateway@sha256:old", [
|
|
77
|
+
{
|
|
78
|
+
name: "PUBLIC_DATABASE_URL",
|
|
79
|
+
value: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
|
|
80
|
+
},
|
|
81
|
+
secretRef("PUBLIC_DATABASE_PASSWORD", "public-role-password"),
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
type Answer = GcloudResult | ((line: string) => GcloudResult);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A fake gcloud for a healthy gcp-firebase install: the first matching rule
|
|
88
|
+
* answers; anything else succeeds with `{}` (an empty IAM policy, an empty
|
|
89
|
+
* listing). Every call lands in `events` too, so tests can check the order
|
|
90
|
+
* against the non-gcloud steps.
|
|
91
|
+
*/
|
|
92
|
+
function fakeInstall(events: string[], overrides: [RegExp, Answer][] = []) {
|
|
93
|
+
const rules: [RegExp, Answer][] = [
|
|
94
|
+
...overrides,
|
|
95
|
+
[/^run services list .*caelo-production-admin/, ok(`${ADMIN}\n`)],
|
|
96
|
+
[/^run services list .*caelo-production-gateway/, ok(`${GATEWAY}\n`)],
|
|
97
|
+
[/describe (\S+) .*revisionName/, (l) => ok(`${l.split(" ")[3]}-00001-old\n`)],
|
|
98
|
+
[new RegExp(`describe ${ADMIN} .*--format=json`), ok(ADMIN_JSON)],
|
|
99
|
+
[new RegExp(`describe ${GATEWAY} .*--format=json`), ok(GATEWAY_JSON)],
|
|
100
|
+
[/describe (\S+) .*status\.url/, (l) => ok(`https://${l.split(" ")[3]}-xyz.a.run.app\n`)],
|
|
101
|
+
[/^sql instances list/, ok(`${SQL}\n`)],
|
|
102
|
+
[/^sql backups list/, ok(`${BACKUP_ID}\n`)],
|
|
103
|
+
[
|
|
104
|
+
/^secrets describe caelo-production-postgres-password .*json/,
|
|
105
|
+
ok('{"replication":{"automatic":{}}}'),
|
|
106
|
+
],
|
|
107
|
+
[/^secrets versions list/, ok("projects/acme/secrets/x/versions/1\n")],
|
|
108
|
+
[/^secrets versions access/, ok("stored-password")],
|
|
109
|
+
[/^secrets describe/, ok("name\n")],
|
|
110
|
+
[/^projects describe/, ok("42\n")],
|
|
111
|
+
[/^auth print-access-token/, ok("ya29.token\n")],
|
|
112
|
+
];
|
|
113
|
+
const calls: string[] = [];
|
|
114
|
+
const run: GcloudRunner = async (args) => {
|
|
115
|
+
const line = args.join(" ");
|
|
116
|
+
calls.push(line);
|
|
117
|
+
events.push(`gcloud ${line}`);
|
|
118
|
+
const rule = rules.find(([re]) => re.test(line));
|
|
119
|
+
if (!rule) return ok("{}");
|
|
120
|
+
const answer = rule[1];
|
|
121
|
+
return typeof answer === "function" ? answer(line) : answer;
|
|
122
|
+
};
|
|
123
|
+
return { run, calls };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Cloud SQL Admin API: every operation is done at once. */
|
|
127
|
+
const http: HttpFetch = async () => ({
|
|
128
|
+
ok: true,
|
|
129
|
+
status: 200,
|
|
130
|
+
text: async () => JSON.stringify({ name: "op-1", status: "DONE" }),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
function recordingReporter() {
|
|
134
|
+
const lines: string[] = [];
|
|
135
|
+
const reporter: UpgradeReporter = {
|
|
136
|
+
step: (label) => {
|
|
137
|
+
lines.push(`step: ${label}`);
|
|
138
|
+
return {
|
|
139
|
+
done: (m) => lines.push(`done: ${m}`),
|
|
140
|
+
failed: (m) => lines.push(`failed: ${m}`),
|
|
141
|
+
warned: (m) => lines.push(`warned: ${m}`),
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
info: (m) => lines.push(`info: ${m}`),
|
|
145
|
+
warn: (m) => lines.push(`warn: ${m}`),
|
|
146
|
+
error: (m) => lines.push(`error: ${m}`),
|
|
147
|
+
success: (m) => lines.push(`success: ${m}`),
|
|
148
|
+
};
|
|
149
|
+
return { reporter, lines };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function fakeHealth(events: string[], deep: HealthOutcome = { ok: true, detail: "fine" }) {
|
|
153
|
+
const probe: HealthProbe = {
|
|
154
|
+
baseline: async () => {
|
|
155
|
+
events.push("health baseline");
|
|
156
|
+
return { ok: true, detail: "reachable" };
|
|
157
|
+
},
|
|
158
|
+
deep: async (expect) => {
|
|
159
|
+
events.push(`health deep ${expect.version ?? "-"}`);
|
|
160
|
+
return deep;
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
return probe;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const noSleep = async () => {};
|
|
167
|
+
|
|
168
|
+
const TARGET: GcpUpgradeTarget = {
|
|
169
|
+
provider: "gcp-firebase",
|
|
170
|
+
projectId: "acme",
|
|
171
|
+
region: "europe-west1",
|
|
172
|
+
env: "production",
|
|
173
|
+
domain: "acme.com",
|
|
174
|
+
stagingHost: "staging.acme.com",
|
|
175
|
+
ownerEmail: "owner@acme.com",
|
|
176
|
+
targetTag: "0.11.0",
|
|
177
|
+
infraContractVersion: 1,
|
|
178
|
+
skipVerify: false,
|
|
179
|
+
skipHealthCheck: false,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
function upgradeDeps(
|
|
183
|
+
events: string[],
|
|
184
|
+
opts: { overrides?: [RegExp, Answer][]; deep?: HealthOutcome } = {},
|
|
185
|
+
) {
|
|
186
|
+
const gcloud = fakeInstall(events, opts.overrides);
|
|
187
|
+
const { reporter, lines } = recordingReporter();
|
|
188
|
+
const recorded: { digests: unknown[]; contract: number[] } = { digests: [], contract: [] };
|
|
189
|
+
const deps: GcpUpgradeDeps = {
|
|
190
|
+
run: gcloud.run,
|
|
191
|
+
reporter,
|
|
192
|
+
sleep: noSleep,
|
|
193
|
+
http,
|
|
194
|
+
resolveImages: async () => ({ ok: true, images: IMAGES }),
|
|
195
|
+
verifyImages: async () => {
|
|
196
|
+
events.push("verify images");
|
|
197
|
+
return true;
|
|
198
|
+
},
|
|
199
|
+
migrate: async (imageRef) => {
|
|
200
|
+
events.push(`migrate ${imageRef}`);
|
|
201
|
+
return { ok: true };
|
|
202
|
+
},
|
|
203
|
+
healthProbe: async () => ({ ok: true, probe: fakeHealth(events, opts.deep) }),
|
|
204
|
+
resolveOpsImage: async () => `registry/ops@sha256:${"c".repeat(64)}`,
|
|
205
|
+
recordDigests: async (d) => {
|
|
206
|
+
events.push("record digests");
|
|
207
|
+
recorded.digests.push(d);
|
|
208
|
+
},
|
|
209
|
+
recordInfraContractVersion: (v) => {
|
|
210
|
+
events.push("record contract");
|
|
211
|
+
recorded.contract.push(v);
|
|
212
|
+
},
|
|
213
|
+
now: () => new Date("2026-10-13T10:00:00Z"),
|
|
214
|
+
};
|
|
215
|
+
return { deps, calls: gcloud.calls, lines, recorded };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Calls that change IAM, service accounts, secrets or APIs — converge-only. */
|
|
219
|
+
const CONVERGE_WRITE =
|
|
220
|
+
/add-iam-policy-binding|remove-iam-policy-binding|set-iam-policy|service-accounts create|secrets create|secrets versions add|services enable|roles create|firewall-rules delete/;
|
|
221
|
+
/** Calls that move a service or run migrations — app-roll-only. */
|
|
222
|
+
const APP_ROLL_WRITE =
|
|
223
|
+
/^run services update |^run services update-traffic|jobs execute caelo-migrate/;
|
|
224
|
+
/** Any gcloud call that changes something. */
|
|
225
|
+
const ANY_WRITE =
|
|
226
|
+
/\b(create|update|update-traffic|delete|add-iam-policy-binding|remove-iam-policy-binding|set-iam-policy|execute|enable|add|restore)\b/;
|
|
227
|
+
|
|
228
|
+
const indexOf = (events: string[], pattern: RegExp): number => {
|
|
229
|
+
const i = events.findIndex((e) => pattern.test(e));
|
|
230
|
+
if (i < 0) throw new Error(`no event matches ${pattern}:\n${events.join("\n")}`);
|
|
231
|
+
return i;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
describe("upgradeGcpInstall — converge, then app-roll", () => {
|
|
235
|
+
it("takes the backup first, migrates before rolling, checks health, records the digests", async () => {
|
|
236
|
+
const events: string[] = [];
|
|
237
|
+
const { deps, recorded, lines } = upgradeDeps(events);
|
|
238
|
+
expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
|
|
239
|
+
|
|
240
|
+
const backup = indexOf(events, /^gcloud sql backups create/);
|
|
241
|
+
const firstConvergeWrite = indexOf(events, /^gcloud .*(add-iam-policy-binding|secrets create)/);
|
|
242
|
+
const baseline = indexOf(events, /^health baseline/);
|
|
243
|
+
const migrate = indexOf(events, /^migrate registry\/admin@/);
|
|
244
|
+
const adminRoll = indexOf(events, new RegExp(`^gcloud run services update ${ADMIN} `));
|
|
245
|
+
const gatewayRoll = indexOf(events, new RegExp(`^gcloud run services update ${GATEWAY} `));
|
|
246
|
+
const deep = indexOf(events, /^health deep 0\.11\.0/);
|
|
247
|
+
const digests = indexOf(events, /^record digests/);
|
|
248
|
+
|
|
249
|
+
// Nothing writes before the backup.
|
|
250
|
+
for (const e of events.slice(0, backup)) expect(e).not.toMatch(/^(migrate|record)/);
|
|
251
|
+
for (const e of events.slice(0, backup).filter((e) => e.startsWith("gcloud "))) {
|
|
252
|
+
expect(e.slice("gcloud ".length)).not.toMatch(ANY_WRITE);
|
|
253
|
+
}
|
|
254
|
+
expect(backup).toBeLessThan(firstConvergeWrite);
|
|
255
|
+
expect(firstConvergeWrite).toBeLessThan(baseline);
|
|
256
|
+
expect(baseline).toBeLessThan(migrate);
|
|
257
|
+
expect(migrate).toBeLessThan(adminRoll);
|
|
258
|
+
expect(adminRoll).toBeLessThan(gatewayRoll);
|
|
259
|
+
expect(gatewayRoll).toBeLessThan(deep);
|
|
260
|
+
expect(deep).toBeLessThan(digests);
|
|
261
|
+
expect(recorded.digests).toEqual([DIGEST]);
|
|
262
|
+
expect(lines).toContain(`done: Pre-upgrade backup ${BACKUP_ID} of ${SQL} taken`);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("sets up the upgrader job after migrations, before operator access keeps its caelo-mcp grant (#696)", async () => {
|
|
266
|
+
const events: string[] = [];
|
|
267
|
+
const { deps, lines } = upgradeDeps(events);
|
|
268
|
+
expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
|
|
269
|
+
expect(lines).toContain(
|
|
270
|
+
"done: Upgrader job ready (the Owner can install updates from the admin)",
|
|
271
|
+
);
|
|
272
|
+
const migrate = indexOf(events, /^migrate /);
|
|
273
|
+
const upgrader = indexOf(events, /^gcloud run jobs deploy caelo-production-upgrader /);
|
|
274
|
+
const opAccess = indexOf(
|
|
275
|
+
events,
|
|
276
|
+
/^gcloud run jobs deploy caelo-production-operator-access-sync /,
|
|
277
|
+
);
|
|
278
|
+
expect(migrate < upgrader && upgrader < opAccess).toBe(true);
|
|
279
|
+
expect(events[upgrader]).toContain(`--image=registry/ops@sha256:${"c".repeat(64)}`);
|
|
280
|
+
expect(events[opAccess]).toContain(
|
|
281
|
+
"CAELO_OPERATOR_ACCESS_MCP_SIGNERS=serviceAccount:caelo-production-upgrader@acme.iam.gserviceaccount.com",
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("without an ops image the upgrader step fails, the roll goes on, the converge is incomplete", async () => {
|
|
286
|
+
const events: string[] = [];
|
|
287
|
+
const { deps, recorded, lines } = upgradeDeps(events);
|
|
288
|
+
expect(await upgradeGcpInstall(TARGET, { ...deps, resolveOpsImage: async () => null })).toBe(
|
|
289
|
+
true,
|
|
290
|
+
);
|
|
291
|
+
expect(events.some((e) => /jobs deploy caelo-production-upgrader/.test(e))).toBe(false);
|
|
292
|
+
expect(recorded.contract).toEqual([]);
|
|
293
|
+
expect(lines.some((l) => l.includes("Upgrader job not set up"))).toBe(true);
|
|
294
|
+
// The admin keeps whatever CAELO_UPGRADER_JOB it had.
|
|
295
|
+
expect(
|
|
296
|
+
events.some((e) => /services update caelo-production-admin.*CAELO_UPGRADER_JOB=/.test(e)),
|
|
297
|
+
).toBe(false);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("verifies the ops image's signature before the upgrader runs it; unverified = not set up", async () => {
|
|
301
|
+
const events: string[] = [];
|
|
302
|
+
const { deps } = upgradeDeps(events);
|
|
303
|
+
const verified: string[] = [];
|
|
304
|
+
expect(
|
|
305
|
+
await upgradeGcpInstall(TARGET, {
|
|
306
|
+
...deps,
|
|
307
|
+
verifyImages: async (images) => {
|
|
308
|
+
verified.push(...images.map((i) => i.slug));
|
|
309
|
+
return !images.some((i) => i.slug === "ops");
|
|
310
|
+
},
|
|
311
|
+
}),
|
|
312
|
+
).toBe(true);
|
|
313
|
+
expect(verified).toContain("ops");
|
|
314
|
+
expect(events.some((e) => /jobs deploy caelo-production-upgrader/.test(e))).toBe(false);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("a failed upgrader setup keeps an existing upgrader's caelo-mcp signing grant", async () => {
|
|
318
|
+
const events: string[] = [];
|
|
319
|
+
const { deps } = upgradeDeps(events, {
|
|
320
|
+
overrides: [
|
|
321
|
+
[/^run jobs deploy caelo-production-upgrader/, fail("transient")],
|
|
322
|
+
[/^iam service-accounts describe caelo-production-upgrader@/, ok("x\n")],
|
|
323
|
+
],
|
|
324
|
+
});
|
|
325
|
+
expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
|
|
326
|
+
const opAccess = indexOf(
|
|
327
|
+
events,
|
|
328
|
+
/^gcloud run jobs deploy caelo-production-operator-access-sync /,
|
|
329
|
+
);
|
|
330
|
+
expect(events[opAccess]).toContain(
|
|
331
|
+
"CAELO_OPERATOR_ACCESS_MCP_SIGNERS=serviceAccount:caelo-production-upgrader@",
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("a failed backup stops the upgrade before any change", async () => {
|
|
336
|
+
const events: string[] = [];
|
|
337
|
+
const { deps, calls, lines, recorded } = upgradeDeps(events, {
|
|
338
|
+
overrides: [
|
|
339
|
+
[
|
|
340
|
+
/^sql backups create/,
|
|
341
|
+
fail("ERROR: (gcloud.sql.backups.insert) HTTPError 409: operation in progress"),
|
|
342
|
+
],
|
|
343
|
+
],
|
|
344
|
+
});
|
|
345
|
+
expect(await upgradeGcpInstall(TARGET, deps)).toBe(false);
|
|
346
|
+
|
|
347
|
+
const writes = calls.filter((c) => ANY_WRITE.test(c));
|
|
348
|
+
expect(writes).toEqual([
|
|
349
|
+
expect.stringMatching(/^sql backups create --instance caelo-production-pg-abc/),
|
|
350
|
+
]);
|
|
351
|
+
expect(events.some((e) => /^(migrate|health|record)/.test(e))).toBe(false);
|
|
352
|
+
expect(recorded.digests).toEqual([]);
|
|
353
|
+
expect(
|
|
354
|
+
lines.some((l) => l.startsWith("failed: Pre-upgrade backup failed") && l.includes("409")),
|
|
355
|
+
).toBe(true);
|
|
356
|
+
expect(lines.some((l) => l.includes("Nothing was changed: no migrations ran"))).toBe(true);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("a failed deep health check rolls both services back and prints the restore command", async () => {
|
|
360
|
+
const events: string[] = [];
|
|
361
|
+
const { deps, calls, lines, recorded } = upgradeDeps(events, {
|
|
362
|
+
deep: { ok: false, error: "cms_admin is missing 1 migration(s)" },
|
|
363
|
+
});
|
|
364
|
+
expect(await upgradeGcpInstall(TARGET, deps)).toBe(false);
|
|
365
|
+
|
|
366
|
+
const deep = indexOf(events, /^health deep/);
|
|
367
|
+
const rollbacks = events
|
|
368
|
+
.slice(deep)
|
|
369
|
+
.filter((e) => e.includes("update-traffic") && e.includes("--to-revisions"));
|
|
370
|
+
expect(rollbacks).toEqual([
|
|
371
|
+
expect.stringContaining(
|
|
372
|
+
`${GATEWAY} --region europe-west1 --project acme --to-revisions ${GATEWAY}-00001-old=100`,
|
|
373
|
+
),
|
|
374
|
+
expect.stringContaining(
|
|
375
|
+
`${ADMIN} --region europe-west1 --project acme --to-revisions ${ADMIN}-00001-old=100`,
|
|
376
|
+
),
|
|
377
|
+
]);
|
|
378
|
+
expect(recorded.digests).toEqual([]);
|
|
379
|
+
expect(recorded.contract).toEqual([]);
|
|
380
|
+
const restore = `gcloud sql backups restore ${BACKUP_ID} --restore-instance=${SQL} --project=acme`;
|
|
381
|
+
expect(lines.some((l) => l.startsWith("warn:") && l.includes(restore))).toBe(true);
|
|
382
|
+
// The rollback is the end: retired bindings stay until a healthy roll.
|
|
383
|
+
expect(calls.some((c) => c.includes("remove-iam-policy-binding"))).toBe(false);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("runs the keyless-provisioner step as the first converge step; its failure stops before migrations", async () => {
|
|
387
|
+
const events: string[] = [];
|
|
388
|
+
const { deps, calls } = upgradeDeps(events);
|
|
389
|
+
const ok = await upgradeGcpInstall(TARGET, {
|
|
390
|
+
...deps,
|
|
391
|
+
provisionerIdentity: async () => {
|
|
392
|
+
events.push("provisioner identity");
|
|
393
|
+
return false;
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
expect(ok).toBe(false);
|
|
397
|
+
expect(indexOf(events, /^gcloud sql backups create/)).toBeLessThan(
|
|
398
|
+
indexOf(events, /^provisioner identity/),
|
|
399
|
+
);
|
|
400
|
+
expect(events.some((e) => e.startsWith("migrate"))).toBe(false);
|
|
401
|
+
expect(calls.filter((c) => CONVERGE_WRITE.test(c) || APP_ROLL_WRITE.test(c))).toEqual([]);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("a deep health probe that throws still rolls back", async () => {
|
|
405
|
+
const events: string[] = [];
|
|
406
|
+
const { deps, recorded } = upgradeDeps(events);
|
|
407
|
+
const throwing: HealthProbe = {
|
|
408
|
+
baseline: async () => ({ ok: true, detail: "" }),
|
|
409
|
+
deep: async () => {
|
|
410
|
+
throw new Error("socket hang up");
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
expect(
|
|
414
|
+
await upgradeGcpInstall(TARGET, {
|
|
415
|
+
...deps,
|
|
416
|
+
healthProbe: async () => ({ ok: true, probe: throwing }),
|
|
417
|
+
}),
|
|
418
|
+
).toBe(false);
|
|
419
|
+
expect(events.filter((e) => e.includes("--to-revisions"))).toHaveLength(2);
|
|
420
|
+
expect(recorded.digests).toEqual([]);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("a health baseline that can't reach the install stops before the migrations", async () => {
|
|
424
|
+
const events: string[] = [];
|
|
425
|
+
const { deps, calls } = upgradeDeps(events);
|
|
426
|
+
const unreachable: HealthProbe = {
|
|
427
|
+
baseline: async () => ({ ok: false, error: "admin unreachable at https://admin.acme.com" }),
|
|
428
|
+
deep: async () => ({ ok: true, detail: "" }),
|
|
429
|
+
};
|
|
430
|
+
expect(
|
|
431
|
+
await upgradeGcpInstall(TARGET, {
|
|
432
|
+
...deps,
|
|
433
|
+
healthProbe: async () => ({ ok: true, probe: unreachable }),
|
|
434
|
+
}),
|
|
435
|
+
).toBe(false);
|
|
436
|
+
expect(events.some((e) => e.startsWith("migrate"))).toBe(false);
|
|
437
|
+
expect(calls.some((c) => APP_ROLL_WRITE.test(c))).toBe(false);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("--skip-health-check rolls without a probe", async () => {
|
|
441
|
+
const events: string[] = [];
|
|
442
|
+
const { deps } = upgradeDeps(events);
|
|
443
|
+
let asked = false;
|
|
444
|
+
const ok = await upgradeGcpInstall(
|
|
445
|
+
{ ...TARGET, skipHealthCheck: true },
|
|
446
|
+
{
|
|
447
|
+
...deps,
|
|
448
|
+
healthProbe: async () => {
|
|
449
|
+
asked = true;
|
|
450
|
+
return { ok: false, error: "never" };
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
);
|
|
454
|
+
expect(ok).toBe(true);
|
|
455
|
+
expect(asked).toBe(false);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const INSTALL: AppRollInstall = {
|
|
460
|
+
provider: "gcp-firebase",
|
|
461
|
+
projectId: "acme",
|
|
462
|
+
region: "europe-west1",
|
|
463
|
+
env: "production",
|
|
464
|
+
domain: "acme.com",
|
|
465
|
+
stagingHost: "staging.acme.com",
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
async function plannedRoll(run: GcloudRunner): Promise<AppRollPlan> {
|
|
469
|
+
const planned = await planAppRoll(INSTALL, IMAGES, run);
|
|
470
|
+
if (!planned.ok) throw new Error(planned.error);
|
|
471
|
+
return planned.plan;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const BACKUP = { projectId: "acme", instance: SQL, backupId: BACKUP_ID };
|
|
475
|
+
|
|
476
|
+
describe("app-roll and converge stay on their side", () => {
|
|
477
|
+
it("app-roll alone (the upgrader job's path) never changes IAM, secrets or APIs", async () => {
|
|
478
|
+
const events: string[] = [];
|
|
479
|
+
const { run, calls } = fakeInstall(events);
|
|
480
|
+
const plan = await plannedRoll(run);
|
|
481
|
+
const result = await executeAppRoll(
|
|
482
|
+
{ plan, backup: BACKUP, targetVersion: "0.11.0", migrationsContract: false },
|
|
483
|
+
{},
|
|
484
|
+
{
|
|
485
|
+
run,
|
|
486
|
+
sleep: noSleep,
|
|
487
|
+
reporter: recordingReporter().reporter,
|
|
488
|
+
migrate: async () => ({ ok: true }),
|
|
489
|
+
health: fakeHealth(events),
|
|
490
|
+
resolveOpsImage: async () => `registry/ops@sha256:${"c".repeat(64)}`,
|
|
491
|
+
recordDigests: async () => {},
|
|
492
|
+
},
|
|
493
|
+
);
|
|
494
|
+
expect(result).toEqual({ ok: true, digests: DIGEST });
|
|
495
|
+
expect(calls.filter((c) => CONVERGE_WRITE.test(c))).toEqual([]);
|
|
496
|
+
expect(calls.filter((c) => /^run services update /.test(c))).toHaveLength(2);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("converge alone never rolls a service or runs migrations", async () => {
|
|
500
|
+
const events: string[] = [];
|
|
501
|
+
const { run, calls } = fakeInstall(events);
|
|
502
|
+
const plan = await plannedRoll(run);
|
|
503
|
+
const session = startConverge(plan, "owner@acme.com", {
|
|
504
|
+
run,
|
|
505
|
+
sleep: noSleep,
|
|
506
|
+
http,
|
|
507
|
+
reporter: recordingReporter().reporter,
|
|
508
|
+
});
|
|
509
|
+
expect(await session.beforeAppRoll()).toBe(true);
|
|
510
|
+
const between = await session.hooks.afterMigrations?.();
|
|
511
|
+
expect(between?.ok).toBe(true);
|
|
512
|
+
expect((await session.hooks.beforeRoll?.("gateway"))?.ok).toBe(true);
|
|
513
|
+
await session.afterAppRoll();
|
|
514
|
+
expect(calls.filter((c) => APP_ROLL_WRITE.test(c))).toEqual([]);
|
|
515
|
+
// It did converge something: the stack's IAM bindings, and the API the
|
|
516
|
+
// health check signs its IAP credential through.
|
|
517
|
+
expect(calls.some((c) => CONVERGE_WRITE.test(c))).toBe(true);
|
|
518
|
+
expect(calls).toContain("services enable iamcredentials.googleapis.com --project=acme");
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it("a failed gateway roll goes back to the prior admin revision and undoes the gateway hook", async () => {
|
|
522
|
+
const events: string[] = [];
|
|
523
|
+
const { run, calls } = fakeInstall(events, [
|
|
524
|
+
[new RegExp(`^run services update ${GATEWAY} `), fail("Revision failed to become ready")],
|
|
525
|
+
]);
|
|
526
|
+
const plan = await plannedRoll(run);
|
|
527
|
+
const undone: string[] = [];
|
|
528
|
+
const result = await executeAppRoll(
|
|
529
|
+
{ plan, backup: BACKUP, targetVersion: null, migrationsContract: null },
|
|
530
|
+
{ afterRollback: async (slug) => void undone.push(slug) },
|
|
531
|
+
{
|
|
532
|
+
run,
|
|
533
|
+
sleep: noSleep,
|
|
534
|
+
reporter: recordingReporter().reporter,
|
|
535
|
+
migrate: async () => ({ ok: true }),
|
|
536
|
+
health: fakeHealth(events),
|
|
537
|
+
resolveOpsImage: async () => `registry/ops@sha256:${"c".repeat(64)}`,
|
|
538
|
+
recordDigests: async () => {
|
|
539
|
+
throw new Error("must not record");
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
);
|
|
543
|
+
expect(result).toEqual({ ok: false, phase: "roll", migrated: true });
|
|
544
|
+
expect(calls).toContain(
|
|
545
|
+
`run services update-traffic ${ADMIN} --region europe-west1 --project acme --to-revisions ${ADMIN}-00001-old=100 --quiet`,
|
|
546
|
+
);
|
|
547
|
+
expect(undone).toEqual(["gateway", "admin"]);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it("a migration failure moves no traffic and names the backup", async () => {
|
|
551
|
+
const events: string[] = [];
|
|
552
|
+
const { run, calls } = fakeInstall(events);
|
|
553
|
+
const plan = await plannedRoll(run);
|
|
554
|
+
const { reporter, lines } = recordingReporter();
|
|
555
|
+
const result = await executeAppRoll(
|
|
556
|
+
{ plan, backup: BACKUP, targetVersion: null, migrationsContract: true },
|
|
557
|
+
{},
|
|
558
|
+
{
|
|
559
|
+
run,
|
|
560
|
+
reporter,
|
|
561
|
+
migrate: async () => ({ ok: false, error: "migrate-admin-exec: exit 1" }),
|
|
562
|
+
health: fakeHealth(events),
|
|
563
|
+
resolveOpsImage: async () => `registry/ops@sha256:${"c".repeat(64)}`,
|
|
564
|
+
recordDigests: async () => {},
|
|
565
|
+
},
|
|
566
|
+
);
|
|
567
|
+
expect(result).toEqual({ ok: false, phase: "migrations", migrated: true });
|
|
568
|
+
expect(calls.filter((c) => APP_ROLL_WRITE.test(c))).toEqual([]);
|
|
569
|
+
expect(
|
|
570
|
+
lines.some((l) => l.includes("contract-type") && l.includes(restoreCommand(BACKUP))),
|
|
571
|
+
).toBe(true);
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
describe("needsConverge", () => {
|
|
576
|
+
it("is true for an install that never recorded a contract version", () => {
|
|
577
|
+
expect(needsConverge({}, 1)).toBe(true);
|
|
578
|
+
});
|
|
579
|
+
it("is true while the recorded version is behind the release's", () => {
|
|
580
|
+
expect(needsConverge({ infraContractVersion: 1 }, 2)).toBe(true);
|
|
581
|
+
});
|
|
582
|
+
it("is false once the install is at (or past) the release's version", () => {
|
|
583
|
+
expect(needsConverge({ infraContractVersion: 2 }, 2)).toBe(false);
|
|
584
|
+
expect(needsConverge({ infraContractVersion: 3 }, 2)).toBe(false);
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
describe("takeSqlBackup", () => {
|
|
589
|
+
it("backs up a gcp-firebase install's Cloud SQL instance and returns the backup id", async () => {
|
|
590
|
+
const events: string[] = [];
|
|
591
|
+
const { run, calls } = fakeInstall(events);
|
|
592
|
+
const r = await takeSqlBackup(
|
|
593
|
+
run,
|
|
594
|
+
{ projectId: "acme", env: "production" },
|
|
595
|
+
"backup",
|
|
596
|
+
() => new Date("2026-10-13T10:00:00Z"),
|
|
597
|
+
);
|
|
598
|
+
expect(r).toEqual({ ok: true, backup: BACKUP });
|
|
599
|
+
expect(calls).toEqual([
|
|
600
|
+
"sql instances list --project acme --filter name~^caelo-production-pg --format=value(name)",
|
|
601
|
+
"sql backups create --instance caelo-production-pg-abc --project acme --description caelo-cms backup 2026-10-13T10:00:00.000Z",
|
|
602
|
+
'sql backups list --instance caelo-production-pg-abc --project acme --filter=description="caelo-cms backup 2026-10-13T10:00:00.000Z" --format=value(id)',
|
|
603
|
+
]);
|
|
604
|
+
expect(restoreCommand(BACKUP)).toBe(
|
|
605
|
+
`gcloud sql backups restore ${BACKUP_ID} --restore-instance=${SQL} --project=acme`,
|
|
606
|
+
);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("fails loudly when the backup's id can't be found", async () => {
|
|
610
|
+
const { run } = fakeInstall([], [[/^sql backups list/, ok("")]]);
|
|
611
|
+
const r = await takeSqlBackup(run, { projectId: "acme", env: "production" }, "backup");
|
|
612
|
+
expect(r.ok).toBe(false);
|
|
613
|
+
if (!r.ok)
|
|
614
|
+
expect(r.error).toContain("gcloud sql backups list --instance=caelo-production-pg-abc");
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it("fails when the install has no Cloud SQL instance", async () => {
|
|
618
|
+
const { run } = fakeInstall([], [[/^sql instances list/, ok("")]]);
|
|
619
|
+
const r = await takeSqlBackup(run, { projectId: "acme", env: "production" }, "backup");
|
|
620
|
+
expect(r).toEqual({
|
|
621
|
+
ok: false,
|
|
622
|
+
error:
|
|
623
|
+
"no caelo-production-pg* Cloud SQL instance in project acme (or listing it was denied)",
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
});
|