@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,833 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import type { GcloudResult } from "./gcloud.js";
|
|
5
|
+
import {
|
|
6
|
+
ABSENT_DEFAULT_FIREWALL_RULES,
|
|
7
|
+
STATIC_CDN_POLICY,
|
|
8
|
+
stackIamInvariants,
|
|
9
|
+
} from "./stack-contract.js";
|
|
10
|
+
import {
|
|
11
|
+
ensureStackInvariants,
|
|
12
|
+
type InstallTarget,
|
|
13
|
+
type LiveEnvValue,
|
|
14
|
+
liveContainerEnv,
|
|
15
|
+
liveDatabaseHost,
|
|
16
|
+
liveServiceAccount,
|
|
17
|
+
planContractEnv,
|
|
18
|
+
planEnvUpdate,
|
|
19
|
+
policyGrants,
|
|
20
|
+
removeRetiredIamBindings,
|
|
21
|
+
rollService,
|
|
22
|
+
serviceRollArgs,
|
|
23
|
+
} from "./stack-converge.js";
|
|
24
|
+
|
|
25
|
+
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
26
|
+
const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Fake gcloud: answers by the longest matching command prefix (queue per
|
|
30
|
+
* prefix, then the prefix's last answer repeats); records every call.
|
|
31
|
+
*/
|
|
32
|
+
function fakeGcloud(answers: Record<string, GcloudResult[]>, fallback: GcloudResult = ok("{}")) {
|
|
33
|
+
const calls: string[] = [];
|
|
34
|
+
const run = async (args: string[]) => {
|
|
35
|
+
const line = args.join(" ");
|
|
36
|
+
calls.push(line);
|
|
37
|
+
const key = Object.keys(answers)
|
|
38
|
+
.filter((k) => line.startsWith(k))
|
|
39
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
40
|
+
const queue = key ? answers[key] : undefined;
|
|
41
|
+
if (!queue || queue.length === 0) return fallback;
|
|
42
|
+
return queue.length > 1 ? (queue.shift() as GcloudResult) : (queue[0] as GcloudResult);
|
|
43
|
+
};
|
|
44
|
+
return { run, calls };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const policy = (bindings: { role: string; members: string[]; condition?: unknown }[]) =>
|
|
48
|
+
ok(JSON.stringify({ bindings }));
|
|
49
|
+
|
|
50
|
+
const RUN_SA = "serviceAccount:caelo-production-run-sa@acme.iam.gserviceaccount.com";
|
|
51
|
+
const GW_SA = "serviceAccount:caelo-production-gateway-sa@acme.iam.gserviceaccount.com";
|
|
52
|
+
const GW_TELEMETRY = [
|
|
53
|
+
{ role: "roles/logging.logWriter", members: [GW_SA] },
|
|
54
|
+
{ role: "roles/monitoring.metricWriter", members: [GW_SA] },
|
|
55
|
+
];
|
|
56
|
+
const IAP_AGENT = "serviceAccount:service-42@gcp-sa-iap.iam.gserviceaccount.com";
|
|
57
|
+
|
|
58
|
+
const firebase: InstallTarget = {
|
|
59
|
+
provider: "gcp-firebase",
|
|
60
|
+
projectId: "acme",
|
|
61
|
+
region: "europe-west1",
|
|
62
|
+
env: "production",
|
|
63
|
+
services: { admin: "caelo-production-admin-aaa", gateway: "caelo-production-gateway-bbb" },
|
|
64
|
+
};
|
|
65
|
+
const gcp: InstallTarget = { ...firebase, provider: "gcp" };
|
|
66
|
+
|
|
67
|
+
const serviceJson = (env: unknown[]) =>
|
|
68
|
+
JSON.stringify({ spec: { template: { spec: { containers: [{ env }] } } } });
|
|
69
|
+
|
|
70
|
+
describe("liveContainerEnv", () => {
|
|
71
|
+
it("reads plain values and secret references from a Knative service", () => {
|
|
72
|
+
const env = liveContainerEnv(
|
|
73
|
+
serviceJson([
|
|
74
|
+
{ name: "CAELO_ENV", value: "production" },
|
|
75
|
+
{
|
|
76
|
+
name: "CAELO_SECRET_KEK",
|
|
77
|
+
valueFrom: { secretKeyRef: { name: "caelo-production-secret-kek", key: "latest" } },
|
|
78
|
+
},
|
|
79
|
+
]),
|
|
80
|
+
);
|
|
81
|
+
expect(env.get("CAELO_ENV")).toEqual({ kind: "value", value: "production" });
|
|
82
|
+
expect(env.get("CAELO_SECRET_KEK")).toEqual({
|
|
83
|
+
kind: "secret",
|
|
84
|
+
secret: "caelo-production-secret-kek",
|
|
85
|
+
version: "latest",
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("liveContainerEnv / liveServiceAccount / liveDatabaseHost", () => {
|
|
91
|
+
it("normalises a fully qualified secret name to its id", () => {
|
|
92
|
+
const env = liveContainerEnv(
|
|
93
|
+
serviceJson([
|
|
94
|
+
{
|
|
95
|
+
name: "X",
|
|
96
|
+
valueFrom: {
|
|
97
|
+
secretKeyRef: { name: "projects/123/secrets/caelo-production-x", key: "latest" },
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
]),
|
|
101
|
+
);
|
|
102
|
+
expect(env.get("X")).toEqual({
|
|
103
|
+
kind: "secret",
|
|
104
|
+
secret: "caelo-production-x",
|
|
105
|
+
version: "latest",
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("reads the revision service account", () => {
|
|
110
|
+
const json = JSON.stringify({
|
|
111
|
+
spec: { template: { spec: { serviceAccountName: "a@p.iam.gserviceaccount.com" } } },
|
|
112
|
+
});
|
|
113
|
+
expect(liveServiceAccount(json)).toBe("a@p.iam.gserviceaccount.com");
|
|
114
|
+
expect(liveServiceAccount("{}")).toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("finds the Cloud SQL host in ADMIN_DATABASE_URL with or without an inline password", () => {
|
|
118
|
+
for (const url of [
|
|
119
|
+
"postgresql://admin_role:pw@10.20.0.3:5432/cms_admin?sslmode=require",
|
|
120
|
+
"postgresql://admin_role@10.20.0.3:5432/cms_admin?sslmode=require",
|
|
121
|
+
]) {
|
|
122
|
+
const host = liveDatabaseHost(
|
|
123
|
+
new Map([["ADMIN_DATABASE_URL", { kind: "value", value: url }]]),
|
|
124
|
+
);
|
|
125
|
+
expect(host).toEqual({ ok: true, host: "10.20.0.3" });
|
|
126
|
+
}
|
|
127
|
+
expect(liveDatabaseHost(new Map()).ok).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("planEnvUpdate", () => {
|
|
132
|
+
const live = new Map<string, LiveEnvValue>([
|
|
133
|
+
["CAELO_ENV", { kind: "value", value: "production" }],
|
|
134
|
+
["CAELO_SECRET_KEK", { kind: "secret", secret: "kek", version: "latest" }],
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
it("touches only vars that differ", () => {
|
|
138
|
+
const plan = planEnvUpdate(live, [
|
|
139
|
+
{ name: "CAELO_ENV", value: "production" },
|
|
140
|
+
{ name: "CAELO_SITE_URL", value: "https://acme.com" },
|
|
141
|
+
]);
|
|
142
|
+
expect(plan).toEqual({
|
|
143
|
+
ok: true,
|
|
144
|
+
changes: [{ name: "CAELO_SITE_URL", from: undefined, to: "https://acme.com" }],
|
|
145
|
+
flags: ["--update-env-vars=CAELO_SITE_URL=https://acme.com"],
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("is empty when the service already matches", () => {
|
|
150
|
+
expect(planEnvUpdate(live, [{ name: "CAELO_ENV", value: "production" }])).toEqual({
|
|
151
|
+
ok: true,
|
|
152
|
+
changes: [],
|
|
153
|
+
flags: [],
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("renders Secret Manager references as --update-secrets", () => {
|
|
158
|
+
const plan = planEnvUpdate(live, [
|
|
159
|
+
{
|
|
160
|
+
name: "CAELO_SECRET_KEK",
|
|
161
|
+
valueSource: { secretKeyRef: { secret: "kek", version: "latest" } },
|
|
162
|
+
},
|
|
163
|
+
{ name: "CSRF", valueSource: { secretKeyRef: { secret: "csrf", version: "3" } } },
|
|
164
|
+
]);
|
|
165
|
+
expect(plan.ok && plan.flags).toEqual(["--update-secrets=CSRF=csrf:3"]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("moves a plain var to a secret reference inside the same update, never printing the old value", () => {
|
|
169
|
+
const withPlainSecret = new Map<string, LiveEnvValue>([
|
|
170
|
+
["CAELO_INTERNAL_SECRET", { kind: "value", value: "hand-set-plaintext" }],
|
|
171
|
+
]);
|
|
172
|
+
const plan = planEnvUpdate(withPlainSecret, [
|
|
173
|
+
{
|
|
174
|
+
name: "CAELO_INTERNAL_SECRET",
|
|
175
|
+
valueSource: { secretKeyRef: { secret: "internal", version: "latest" } },
|
|
176
|
+
},
|
|
177
|
+
]);
|
|
178
|
+
expect(plan).toEqual({
|
|
179
|
+
ok: true,
|
|
180
|
+
changes: [
|
|
181
|
+
{ name: "CAELO_INTERNAL_SECRET", from: "(plain value)", to: "secret:internal:latest" },
|
|
182
|
+
],
|
|
183
|
+
flags: [
|
|
184
|
+
"--remove-env-vars=CAELO_INTERNAL_SECRET",
|
|
185
|
+
"--update-secrets=CAELO_INTERNAL_SECRET=internal:latest",
|
|
186
|
+
],
|
|
187
|
+
});
|
|
188
|
+
expect(JSON.stringify(plan)).not.toContain("hand-set-plaintext");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("refuses to flip a secret reference to a plain value (gcloud can't do it in one revision)", () => {
|
|
192
|
+
const toPlain = planEnvUpdate(live, [{ name: "CAELO_SECRET_KEK", value: "raw" }]);
|
|
193
|
+
expect(toPlain.ok).toBe(false);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("never prints a retired literal (a plain KEK)", () => {
|
|
197
|
+
const plan = planEnvUpdate(
|
|
198
|
+
new Map<string, LiveEnvValue>([
|
|
199
|
+
["CAELO_SECRET_KEK", { kind: "value", value: "kek-literal" }],
|
|
200
|
+
]),
|
|
201
|
+
[],
|
|
202
|
+
["CAELO_SECRET_KEK"],
|
|
203
|
+
);
|
|
204
|
+
expect(JSON.stringify(plan)).not.toContain("kek-literal");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("removes retired vars of either kind", () => {
|
|
208
|
+
const plan = planEnvUpdate(
|
|
209
|
+
new Map<string, LiveEnvValue>([
|
|
210
|
+
["OLD_PLAIN", { kind: "value", value: "x" }],
|
|
211
|
+
["CAELO_SECRET_KEK", { kind: "secret", secret: "kek", version: "latest" }],
|
|
212
|
+
]),
|
|
213
|
+
[],
|
|
214
|
+
["OLD_PLAIN", "CAELO_SECRET_KEK", "NEVER_SET"],
|
|
215
|
+
);
|
|
216
|
+
expect(plan).toEqual({
|
|
217
|
+
ok: true,
|
|
218
|
+
changes: [
|
|
219
|
+
{ name: "OLD_PLAIN", from: "(plain value)", to: undefined },
|
|
220
|
+
{ name: "CAELO_SECRET_KEK", from: "secret:kek:latest", to: undefined },
|
|
221
|
+
],
|
|
222
|
+
flags: ["--remove-env-vars=OLD_PLAIN", "--remove-secrets=CAELO_SECRET_KEK"],
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("masks a password in a URL it reports as the old value", () => {
|
|
227
|
+
const plan = planEnvUpdate(
|
|
228
|
+
new Map<string, LiveEnvValue>([
|
|
229
|
+
["ADMIN_DATABASE_URL", { kind: "value", value: "postgres://admin_role:hunter2@h:5432/db" }],
|
|
230
|
+
]),
|
|
231
|
+
[{ name: "ADMIN_DATABASE_URL", value: "postgresql://admin_role@h:5432/db" }],
|
|
232
|
+
);
|
|
233
|
+
expect(plan.ok && plan.changes[0]?.from).toBe("postgres://admin_role:***@h:5432/db");
|
|
234
|
+
expect(JSON.stringify(plan)).not.toContain("hunter2");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("escapes values containing commas", () => {
|
|
238
|
+
const plan = planEnvUpdate(new Map(), [
|
|
239
|
+
{ name: "A", value: "x,y" },
|
|
240
|
+
{ name: "B", value: "z" },
|
|
241
|
+
]);
|
|
242
|
+
expect(plan.ok && plan.flags).toEqual(["--update-env-vars=^@^A=x,y@B=z"]);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
/** The env a service carries once this contract applied (database + secrets). */
|
|
247
|
+
const secretRef = (name: string, secret: string) => ({
|
|
248
|
+
name,
|
|
249
|
+
valueFrom: { secretKeyRef: { name: `caelo-production-${secret}`, key: "latest" } },
|
|
250
|
+
});
|
|
251
|
+
const CONVERGED_ADMIN_DB = [
|
|
252
|
+
{
|
|
253
|
+
name: "ADMIN_DATABASE_URL",
|
|
254
|
+
value: "postgresql://admin_role@10.20.0.3:5432/cms_admin?sslmode=require",
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: "PUBLIC_ADMIN_DATABASE_URL",
|
|
258
|
+
value: "postgresql://admin_role@10.20.0.3:5432/cms_public?sslmode=require",
|
|
259
|
+
},
|
|
260
|
+
secretRef("ADMIN_DATABASE_PASSWORD", "postgres-password"),
|
|
261
|
+
secretRef("PUBLIC_ADMIN_DATABASE_PASSWORD", "postgres-password"),
|
|
262
|
+
secretRef("CAELO_SECRET_KEK", "secret-kek"),
|
|
263
|
+
secretRef("CAELO_INTERNAL_SECRET", "internal-secret"),
|
|
264
|
+
secretRef("CAELO_TOOL_APPROVAL_SECRET", "tool-approval-secret"),
|
|
265
|
+
];
|
|
266
|
+
const CONVERGED_GATEWAY_DB = [
|
|
267
|
+
{
|
|
268
|
+
name: "PUBLIC_DATABASE_URL",
|
|
269
|
+
value: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: "GATEWAY_DATABASE_URL",
|
|
273
|
+
value: "postgresql://gateway_role@10.20.0.3:5432/cms_admin?sslmode=require",
|
|
274
|
+
},
|
|
275
|
+
secretRef("PUBLIC_DATABASE_PASSWORD", "public-role-password"),
|
|
276
|
+
secretRef("GATEWAY_DATABASE_PASSWORD", "gateway-role-password"),
|
|
277
|
+
];
|
|
278
|
+
|
|
279
|
+
describe("planContractEnv", () => {
|
|
280
|
+
it("regression A1: an install without CAELO_SITE_URL gets it on the admin roll", () => {
|
|
281
|
+
// What a pre-#551 gcp-firebase admin runs with.
|
|
282
|
+
const adminLive = liveContainerEnv(
|
|
283
|
+
serviceJson([
|
|
284
|
+
{ name: "CAELO_PROVIDER", value: "gcp-firebase" },
|
|
285
|
+
{ name: "CAELO_ENV", value: "production" },
|
|
286
|
+
{ name: "MEDIA_STORAGE_URL", value: "gs://acme-caelo-production-media" },
|
|
287
|
+
{ name: "CAELO_FIREBASE_SITE", value: "caelo-production-site-abc123" },
|
|
288
|
+
{ name: "CAELO_GENERATOR_CLI", value: "/app/apps/static-generator/src/cli.ts" },
|
|
289
|
+
{ name: "CAELO_GATEWAY_SERVICE", value: "caelo-production-gateway-bbb" },
|
|
290
|
+
{ name: "CAELO_GATEWAY_REGION", value: "europe-west1" },
|
|
291
|
+
...CONVERGED_ADMIN_DB,
|
|
292
|
+
]),
|
|
293
|
+
);
|
|
294
|
+
const gatewayLive = liveContainerEnv(
|
|
295
|
+
serviceJson([
|
|
296
|
+
{ name: "CAELO_PROVIDER", value: "gcp-firebase" },
|
|
297
|
+
{ name: "CAELO_ENV", value: "production" },
|
|
298
|
+
{ name: "MEDIA_STORAGE_URL", value: "gs://acme-caelo-production-media" },
|
|
299
|
+
...CONVERGED_GATEWAY_DB,
|
|
300
|
+
]),
|
|
301
|
+
);
|
|
302
|
+
const plan = planContractEnv(
|
|
303
|
+
{
|
|
304
|
+
provider: "gcp-firebase",
|
|
305
|
+
projectId: "acme",
|
|
306
|
+
env: "production",
|
|
307
|
+
domain: "acme.com",
|
|
308
|
+
stagingHost: "staging.acme.com",
|
|
309
|
+
region: "europe-west1",
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
admin: { serviceName: "caelo-production-admin-aaa", liveEnv: adminLive },
|
|
313
|
+
gateway: { serviceName: "caelo-production-gateway-bbb", liveEnv: gatewayLive },
|
|
314
|
+
},
|
|
315
|
+
);
|
|
316
|
+
if (!plan.ok) throw new Error(plan.error);
|
|
317
|
+
// #650 — the same roll converges the protected staging domain's host
|
|
318
|
+
// and gate key onto an install that predates it.
|
|
319
|
+
expect(plan.services.admin.flags).toEqual([
|
|
320
|
+
"--update-env-vars=CAELO_SITE_URL=https://acme.com,MEDIA_ROOT_DIR=/app/apps/admin/data/media,CAELO_MCP_IAP_SERVICE_ACCOUNT=caelo-mcp@acme.iam.gserviceaccount.com,CAELO_OPERATOR_ACCESS_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync,CAELO_UPGRADER_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-upgrader,CAELO_STAGING_HOST=staging.acme.com",
|
|
321
|
+
"--update-secrets=CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest",
|
|
322
|
+
]);
|
|
323
|
+
expect(plan.services.gateway.flags).toEqual([
|
|
324
|
+
"--update-secrets=CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest",
|
|
325
|
+
]);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("leaves the MCP service account untouched when upgrade couldn't set it up", () => {
|
|
329
|
+
const live = liveContainerEnv(
|
|
330
|
+
serviceJson([
|
|
331
|
+
{ name: "CAELO_FIREBASE_SITE", value: "caelo-production-site-abc123" },
|
|
332
|
+
...CONVERGED_ADMIN_DB,
|
|
333
|
+
]),
|
|
334
|
+
);
|
|
335
|
+
const plan = planContractEnv(
|
|
336
|
+
{
|
|
337
|
+
provider: "gcp-firebase",
|
|
338
|
+
projectId: "acme",
|
|
339
|
+
env: "production",
|
|
340
|
+
domain: "acme.com",
|
|
341
|
+
stagingHost: "staging.acme.com",
|
|
342
|
+
region: "europe-west1",
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
admin: { serviceName: "caelo-production-admin-aaa", liveEnv: live },
|
|
346
|
+
gateway: {
|
|
347
|
+
serviceName: "caelo-production-gateway-bbb",
|
|
348
|
+
liveEnv: liveContainerEnv(serviceJson(CONVERGED_GATEWAY_DB)),
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
{ leaveUntouched: ["CAELO_MCP_IAP_SERVICE_ACCOUNT", "CAELO_OPERATOR_ACCESS_JOB"] },
|
|
352
|
+
);
|
|
353
|
+
if (!plan.ok) throw new Error(plan.error);
|
|
354
|
+
expect(plan.services.admin.changes.map((c) => c.name)).not.toContain(
|
|
355
|
+
"CAELO_MCP_IAP_SERVICE_ACCOUNT",
|
|
356
|
+
);
|
|
357
|
+
// Nor the sync job's name when upgrade couldn't set the job up.
|
|
358
|
+
expect(plan.services.admin.changes.map((c) => c.name)).not.toContain(
|
|
359
|
+
"CAELO_OPERATOR_ACCESS_JOB",
|
|
360
|
+
);
|
|
361
|
+
expect(plan.services.admin.changes.map((c) => c.name)).toContain("CAELO_SITE_URL");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("fails loudly when the Firebase site id can't be discovered", () => {
|
|
365
|
+
const plan = planContractEnv(
|
|
366
|
+
{ provider: "gcp-firebase", projectId: "a", env: "production", domain: "a.com", region: "r" },
|
|
367
|
+
{
|
|
368
|
+
admin: { serviceName: "adm", liveEnv: liveContainerEnv(serviceJson(CONVERGED_ADMIN_DB)) },
|
|
369
|
+
gateway: { serviceName: "gw", liveEnv: new Map() },
|
|
370
|
+
},
|
|
371
|
+
);
|
|
372
|
+
expect(plan.ok).toBe(false);
|
|
373
|
+
expect(!plan.ok && plan.error).toContain("CAELO_FIREBASE_SITE");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("fails loudly when the Cloud SQL host can't be discovered", () => {
|
|
377
|
+
const plan = planContractEnv(
|
|
378
|
+
{ provider: "gcp", projectId: "a", env: "production", domain: "a.com", region: "r" },
|
|
379
|
+
{
|
|
380
|
+
admin: { serviceName: "adm", liveEnv: new Map() },
|
|
381
|
+
gateway: { serviceName: "gw", liveEnv: new Map() },
|
|
382
|
+
},
|
|
383
|
+
);
|
|
384
|
+
expect(plan.ok).toBe(false);
|
|
385
|
+
expect(!plan.ok && plan.error).toContain("ADMIN_DATABASE_URL");
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
describe("serviceRollArgs", () => {
|
|
390
|
+
it("rolls the image and applies env changes in one services update (one revision)", () => {
|
|
391
|
+
expect(
|
|
392
|
+
serviceRollArgs({
|
|
393
|
+
serviceName: "caelo-production-admin-aaa",
|
|
394
|
+
region: "europe-west1",
|
|
395
|
+
projectId: "acme",
|
|
396
|
+
imageRef: "img@sha256:1",
|
|
397
|
+
serviceAccount: "caelo-production-run-sa@acme.iam.gserviceaccount.com",
|
|
398
|
+
envFlags: ["--update-env-vars=CAELO_SITE_URL=https://acme.com"],
|
|
399
|
+
}),
|
|
400
|
+
).toEqual([
|
|
401
|
+
"run",
|
|
402
|
+
"services",
|
|
403
|
+
"update",
|
|
404
|
+
"caelo-production-admin-aaa",
|
|
405
|
+
"--region",
|
|
406
|
+
"europe-west1",
|
|
407
|
+
"--project",
|
|
408
|
+
"acme",
|
|
409
|
+
"--image",
|
|
410
|
+
"img@sha256:1",
|
|
411
|
+
"--service-account=caelo-production-run-sa@acme.iam.gserviceaccount.com",
|
|
412
|
+
"--update-env-vars=CAELO_SITE_URL=https://acme.com",
|
|
413
|
+
"--quiet",
|
|
414
|
+
]);
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
describe("rollService", () => {
|
|
419
|
+
it("retries while a fresh secret binding has not propagated, then succeeds", async () => {
|
|
420
|
+
const { run, calls } = fakeGcloud({
|
|
421
|
+
"run services update": [
|
|
422
|
+
fail(
|
|
423
|
+
"ERROR: (gcloud.run.services.update) spec.template.spec.containers[0].env[3].value_from.secret_key_ref.name: Permission denied on secret: projects/1/secrets/caelo-production-postgres-password/versions/latest for Revision service account caelo-production-gateway-sa@acme.iam.gserviceaccount.com.",
|
|
424
|
+
),
|
|
425
|
+
ok(),
|
|
426
|
+
],
|
|
427
|
+
});
|
|
428
|
+
const slept: number[] = [];
|
|
429
|
+
const r = await rollService(["run", "services", "update", "gw"], {
|
|
430
|
+
run,
|
|
431
|
+
sleep: async (ms) => {
|
|
432
|
+
slept.push(ms);
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
expect(r.ok).toBe(true);
|
|
436
|
+
expect(calls).toHaveLength(2);
|
|
437
|
+
expect(slept).toEqual([10_000]);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("returns any other failure at once", async () => {
|
|
441
|
+
const { run, calls } = fakeGcloud({
|
|
442
|
+
"run services update": [fail("ERROR: revision failed readiness check")],
|
|
443
|
+
});
|
|
444
|
+
const r = await rollService(["run", "services", "update", "gw"], {
|
|
445
|
+
run,
|
|
446
|
+
sleep: async () => {},
|
|
447
|
+
});
|
|
448
|
+
expect(r.ok).toBe(false);
|
|
449
|
+
expect(calls).toHaveLength(1);
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
describe("policyGrants", () => {
|
|
454
|
+
it("matches unconditional bindings only", () => {
|
|
455
|
+
const json = JSON.stringify({
|
|
456
|
+
bindings: [
|
|
457
|
+
{ role: "roles/a", members: ["user:x"] },
|
|
458
|
+
{ role: "roles/b", members: ["user:x"], condition: { title: "t" } },
|
|
459
|
+
],
|
|
460
|
+
});
|
|
461
|
+
expect(policyGrants(json, "roles/a", "user:x")).toBe(true);
|
|
462
|
+
expect(policyGrants(json, "roles/b", "user:x")).toBe(false);
|
|
463
|
+
expect(policyGrants("{}", "roles/a", "user:x")).toBe(false);
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
describe("ensureStackInvariants", () => {
|
|
468
|
+
/** Policies that already hold every gcp-firebase invariant. */
|
|
469
|
+
const fullFirebase = () => ({
|
|
470
|
+
"projects describe": [ok("42\n")],
|
|
471
|
+
"projects get-iam-policy": [
|
|
472
|
+
policy([
|
|
473
|
+
{ role: "roles/firebasehosting.admin", members: [RUN_SA] },
|
|
474
|
+
{ role: "roles/logging.logWriter", members: [RUN_SA] },
|
|
475
|
+
{ role: "roles/monitoring.metricWriter", members: [RUN_SA] },
|
|
476
|
+
...GW_TELEMETRY,
|
|
477
|
+
]),
|
|
478
|
+
],
|
|
479
|
+
"secrets get-iam-policy": [
|
|
480
|
+
policy([{ role: "roles/secretmanager.secretAccessor", members: [RUN_SA, GW_SA] }]),
|
|
481
|
+
],
|
|
482
|
+
"storage buckets get-iam-policy": [
|
|
483
|
+
policy([{ role: "roles/storage.objectAdmin", members: [RUN_SA] }]),
|
|
484
|
+
],
|
|
485
|
+
"run services get-iam-policy caelo-production-gateway-bbb": [
|
|
486
|
+
policy([
|
|
487
|
+
{ role: "roles/run.invoker", members: ["allUsers"] },
|
|
488
|
+
{ role: "roles/run.viewer", members: [RUN_SA] },
|
|
489
|
+
]),
|
|
490
|
+
],
|
|
491
|
+
"run services get-iam-policy caelo-production-admin-aaa": [
|
|
492
|
+
policy([{ role: "roles/run.invoker", members: [IAP_AGENT] }]),
|
|
493
|
+
],
|
|
494
|
+
"compute firewall-rules list": [ok("[]")],
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("is a read-only no-op on an install that is already in shape", async () => {
|
|
498
|
+
const { run, calls } = fakeGcloud(fullFirebase());
|
|
499
|
+
const report = await ensureStackInvariants(firebase, { run, sleep: async () => {} });
|
|
500
|
+
expect(report.mustAbort).toBe(false);
|
|
501
|
+
expect(report.outcomes.every((o) => o.status === "present")).toBe(true);
|
|
502
|
+
expect(report.outcomes).toHaveLength(
|
|
503
|
+
stackIamInvariants("gcp-firebase").length + ABSENT_DEFAULT_FIREWALL_RULES.length,
|
|
504
|
+
);
|
|
505
|
+
expect(calls.some((c) => c.startsWith("compute firewall-rules delete"))).toBe(false);
|
|
506
|
+
expect(calls.some((c) => c.includes("add-iam-policy-binding"))).toBe(false);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("upgrade deletes GCP's pre-populated default-network firewall rules without aborting", async () => {
|
|
510
|
+
const stock = (
|
|
511
|
+
name: string,
|
|
512
|
+
allowed: { IPProtocol: string; ports?: string[] }[],
|
|
513
|
+
sourceRanges = ["0.0.0.0/0"],
|
|
514
|
+
) => ({
|
|
515
|
+
name,
|
|
516
|
+
network: "https://www.googleapis.com/compute/v1/projects/acme/global/networks/default",
|
|
517
|
+
direction: "INGRESS",
|
|
518
|
+
priority: 65534,
|
|
519
|
+
sourceRanges,
|
|
520
|
+
allowed,
|
|
521
|
+
});
|
|
522
|
+
const answers = {
|
|
523
|
+
...fullFirebase(),
|
|
524
|
+
"compute firewall-rules list": [
|
|
525
|
+
ok(
|
|
526
|
+
JSON.stringify([
|
|
527
|
+
stock("default-allow-ssh", [{ IPProtocol: "tcp", ports: ["22"] }]),
|
|
528
|
+
stock("default-allow-rdp", [{ IPProtocol: "tcp", ports: ["3389"] }]),
|
|
529
|
+
stock("default-allow-icmp", [{ IPProtocol: "icmp" }]),
|
|
530
|
+
stock(
|
|
531
|
+
"default-allow-internal",
|
|
532
|
+
[
|
|
533
|
+
{ IPProtocol: "tcp", ports: ["0-65535"] },
|
|
534
|
+
{ IPProtocol: "udp", ports: ["0-65535"] },
|
|
535
|
+
{ IPProtocol: "icmp" },
|
|
536
|
+
],
|
|
537
|
+
["10.128.0.0/9"],
|
|
538
|
+
),
|
|
539
|
+
]),
|
|
540
|
+
),
|
|
541
|
+
],
|
|
542
|
+
"compute instances list": [ok("[]")],
|
|
543
|
+
"compute project-info describe": [ok("UNSPECIFIED_XPN_PROJECT_STATUS")],
|
|
544
|
+
};
|
|
545
|
+
const { run, calls } = fakeGcloud(answers);
|
|
546
|
+
const report = await ensureStackInvariants(firebase, { run, sleep: async () => {} });
|
|
547
|
+
expect(report.mustAbort).toBe(false);
|
|
548
|
+
for (const name of [
|
|
549
|
+
"default-allow-ssh",
|
|
550
|
+
"default-allow-rdp",
|
|
551
|
+
"default-allow-icmp",
|
|
552
|
+
"default-allow-internal",
|
|
553
|
+
]) {
|
|
554
|
+
expect(calls).toContain(`compute firewall-rules delete ${name} --project=acme --quiet`);
|
|
555
|
+
}
|
|
556
|
+
expect(report.outcomes.filter((o) => o.status === "applied").map((o) => o.id)).toEqual([
|
|
557
|
+
"firewall rule default-allow-ssh (tcp:22 from 0.0.0.0/0)",
|
|
558
|
+
"firewall rule default-allow-rdp (tcp:3389 from 0.0.0.0/0)",
|
|
559
|
+
"firewall rule default-allow-icmp (icmp from 0.0.0.0/0)",
|
|
560
|
+
"firewall rule default-allow-internal (tcp:0-65535 udp:0-65535 icmp from 10.128.0.0/9)",
|
|
561
|
+
]);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("regression A5: adds the gateway run.viewer + telemetry roles an older install lacks", async () => {
|
|
565
|
+
const answers = fullFirebase();
|
|
566
|
+
answers["projects get-iam-policy"] = [
|
|
567
|
+
policy([{ role: "roles/firebasehosting.admin", members: [RUN_SA] }, ...GW_TELEMETRY]),
|
|
568
|
+
];
|
|
569
|
+
answers["run services get-iam-policy caelo-production-gateway-bbb"] = [
|
|
570
|
+
policy([{ role: "roles/run.invoker", members: ["allUsers"] }]),
|
|
571
|
+
];
|
|
572
|
+
const { run, calls } = fakeGcloud(answers);
|
|
573
|
+
const report = await ensureStackInvariants(firebase, { run, sleep: async () => {} });
|
|
574
|
+
|
|
575
|
+
expect(report.mustAbort).toBe(false);
|
|
576
|
+
expect(calls).toContain(
|
|
577
|
+
`run services add-iam-policy-binding caelo-production-gateway-bbb --region=europe-west1 --project=acme --member=${RUN_SA} --role=roles/run.viewer --quiet --format=none`,
|
|
578
|
+
);
|
|
579
|
+
for (const role of ["roles/logging.logWriter", "roles/monitoring.metricWriter"]) {
|
|
580
|
+
expect(calls).toContain(
|
|
581
|
+
`projects add-iam-policy-binding acme --member=${RUN_SA} --role=${role} --condition=None --quiet --format=none`,
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
expect(report.outcomes.filter((o) => o.status === "applied")).toHaveLength(3);
|
|
585
|
+
// One policy read per resource, not per invariant.
|
|
586
|
+
expect(calls.filter((c) => c.startsWith("projects get-iam-policy"))).toHaveLength(1);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it("aborts when a binding the install needs can't be added", async () => {
|
|
590
|
+
const answers = fullFirebase();
|
|
591
|
+
answers["run services get-iam-policy caelo-production-gateway-bbb"] = [
|
|
592
|
+
policy([{ role: "roles/run.invoker", members: ["allUsers"] }]),
|
|
593
|
+
];
|
|
594
|
+
answers["run services add-iam-policy-binding"] = [
|
|
595
|
+
fail("PERMISSION_DENIED: run.services.setIamPolicy"),
|
|
596
|
+
];
|
|
597
|
+
const { run } = fakeGcloud(answers);
|
|
598
|
+
const report = await ensureStackInvariants(firebase, { run, sleep: async () => {} });
|
|
599
|
+
expect(report.mustAbort).toBe(true);
|
|
600
|
+
const failed = report.outcomes.filter((o) => o.status === "failed");
|
|
601
|
+
expect(failed).toHaveLength(1);
|
|
602
|
+
expect(failed[0]?.id).toContain("roles/run.viewer");
|
|
603
|
+
expect(failed[0]?.error).toContain("PERMISSION_DENIED");
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("points a binding on a resource the install predates at the installer", async () => {
|
|
607
|
+
const answers = fullFirebase();
|
|
608
|
+
answers["secrets get-iam-policy caelo-production-secret-kek"] = [
|
|
609
|
+
fail("NOT_FOUND: Secret [caelo-production-secret-kek] not found"),
|
|
610
|
+
];
|
|
611
|
+
answers["secrets add-iam-policy-binding caelo-production-secret-kek"] = [
|
|
612
|
+
fail("NOT_FOUND: Secret [caelo-production-secret-kek] not found"),
|
|
613
|
+
];
|
|
614
|
+
const { run } = fakeGcloud(answers);
|
|
615
|
+
const report = await ensureStackInvariants(firebase, { run, sleep: async () => {} });
|
|
616
|
+
expect(report.mustAbort).toBe(true);
|
|
617
|
+
const failed = report.outcomes.find((o) => o.status === "failed");
|
|
618
|
+
expect(failed?.error).toContain("Re-run the installer");
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("only warns when a telemetry role can't be added", async () => {
|
|
622
|
+
const answers = fullFirebase();
|
|
623
|
+
answers["projects get-iam-policy"] = [
|
|
624
|
+
policy([{ role: "roles/firebasehosting.admin", members: [RUN_SA] }, ...GW_TELEMETRY]),
|
|
625
|
+
];
|
|
626
|
+
answers["projects add-iam-policy-binding"] = [fail("PERMISSION_DENIED: setIamPolicy")];
|
|
627
|
+
const { run } = fakeGcloud(answers);
|
|
628
|
+
const report = await ensureStackInvariants(firebase, { run, sleep: async () => {} });
|
|
629
|
+
expect(report.mustAbort).toBe(false);
|
|
630
|
+
expect(report.outcomes.filter((o) => o.status === "failed")).toHaveLength(2);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it("retries a binding that loses a concurrent policy write", async () => {
|
|
634
|
+
const answers = fullFirebase();
|
|
635
|
+
answers["projects get-iam-policy"] = [
|
|
636
|
+
policy([
|
|
637
|
+
{ role: "roles/firebasehosting.admin", members: [RUN_SA] },
|
|
638
|
+
{ role: "roles/monitoring.metricWriter", members: [RUN_SA] },
|
|
639
|
+
...GW_TELEMETRY,
|
|
640
|
+
]),
|
|
641
|
+
];
|
|
642
|
+
answers["projects add-iam-policy-binding"] = [
|
|
643
|
+
fail("ERROR: (gcloud.projects.add-iam-policy-binding) ABORTED: concurrent policy changes"),
|
|
644
|
+
ok(),
|
|
645
|
+
];
|
|
646
|
+
const { run, calls } = fakeGcloud(answers);
|
|
647
|
+
const slept: number[] = [];
|
|
648
|
+
const report = await ensureStackInvariants(firebase, {
|
|
649
|
+
run,
|
|
650
|
+
sleep: async (ms) => {
|
|
651
|
+
slept.push(ms);
|
|
652
|
+
},
|
|
653
|
+
});
|
|
654
|
+
expect(report.mustAbort).toBe(false);
|
|
655
|
+
expect(calls.filter((c) => c.startsWith("projects add-iam-policy-binding"))).toHaveLength(2);
|
|
656
|
+
expect(slept).toEqual([2_000]);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it("fails the IAP-agent invariants (abort) when the project number is unreadable", async () => {
|
|
660
|
+
const answers = fullFirebase();
|
|
661
|
+
answers["projects describe"] = [fail("PERMISSION_DENIED")];
|
|
662
|
+
const { run } = fakeGcloud(answers);
|
|
663
|
+
const report = await ensureStackInvariants(firebase, { run, sleep: async () => {} });
|
|
664
|
+
expect(report.mustAbort).toBe(true);
|
|
665
|
+
expect(report.outcomes.filter((o) => o.status === "failed")).toHaveLength(1);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
describe("gcp CDN policy (regression C3)", () => {
|
|
669
|
+
const fullGcp = () => ({
|
|
670
|
+
"projects describe": [ok("42")],
|
|
671
|
+
"projects get-iam-policy": [
|
|
672
|
+
policy([
|
|
673
|
+
{ role: "roles/logging.logWriter", members: [RUN_SA] },
|
|
674
|
+
{ role: "roles/monitoring.metricWriter", members: [RUN_SA] },
|
|
675
|
+
...GW_TELEMETRY,
|
|
676
|
+
]),
|
|
677
|
+
],
|
|
678
|
+
"secrets get-iam-policy": [
|
|
679
|
+
policy([{ role: "roles/secretmanager.secretAccessor", members: [RUN_SA, GW_SA] }]),
|
|
680
|
+
],
|
|
681
|
+
"storage buckets get-iam-policy": [
|
|
682
|
+
policy([
|
|
683
|
+
{
|
|
684
|
+
role: "roles/storage.objectAdmin",
|
|
685
|
+
members: [RUN_SA, "serviceAccount:caelo-prod-publisher@acme.iam.gserviceaccount.com"],
|
|
686
|
+
},
|
|
687
|
+
{ role: "roles/storage.objectViewer", members: ["allUsers"] },
|
|
688
|
+
]),
|
|
689
|
+
],
|
|
690
|
+
"run services get-iam-policy": [
|
|
691
|
+
policy([{ role: "roles/run.invoker", members: [IAP_AGENT] }]),
|
|
692
|
+
],
|
|
693
|
+
"compute backend-buckets list": [ok("caelo-production-static-backend-9f8e7d6\n")],
|
|
694
|
+
"compute firewall-rules list": [ok("[]")],
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it("raises the pre-#555 1h/24h TTLs to a year", async () => {
|
|
698
|
+
const answers = {
|
|
699
|
+
...fullGcp(),
|
|
700
|
+
"compute backend-buckets describe": [
|
|
701
|
+
ok(
|
|
702
|
+
JSON.stringify({
|
|
703
|
+
cdnPolicy: {
|
|
704
|
+
cacheMode: "CACHE_ALL_STATIC",
|
|
705
|
+
defaultTtl: 3600,
|
|
706
|
+
maxTtl: 86400,
|
|
707
|
+
clientTtl: 3600,
|
|
708
|
+
},
|
|
709
|
+
}),
|
|
710
|
+
),
|
|
711
|
+
],
|
|
712
|
+
};
|
|
713
|
+
const { run, calls } = fakeGcloud(answers);
|
|
714
|
+
const report = await ensureStackInvariants(gcp, { run, sleep: async () => {} });
|
|
715
|
+
expect(report.mustAbort).toBe(false);
|
|
716
|
+
expect(calls).toContain(
|
|
717
|
+
`compute backend-buckets update caelo-production-static-backend-9f8e7d6 --project=acme --cache-mode=CACHE_ALL_STATIC --default-ttl=3600 --max-ttl=${STATIC_CDN_POLICY.maxTtl} --client-ttl=${STATIC_CDN_POLICY.clientTtl} --quiet`,
|
|
718
|
+
);
|
|
719
|
+
expect(report.outcomes.find((o) => o.id.startsWith("Cloud CDN"))?.status).toBe("applied");
|
|
720
|
+
// Every IAM invariant was already present.
|
|
721
|
+
expect(calls.some((c) => c.includes("add-iam-policy-binding"))).toBe(false);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it("leaves a backend bucket already at the stack's policy alone", async () => {
|
|
725
|
+
const answers = {
|
|
726
|
+
...fullGcp(),
|
|
727
|
+
"compute backend-buckets describe": [ok(JSON.stringify({ cdnPolicy: STATIC_CDN_POLICY }))],
|
|
728
|
+
};
|
|
729
|
+
const { run, calls } = fakeGcloud(answers);
|
|
730
|
+
const report = await ensureStackInvariants(gcp, { run, sleep: async () => {} });
|
|
731
|
+
expect(calls.some((c) => c.startsWith("compute backend-buckets update"))).toBe(false);
|
|
732
|
+
expect(report.outcomes.every((o) => o.status === "present")).toBe(true);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it("warns (no abort) when the CDN update fails", async () => {
|
|
736
|
+
const answers = {
|
|
737
|
+
...fullGcp(),
|
|
738
|
+
"compute backend-buckets describe": [ok(JSON.stringify({ cdnPolicy: {} }))],
|
|
739
|
+
"compute backend-buckets update": [fail("PERMISSION_DENIED")],
|
|
740
|
+
};
|
|
741
|
+
const { run } = fakeGcloud(answers);
|
|
742
|
+
const report = await ensureStackInvariants(gcp, { run, sleep: async () => {} });
|
|
743
|
+
expect(report.mustAbort).toBe(false);
|
|
744
|
+
expect(report.outcomes.find((o) => o.id.startsWith("Cloud CDN"))?.status).toBe("failed");
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it("addresses buckets, secrets and the static-publisher SA by the stack's names", async () => {
|
|
748
|
+
const answers = {
|
|
749
|
+
...fullGcp(),
|
|
750
|
+
"storage buckets get-iam-policy": [policy([])],
|
|
751
|
+
"secrets get-iam-policy": [policy([])],
|
|
752
|
+
"compute backend-buckets describe": [ok(JSON.stringify({ cdnPolicy: STATIC_CDN_POLICY }))],
|
|
753
|
+
};
|
|
754
|
+
const { run, calls } = fakeGcloud(answers);
|
|
755
|
+
await ensureStackInvariants(gcp, { run, sleep: async () => {} });
|
|
756
|
+
expect(calls).toContain(
|
|
757
|
+
"storage buckets add-iam-policy-binding gs://acme-caelo-production-staging --project=acme --member=" +
|
|
758
|
+
`${RUN_SA} --role=roles/storage.objectAdmin --quiet --format=none`,
|
|
759
|
+
);
|
|
760
|
+
expect(calls).toContain(
|
|
761
|
+
"storage buckets add-iam-policy-binding gs://acme-caelo-production-static --project=acme --member=allUsers --role=roles/storage.objectViewer --quiet --format=none",
|
|
762
|
+
);
|
|
763
|
+
expect(calls).toContain(
|
|
764
|
+
`secrets add-iam-policy-binding caelo-production-secret-kek --project=acme --member=${RUN_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
|
|
765
|
+
);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it("grants the gateway SA its own role passwords and nothing admin-only", async () => {
|
|
769
|
+
const answers = {
|
|
770
|
+
...fullGcp(),
|
|
771
|
+
"secrets get-iam-policy": [policy([])],
|
|
772
|
+
"compute backend-buckets describe": [ok(JSON.stringify({ cdnPolicy: STATIC_CDN_POLICY }))],
|
|
773
|
+
};
|
|
774
|
+
const { run, calls } = fakeGcloud(answers);
|
|
775
|
+
await ensureStackInvariants(gcp, { run, sleep: async () => {} });
|
|
776
|
+
const gatewayGrants = calls.filter(
|
|
777
|
+
(c) => c.startsWith("secrets add-iam-policy-binding") && c.includes(GW_SA),
|
|
778
|
+
);
|
|
779
|
+
// #613 — never admin_role's postgres-password. #650 — the staging
|
|
780
|
+
// gate key is the one secret the gateway shares with the admin.
|
|
781
|
+
expect(gatewayGrants).toEqual([
|
|
782
|
+
`secrets add-iam-policy-binding caelo-production-gateway-role-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
|
|
783
|
+
`secrets add-iam-policy-binding caelo-production-public-role-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
|
|
784
|
+
`secrets add-iam-policy-binding caelo-production-staging-gate-secret --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
|
|
785
|
+
]);
|
|
786
|
+
for (const secret of ["internal-secret", "tool-approval-secret", "secret-kek"]) {
|
|
787
|
+
expect(calls).toContain(
|
|
788
|
+
`secrets add-iam-policy-binding caelo-production-${secret} --project=acme --member=${RUN_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
describe("removeRetiredIamBindings (#613)", () => {
|
|
796
|
+
const REMOVE = `secrets remove-iam-policy-binding caelo-production-postgres-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`;
|
|
797
|
+
|
|
798
|
+
it("takes the gateway SA off admin_role's password", async () => {
|
|
799
|
+
const { run, calls } = fakeGcloud({
|
|
800
|
+
"secrets get-iam-policy caelo-production-postgres-password": [
|
|
801
|
+
policy([{ role: "roles/secretmanager.secretAccessor", members: [RUN_SA, GW_SA] }]),
|
|
802
|
+
],
|
|
803
|
+
});
|
|
804
|
+
const outcomes = await removeRetiredIamBindings(gcp, { run, sleep: async () => {} });
|
|
805
|
+
expect(outcomes.map((o) => o.status)).toEqual(["removed"]);
|
|
806
|
+
expect(calls).toContain(REMOVE);
|
|
807
|
+
// The admin's own binding on the same secret is never touched.
|
|
808
|
+
expect(calls.some((c) => c.includes("remove") && c.includes(RUN_SA))).toBe(false);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
it("is a read-only no-op on an install that is already clean", async () => {
|
|
812
|
+
const { run, calls } = fakeGcloud({
|
|
813
|
+
"secrets get-iam-policy caelo-production-postgres-password": [
|
|
814
|
+
policy([{ role: "roles/secretmanager.secretAccessor", members: [RUN_SA] }]),
|
|
815
|
+
],
|
|
816
|
+
});
|
|
817
|
+
const outcomes = await removeRetiredIamBindings(firebase, { run, sleep: async () => {} });
|
|
818
|
+
expect(outcomes.map((o) => o.status)).toEqual(["absent"]);
|
|
819
|
+
expect(calls.filter((c) => c.includes("remove-iam-policy-binding"))).toEqual([]);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it("reports a failed removal instead of throwing", async () => {
|
|
823
|
+
const { run } = fakeGcloud({
|
|
824
|
+
"secrets get-iam-policy caelo-production-postgres-password": [
|
|
825
|
+
policy([{ role: "roles/secretmanager.secretAccessor", members: [GW_SA] }]),
|
|
826
|
+
],
|
|
827
|
+
"secrets remove-iam-policy-binding": [fail("PERMISSION_DENIED: setIamPolicy")],
|
|
828
|
+
});
|
|
829
|
+
const [o] = await removeRetiredIamBindings(gcp, { run, sleep: async () => {} });
|
|
830
|
+
expect(o?.status).toBe("failed");
|
|
831
|
+
expect(o?.error).toContain("PERMISSION_DENIED");
|
|
832
|
+
});
|
|
833
|
+
});
|