@caelo-cms/provisioning 0.10.55 → 0.10.58
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 +97 -24
- package/dist/cli-routing.js +1 -1
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +4 -1
- 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 +9 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +13 -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 +33 -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 +4 -0
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +43 -7
- package/dist/operator-access.js.map +1 -1
- package/dist/release-contract.d.ts +3 -2
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +5 -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 +139 -0
- package/dist/remote-mcp.d.ts.map +1 -0
- package/dist/remote-mcp.js +434 -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 +206 -3
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +219 -4
- package/dist/stack-contract.js.map +1 -1
- package/dist/stack-converge.d.ts +53 -1
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +124 -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/upgrade/app-roll.d.ts +36 -7
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +53 -19
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/audit-job-roll.d.ts.map +1 -1
- package/dist/upgrade/audit-job-roll.js +3 -1
- package/dist/upgrade/audit-job-roll.js.map +1 -1
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +125 -12
- 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 +123 -11
- package/dist/upgrader-setup.js.map +1 -1
- package/dist/wizards/gcp-cost.d.ts.map +1 -1
- package/dist/wizards/gcp-cost.js +11 -3
- 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 +14 -2
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +106 -4
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/agent/run.test.ts +14 -1
- package/src/audit-job-setup.test.ts +7 -1
- package/src/cli-routing.ts +1 -1
- package/src/cli.ts +4 -1
- 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 +15 -0
- package/src/gcp-url-map.test.ts +9 -1
- package/src/index.ts +8 -0
- package/src/infra-contract.test.ts +8 -4
- package/src/infra-contract.ts +33 -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 +24 -1
- package/src/operator-access.ts +44 -6
- package/src/release-contract.ts +5 -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 +690 -0
- package/src/remote-mcp.ts +524 -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 +32 -2
- package/src/stack-contract.ts +363 -4
- package/src/stack-converge-secrets.test.ts +4 -0
- package/src/stack-converge.test.ts +21 -5
- package/src/stack-converge.ts +162 -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/upgrade/app-roll.ts +102 -21
- package/src/upgrade/audit-job-roll.ts +3 -1
- package/src/upgrade/converge.ts +148 -10
- 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 +203 -3
- package/src/upgrade/upgrade.ts +120 -24
- package/src/upgrader-assess.test.ts +92 -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 +586 -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 +3 -3
- package/src/upgrader-setup.ts +128 -11
- package/src/wizards/gcp-cost.ts +12 -3
- package/src/wizards/gcp-pulumi.test.ts +15 -0
- package/src/wizards/gcp-pulumi.ts +18 -2
- package/src/wizards/gcp.ts +134 -4
- package/stacks/aws/index.ts +10 -0
- package/stacks/azure/index.ts +9 -0
- package/stacks/gcp/index.ts +47 -7
- package/stacks/gcp-firebase/index.ts +38 -6
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #883 review — the converge job runs as caelo-provisioner, so an
|
|
5
|
+
* execution override must be useless even if someone held
|
|
6
|
+
* run.jobs.runWithOverrides:
|
|
7
|
+
*
|
|
8
|
+
* - the job's entry point (ops/caelo-converge-job) refuses every argument
|
|
9
|
+
* but `converge-job` before any code runs;
|
|
10
|
+
* - it starts the job with an empty environment plus a fixed allowlist,
|
|
11
|
+
* so CLOUDSDK_*, PULUMI_*, BUN_*, LD_* overrides never reach gcloud,
|
|
12
|
+
* Pulumi or Bun;
|
|
13
|
+
* - and nobody is granted a role that carries runWithOverrides: not the
|
|
14
|
+
* upgrader (anywhere), not the admin (on the converge or upgrader job).
|
|
15
|
+
*
|
|
16
|
+
* The script runs here under /bin/sh with its final `bun …` target swapped
|
|
17
|
+
* for `env`, so the test sees exactly the environment the job would get.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, expect, it } from "bun:test";
|
|
21
|
+
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { join, resolve } from "node:path";
|
|
24
|
+
import { CONVERGE_ADMIN_ROLES, convergeJobDeployArgs } from "./converge-job-setup.js";
|
|
25
|
+
import type { GcloudResult } from "./gcloud.js";
|
|
26
|
+
import {
|
|
27
|
+
ensureUpgraderJob,
|
|
28
|
+
RUN_WITH_OVERRIDES_ROLES,
|
|
29
|
+
UPGRADER_FORBIDDEN_ROLES,
|
|
30
|
+
UPGRADER_PROJECT_ROLES,
|
|
31
|
+
UPGRADER_RUN_ROLE,
|
|
32
|
+
} from "./upgrader-setup.js";
|
|
33
|
+
|
|
34
|
+
const SCRIPT = resolve(import.meta.dir, "..", "ops", "caelo-converge-job");
|
|
35
|
+
const TARGET_LINE = "bun /opt/caelo-ops/packages/platform-updater/src/converge-main.ts";
|
|
36
|
+
|
|
37
|
+
/** The entry point with its final target replaced by `env` (prints the environment). */
|
|
38
|
+
function scriptPrintingEnv(dir: string): string {
|
|
39
|
+
const text = readFileSync(SCRIPT, "utf8");
|
|
40
|
+
expect(text.split(TARGET_LINE).length).toBe(2);
|
|
41
|
+
const path = join(dir, "caelo-converge-job");
|
|
42
|
+
writeFileSync(path, text.replace(TARGET_LINE, "env"));
|
|
43
|
+
chmodSync(path, 0o755);
|
|
44
|
+
return path;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function runScript(path: string, args: string[], env: Record<string, string>) {
|
|
48
|
+
const r = Bun.spawnSync(["/bin/sh", path, ...args], {
|
|
49
|
+
env,
|
|
50
|
+
stdout: "pipe",
|
|
51
|
+
stderr: "pipe",
|
|
52
|
+
});
|
|
53
|
+
return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("ops/caelo-converge-job (the converge job's entry point)", () => {
|
|
57
|
+
it("refuses every argument list but exactly `converge-job`, before any code runs", () => {
|
|
58
|
+
const dir = mkdtempSync(join(tmpdir(), "caelo-entry-"));
|
|
59
|
+
try {
|
|
60
|
+
const path = scriptPrintingEnv(dir);
|
|
61
|
+
for (const args of [
|
|
62
|
+
[],
|
|
63
|
+
["destroy"],
|
|
64
|
+
["owner-reset", "--email", "evil@example.com"],
|
|
65
|
+
["rotate-secret"],
|
|
66
|
+
["converge-job", "--extra"],
|
|
67
|
+
["upgrader"],
|
|
68
|
+
]) {
|
|
69
|
+
const r = runScript(path, args, { PATH: "/usr/bin:/bin" });
|
|
70
|
+
expect(r.code).toBe(64);
|
|
71
|
+
expect(r.err).toContain("refused");
|
|
72
|
+
expect(r.out).toBe("");
|
|
73
|
+
}
|
|
74
|
+
} finally {
|
|
75
|
+
rmSync(dir, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("starts the job with a scrubbed environment: overrides never reach gcloud, Pulumi or Bun", () => {
|
|
80
|
+
const dir = mkdtempSync(join(tmpdir(), "caelo-entry-"));
|
|
81
|
+
try {
|
|
82
|
+
const r = runScript(scriptPrintingEnv(dir), ["converge-job"], {
|
|
83
|
+
PATH: "/usr/bin:/bin",
|
|
84
|
+
CLOUDSDK_API_ENDPOINT_OVERRIDES_RUN: "https://evil.example/",
|
|
85
|
+
CLOUDSDK_AUTH_ACCESS_TOKEN: "stolen",
|
|
86
|
+
PULUMI_BACKEND_URL: "gs://evil",
|
|
87
|
+
PULUMI_CONFIG_PASSPHRASE: "x",
|
|
88
|
+
BUN_OPTIONS: "--preload=/tmp/x.ts",
|
|
89
|
+
LD_PRELOAD: "/tmp/x.so",
|
|
90
|
+
NODE_OPTIONS: "--require=/tmp/x.js",
|
|
91
|
+
CLOUD_RUN_EXECUTION: "caelo-production-converge-abc12",
|
|
92
|
+
CLOUD_RUN_JOB: "caelo-production-converge",
|
|
93
|
+
});
|
|
94
|
+
expect(r.code).toBe(0);
|
|
95
|
+
const env = Object.fromEntries(
|
|
96
|
+
r.out
|
|
97
|
+
.trim()
|
|
98
|
+
.split("\n")
|
|
99
|
+
.map((l) => [l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1)]),
|
|
100
|
+
);
|
|
101
|
+
expect(Object.keys(env).sort()).toEqual(
|
|
102
|
+
[
|
|
103
|
+
"CLOUDSDK_COMPONENT_MANAGER_DISABLE_UPDATE_CHECK",
|
|
104
|
+
"CLOUDSDK_CORE_DISABLE_PROMPTS",
|
|
105
|
+
"CLOUD_RUN_EXECUTION",
|
|
106
|
+
"CLOUD_RUN_JOB",
|
|
107
|
+
"CLOUD_RUN_TASK_ATTEMPT",
|
|
108
|
+
"CLOUD_RUN_TASK_COUNT",
|
|
109
|
+
"CLOUD_RUN_TASK_INDEX",
|
|
110
|
+
"HOME",
|
|
111
|
+
"PATH",
|
|
112
|
+
"PULUMI_SKIP_UPDATE_CHECK",
|
|
113
|
+
].sort(),
|
|
114
|
+
);
|
|
115
|
+
expect(env.CLOUD_RUN_EXECUTION).toBe("caelo-production-converge-abc12");
|
|
116
|
+
expect(env.PATH).toBe("/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin");
|
|
117
|
+
} finally {
|
|
118
|
+
rmSync(dir, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("the deployed job runs exactly that entry point with that one argument", () => {
|
|
123
|
+
const args = convergeJobDeployArgs({
|
|
124
|
+
projectId: "acme",
|
|
125
|
+
region: "europe-west1",
|
|
126
|
+
env: "production",
|
|
127
|
+
opsImageRef: `x/ops@sha256:${"c".repeat(64)}`,
|
|
128
|
+
network: "n",
|
|
129
|
+
subnet: "s",
|
|
130
|
+
});
|
|
131
|
+
expect(args).toContain("--command=caelo-converge-job");
|
|
132
|
+
expect(args).toContain("--args=converge-job");
|
|
133
|
+
expect(args).toContain("--clear-env-vars");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("no upgrader or admin role carries run.jobs.runWithOverrides (#883 review)", () => {
|
|
138
|
+
it("the forbidden list covers every predefined role that does", () => {
|
|
139
|
+
for (const role of [
|
|
140
|
+
"roles/run.developer",
|
|
141
|
+
"roles/run.admin",
|
|
142
|
+
"roles/run.invoker",
|
|
143
|
+
"roles/run.jobsExecutorWithOverrides",
|
|
144
|
+
"roles/editor",
|
|
145
|
+
"roles/owner",
|
|
146
|
+
]) {
|
|
147
|
+
expect(RUN_WITH_OVERRIDES_ROLES).toContain(role as (typeof RUN_WITH_OVERRIDES_ROLES)[number]);
|
|
148
|
+
expect(UPGRADER_FORBIDDEN_ROLES.some((f) => role.startsWith(f))).toBe(true);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("the upgrader's own Cloud Run role leaves it out", () => {
|
|
153
|
+
expect(UPGRADER_RUN_ROLE.permissions).not.toContain("run.jobs.runWithOverrides");
|
|
154
|
+
expect(UPGRADER_RUN_ROLE.permissions.some((p) => /setIamPolicy|actAs/.test(p))).toBe(false);
|
|
155
|
+
for (const role of UPGRADER_PROJECT_ROLES) {
|
|
156
|
+
expect(RUN_WITH_OVERRIDES_ROLES as readonly string[]).not.toContain(role);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("setup grants the upgrader no such role, and removes the run.developer an earlier setup gave it", async () => {
|
|
161
|
+
const calls: string[][] = [];
|
|
162
|
+
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
163
|
+
const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
|
|
164
|
+
const upgrader = "serviceAccount:caelo-production-upgrader@acme.iam.gserviceaccount.com";
|
|
165
|
+
const rules: [RegExp, GcloudResult][] = [
|
|
166
|
+
[/^iam roles describe/, fail("NOT_FOUND")],
|
|
167
|
+
[/^iam service-accounts describe/, ok("x")],
|
|
168
|
+
[
|
|
169
|
+
/^projects get-iam-policy/,
|
|
170
|
+
ok(JSON.stringify({ bindings: [{ role: "roles/run.developer", members: [upgrader] }] })),
|
|
171
|
+
],
|
|
172
|
+
[/^sql instances list/, ok("caelo-production-pg-abc\n")],
|
|
173
|
+
[/^sql instances describe/, ok('{"settings":{"databaseFlags":[]}}')],
|
|
174
|
+
[/^sql users list/, ok("")],
|
|
175
|
+
];
|
|
176
|
+
const run = async (args: string[]) => {
|
|
177
|
+
calls.push(args);
|
|
178
|
+
return rules.find(([re]) => re.test(args.join(" ")))?.[1] ?? ok("");
|
|
179
|
+
};
|
|
180
|
+
const r = await ensureUpgraderJob(
|
|
181
|
+
{
|
|
182
|
+
projectId: "acme",
|
|
183
|
+
region: "europe-west1",
|
|
184
|
+
env: "production",
|
|
185
|
+
opsImageRef: `x/ops@sha256:${"c".repeat(64)}`,
|
|
186
|
+
network: "n",
|
|
187
|
+
subnet: "s",
|
|
188
|
+
},
|
|
189
|
+
{ run, sleep: async () => {} },
|
|
190
|
+
);
|
|
191
|
+
expect(r.ok).toBe(true);
|
|
192
|
+
const roleOf = (c: string[]) => c.find((a) => a.startsWith("--role="))?.slice(7) ?? "";
|
|
193
|
+
const granted = calls.filter((c) => c.includes("add-iam-policy-binding")).map(roleOf);
|
|
194
|
+
for (const role of granted) {
|
|
195
|
+
expect(RUN_WITH_OVERRIDES_ROLES as readonly string[]).not.toContain(role);
|
|
196
|
+
}
|
|
197
|
+
const removed = calls.filter((c) => c.includes("remove-iam-policy-binding"));
|
|
198
|
+
expect(removed.map((c) => `${roleOf(c)} ${c.find((a) => a.startsWith("--member="))}`)).toEqual([
|
|
199
|
+
`roles/run.developer --member=${upgrader}`,
|
|
200
|
+
]);
|
|
201
|
+
// The custom role is bound before the old one is removed.
|
|
202
|
+
const bindRun = calls.findIndex((c) => roleOf(c) === "projects/acme/roles/caeloUpgraderRun");
|
|
203
|
+
const removeDev = calls.findIndex((c) => c.includes("remove-iam-policy-binding"));
|
|
204
|
+
expect(bindRun).toBeGreaterThan(-1);
|
|
205
|
+
expect(bindRun).toBeLessThan(removeDev);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("the admin's roles on the converge job carry none", () => {
|
|
209
|
+
for (const role of CONVERGE_ADMIN_ROLES) {
|
|
210
|
+
expect(RUN_WITH_OVERRIDES_ROLES as readonly string[]).not.toContain(role);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The parts of the converge job's Google Cloud wiring (#819) that parse
|
|
5
|
+
* what gcloud says — which image an execution runs, which execution a
|
|
6
|
+
* hand-over started — and its checkpoint document in the ops bucket.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
type ConvergeCheckpoint,
|
|
12
|
+
convergeCheckpointUrl,
|
|
13
|
+
readConvergeCheckpoint,
|
|
14
|
+
writeConvergeCheckpoint,
|
|
15
|
+
} from "./converge-checkpoint.js";
|
|
16
|
+
import {
|
|
17
|
+
convergeExecutionNameError,
|
|
18
|
+
deployedOwnerMismatch,
|
|
19
|
+
deployedSiteMismatch,
|
|
20
|
+
executionImageDigest,
|
|
21
|
+
startedExecutionName,
|
|
22
|
+
} from "./converge-job-gcp.js";
|
|
23
|
+
import type { GcloudResult } from "./gcloud.js";
|
|
24
|
+
import type { SyncGcloud } from "./state-store.js";
|
|
25
|
+
import { installStateCondition } from "./upgrader-setup.js";
|
|
26
|
+
|
|
27
|
+
const D = `sha256:${"c".repeat(64)}`;
|
|
28
|
+
const OPS = `europe-west1-docker.pkg.dev/caelo-website/caelo-cms-images/ops@${D}`;
|
|
29
|
+
|
|
30
|
+
describe("executionImageDigest", () => {
|
|
31
|
+
it("reads the digest from gcloud's v1 execution (and the v2 shape)", () => {
|
|
32
|
+
const v1 = { spec: { template: { spec: { containers: [{ image: OPS }] } } } };
|
|
33
|
+
expect(executionImageDigest(JSON.stringify(v1))).toBe(D);
|
|
34
|
+
expect(
|
|
35
|
+
executionImageDigest(JSON.stringify({ template: { containers: [{ image: OPS }] } })),
|
|
36
|
+
).toBe(D);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("is null for a tag, no container or unreadable output — never a guess", () => {
|
|
40
|
+
const tag = { spec: { template: { spec: { containers: [{ image: "x/ops:0.11.0" }] } } } };
|
|
41
|
+
expect(executionImageDigest(JSON.stringify(tag))).toBeNull();
|
|
42
|
+
expect(executionImageDigest("{}")).toBeNull();
|
|
43
|
+
expect(executionImageDigest("not json")).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("startedExecutionName", () => {
|
|
48
|
+
it("takes the name gcloud prints, or the one it announces on stderr", () => {
|
|
49
|
+
expect(startedExecutionName("caelo-production-converge-x7k2p\n", "")).toBe(
|
|
50
|
+
"caelo-production-converge-x7k2p",
|
|
51
|
+
);
|
|
52
|
+
expect(
|
|
53
|
+
startedExecutionName(
|
|
54
|
+
"",
|
|
55
|
+
"Execution [caelo-production-converge-q1w2e] has successfully started",
|
|
56
|
+
),
|
|
57
|
+
).toBe("caelo-production-converge-q1w2e");
|
|
58
|
+
expect(startedExecutionName("", "")).toBeNull();
|
|
59
|
+
expect(startedExecutionName("Bad Name; rm -rf", "")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("the converge checkpoint", () => {
|
|
64
|
+
const BUCKET = "acme-caelo-ops";
|
|
65
|
+
const fakeBucket = () => {
|
|
66
|
+
const objects = new Map<string, string>();
|
|
67
|
+
const run: SyncGcloud = (args, input): GcloudResult => {
|
|
68
|
+
if (args[1] === "cat") {
|
|
69
|
+
const v = objects.get(args[2] as string);
|
|
70
|
+
return v === undefined
|
|
71
|
+
? { ok: false, stdout: "", stderr: "One or more URLs matched no objects.", exitCode: 1 }
|
|
72
|
+
: { ok: true, stdout: v, stderr: "", exitCode: 0 };
|
|
73
|
+
}
|
|
74
|
+
objects.set(args[3] as string, input ?? "");
|
|
75
|
+
return { ok: true, stdout: "", stderr: "", exitCode: 0 };
|
|
76
|
+
};
|
|
77
|
+
return { run, objects };
|
|
78
|
+
};
|
|
79
|
+
const CHECKPOINT: ConvergeCheckpoint = {
|
|
80
|
+
statusVersion: 1,
|
|
81
|
+
installId: "gcp-acme",
|
|
82
|
+
execution: "caelo-production-converge-x7k2p",
|
|
83
|
+
state: "running",
|
|
84
|
+
phase: "converge",
|
|
85
|
+
caeloVersion: "0.11.0",
|
|
86
|
+
startedAt: "2026-10-19T12:00:00Z",
|
|
87
|
+
updatedAt: "2026-10-19T12:05:00Z",
|
|
88
|
+
targetVersion: "0.11.0",
|
|
89
|
+
fromVersion: "0.10.53",
|
|
90
|
+
requestId: "11111111-2222-4333-8444-555555555555",
|
|
91
|
+
backup: { projectId: "acme", instance: "caelo-production-pg-x", backupId: "1760000000" },
|
|
92
|
+
priorRevisions: { admin: "a-00001", gateway: "g-00001" },
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
it("lives outside install/, where the upgrader may write, and round-trips (#883 review)", () => {
|
|
96
|
+
const { run, objects } = fakeBucket();
|
|
97
|
+
expect(readConvergeCheckpoint(BUCKET, run)).toBeNull();
|
|
98
|
+
writeConvergeCheckpoint(BUCKET, CHECKPOINT, run);
|
|
99
|
+
expect([...objects.keys()]).toEqual(["gs://acme-caelo-ops/converge/converge-job.json"]);
|
|
100
|
+
expect(convergeCheckpointUrl(BUCKET)).toBe("gs://acme-caelo-ops/converge/converge-job.json");
|
|
101
|
+
expect(readConvergeCheckpoint(BUCKET, run)).toEqual(CHECKPOINT);
|
|
102
|
+
// The upgrader's only object grant on the bucket is conditioned on
|
|
103
|
+
// install/ — the checkpoint is not under it.
|
|
104
|
+
const condition = installStateCondition(BUCKET);
|
|
105
|
+
const prefix = /objects\/([^"]+)"/.exec(condition)?.[1] ?? "";
|
|
106
|
+
expect(prefix).toBe("install/");
|
|
107
|
+
expect(convergeCheckpointUrl(BUCKET).startsWith(`gs://${BUCKET}/${prefix}`)).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("refuses a document it does not understand", () => {
|
|
111
|
+
const { run, objects } = fakeBucket();
|
|
112
|
+
objects.set(convergeCheckpointUrl(BUCKET), JSON.stringify({ ...CHECKPOINT, fromVersion: "x" }));
|
|
113
|
+
expect(() => readConvergeCheckpoint(BUCKET, run)).toThrow(/not a job status/);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("deployedOwnerMismatch (#883 review)", () => {
|
|
118
|
+
const job = (members: string | null) =>
|
|
119
|
+
JSON.stringify({
|
|
120
|
+
spec: {
|
|
121
|
+
template: {
|
|
122
|
+
spec: {
|
|
123
|
+
template: {
|
|
124
|
+
spec: {
|
|
125
|
+
containers: [
|
|
126
|
+
{
|
|
127
|
+
env: [
|
|
128
|
+
{ name: "CAELO_OPERATOR_ACCESS_IAP_WEB", value: "x" },
|
|
129
|
+
...(members === null
|
|
130
|
+
? []
|
|
131
|
+
: [{ name: "CAELO_OPERATOR_ACCESS_STATIC_MEMBERS", value: members }]),
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("accepts install.json's Owner when the deployed IAP members are exactly it", () => {
|
|
143
|
+
expect(deployedOwnerMismatch(job("user:owner@acme.com"), "Owner@Acme.com")).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("refuses an Owner email the deployed access settings do not admit (a tampered install.json)", () => {
|
|
147
|
+
const why = deployedOwnerMismatch(job("user:owner@acme.com"), "attacker@evil.example");
|
|
148
|
+
expect(why).toContain("user:owner@acme.com");
|
|
149
|
+
expect(why).toContain("user:attacker@evil.example");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("refuses when the deployed members cannot be read — never assumes they match", () => {
|
|
153
|
+
expect(deployedOwnerMismatch(job(null), "owner@acme.com")).toContain("sets no");
|
|
154
|
+
expect(deployedOwnerMismatch("not json", "owner@acme.com")).toContain("not JSON");
|
|
155
|
+
expect(
|
|
156
|
+
deployedOwnerMismatch(job("user:owner@acme.com,user:x@y.z"), "owner@acme.com"),
|
|
157
|
+
).not.toBeNull();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("deployedSiteMismatch (#883 review, round 2)", () => {
|
|
162
|
+
const admin = (env: Record<string, string>) =>
|
|
163
|
+
JSON.stringify({
|
|
164
|
+
spec: {
|
|
165
|
+
template: {
|
|
166
|
+
spec: {
|
|
167
|
+
containers: [{ env: Object.entries(env).map(([name, value]) => ({ name, value })) }],
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
const META = { domain: "acme.com", region: "europe-west1" } as const;
|
|
173
|
+
const SERVED = { CAELO_SITE_URL: "https://acme.com", CAELO_STAGING_HOST: "staging.acme.com" };
|
|
174
|
+
|
|
175
|
+
it("accepts the records of the site the admin serves, in the job's own region", () => {
|
|
176
|
+
expect(deployedSiteMismatch(admin(SERVED), META, "europe-west1")).toBeNull();
|
|
177
|
+
// An admin from before #650 has no staging host yet: the default is fine.
|
|
178
|
+
expect(
|
|
179
|
+
deployedSiteMismatch(admin({ CAELO_SITE_URL: "https://acme.com" }), META, "europe-west1"),
|
|
180
|
+
).toBeNull();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("refuses a tampered domain, staging host or region: the provisioner would act on it", () => {
|
|
184
|
+
expect(
|
|
185
|
+
deployedSiteMismatch(admin(SERVED), { ...META, domain: "evil.example" }, "europe-west1"),
|
|
186
|
+
).toContain("https://evil.example");
|
|
187
|
+
expect(
|
|
188
|
+
deployedSiteMismatch(
|
|
189
|
+
admin(SERVED),
|
|
190
|
+
{ ...META, stagingDomain: "staging.evil.example" },
|
|
191
|
+
"europe-west1",
|
|
192
|
+
),
|
|
193
|
+
).toContain("staging.evil.example");
|
|
194
|
+
expect(
|
|
195
|
+
deployedSiteMismatch(
|
|
196
|
+
admin({ CAELO_SITE_URL: "https://acme.com" }),
|
|
197
|
+
{ ...META, stagingDomain: "staging.evil.example" },
|
|
198
|
+
"europe-west1",
|
|
199
|
+
),
|
|
200
|
+
).not.toBeNull();
|
|
201
|
+
expect(
|
|
202
|
+
deployedSiteMismatch(admin(SERVED), { ...META, region: "us-central1" }, "europe-west1"),
|
|
203
|
+
).toContain("us-central1");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("refuses when the admin's site cannot be read — never assumes it matches", () => {
|
|
207
|
+
expect(deployedSiteMismatch("not json", META, "europe-west1")).not.toBeNull();
|
|
208
|
+
expect(deployedSiteMismatch(admin({}), META, "europe-west1")).toContain("CAELO_SITE_URL");
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("convergeExecutionNameError (#883 review)", () => {
|
|
213
|
+
it("accepts a run of the converge job and no name at all (outside Cloud Run)", () => {
|
|
214
|
+
expect(convergeExecutionNameError("caelo-production-converge-x7k2p")).toBeNull();
|
|
215
|
+
expect(convergeExecutionNameError(null)).toBeNull();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("refuses a run of another job or a value that is no Cloud Run name", () => {
|
|
219
|
+
expect(convergeExecutionNameError("caelo-production-upgrader-x1")).not.toBeNull();
|
|
220
|
+
expect(convergeExecutionNameError("caelo-production-converge-X;rm")).not.toBeNull();
|
|
221
|
+
});
|
|
222
|
+
});
|