@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,58 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Issue #37 — new installs must get MCP-through-IAP from Pulumi alone, with the
|
|
5
|
+
* same names `cms-provision upgrade` uses for older installs (mcp-iap.ts).
|
|
6
|
+
* Pure-string checks on the stack programs, like the other stack tests.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import { mcpIapServiceAccountEmail } from "./gcp-names.js";
|
|
13
|
+
import { adminEnvContract, databaseUrls } from "./stack-contract.js";
|
|
14
|
+
|
|
15
|
+
const stack = (provider: string) =>
|
|
16
|
+
readFileSync(resolve(import.meta.dir, `../stacks/${provider}/index.ts`), "utf8");
|
|
17
|
+
|
|
18
|
+
describe.each(["gcp", "gcp-firebase"])("%s stack — MCP through IAP", (provider) => {
|
|
19
|
+
const src = stack(provider);
|
|
20
|
+
|
|
21
|
+
it("creates the MCP service account under the id upgrade uses, tolerating one upgrade created", () => {
|
|
22
|
+
expect(src).toContain("accountId: MCP_IAP_SERVICE_ACCOUNT_ID,");
|
|
23
|
+
expect(src).toContain("createIgnoreAlreadyExists: true");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("lets every IAP-allowlisted principal sign as it", () => {
|
|
27
|
+
expect(src).toMatch(
|
|
28
|
+
/for \(const principal of iapAllowlist\) \{[\s\S]*?serviceaccount\.IAMMember\([\s\S]*?roles\/iam\.serviceAccountTokenCreator/,
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("allowlists the service account on the admin's IAP resource", () => {
|
|
33
|
+
const iapType =
|
|
34
|
+
provider === "gcp" ? "WebBackendServiceIamMember" : "WebCloudRunServiceIamMember";
|
|
35
|
+
expect(src).toMatch(
|
|
36
|
+
new RegExp(
|
|
37
|
+
`${iapType}\\(\\s*\`\\$\\{namePrefix\\}-admin-iap-mcp\`[\\s\\S]*?roles/iap\\.httpsResourceAccessor`,
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("hands the email to the admin for the /settings/mcp command (via the env contract)", () => {
|
|
43
|
+
// The stack builds the admin env from adminEnvContract (parity test);
|
|
44
|
+
// the contract carries the SA upgrade ensures.
|
|
45
|
+
const env = adminEnvContract({
|
|
46
|
+
provider: "gcp",
|
|
47
|
+
projectId: "p",
|
|
48
|
+
env: "production",
|
|
49
|
+
domain: "d.com",
|
|
50
|
+
region: "r",
|
|
51
|
+
databaseUrls: databaseUrls("10.0.0.3"),
|
|
52
|
+
});
|
|
53
|
+
expect(env).toContainEqual({
|
|
54
|
+
name: "CAELO_MCP_IAP_SERVICE_ACCOUNT",
|
|
55
|
+
value: mcpIapServiceAccountEmail("p"),
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
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 { mcpIapServiceAccountEmail } from "./gcp-names.js";
|
|
6
|
+
import { ensureMcpIapAccess, iapOperators } from "./mcp-iap.js";
|
|
7
|
+
|
|
8
|
+
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
9
|
+
const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
|
|
10
|
+
|
|
11
|
+
const POLICY = JSON.stringify({
|
|
12
|
+
bindings: [
|
|
13
|
+
{
|
|
14
|
+
role: "roles/iap.httpsResourceAccessor",
|
|
15
|
+
members: [
|
|
16
|
+
"user:owner@example.com",
|
|
17
|
+
"group:editors@example.com",
|
|
18
|
+
"serviceAccount:caelo-mcp@p.iam.gserviceaccount.com",
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
{ role: "roles/iap.admin", members: ["user:admin@example.com"] },
|
|
22
|
+
],
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** Fake gcloud answering by command prefix; records every call. */
|
|
26
|
+
function fakeGcloud(answers: Record<string, GcloudResult[]>) {
|
|
27
|
+
const calls: string[][] = [];
|
|
28
|
+
const run = async (args: string[]) => {
|
|
29
|
+
calls.push(args);
|
|
30
|
+
const key = Object.keys(answers).find((k) => args.join(" ").startsWith(k));
|
|
31
|
+
return (key && answers[key]?.shift()) || ok();
|
|
32
|
+
};
|
|
33
|
+
return { run, calls };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const cloudRun = {
|
|
37
|
+
kind: "cloud-run",
|
|
38
|
+
service: "caelo-production-admin-abc",
|
|
39
|
+
region: "europe-west1",
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
describe("iapOperators", () => {
|
|
43
|
+
it("takes users and groups with IAP access, not service accounts or other roles", () => {
|
|
44
|
+
expect(iapOperators(POLICY)).toEqual(["user:owner@example.com", "group:editors@example.com"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("ignores conditional bindings and empty policies", () => {
|
|
48
|
+
expect(
|
|
49
|
+
iapOperators(
|
|
50
|
+
JSON.stringify({
|
|
51
|
+
bindings: [
|
|
52
|
+
{
|
|
53
|
+
role: "roles/iap.httpsResourceAccessor",
|
|
54
|
+
members: ["user:x@y"],
|
|
55
|
+
condition: { title: "t" },
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
}),
|
|
59
|
+
),
|
|
60
|
+
).toEqual([]);
|
|
61
|
+
expect(iapOperators("{}")).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("ensureMcpIapAccess", () => {
|
|
66
|
+
it("creates the SA, allowlists it on IAP and lets every IAP operator sign as it", async () => {
|
|
67
|
+
const { run, calls } = fakeGcloud({
|
|
68
|
+
"iam service-accounts describe": [fail("NOT_FOUND")],
|
|
69
|
+
"iap web get-iam-policy": [ok(POLICY)],
|
|
70
|
+
});
|
|
71
|
+
const r = await ensureMcpIapAccess({
|
|
72
|
+
projectId: "p",
|
|
73
|
+
resource: cloudRun,
|
|
74
|
+
run,
|
|
75
|
+
sleep: async () => {},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(r).toEqual({
|
|
79
|
+
ok: true,
|
|
80
|
+
serviceAccount: mcpIapServiceAccountEmail("p"),
|
|
81
|
+
operators: ["user:owner@example.com", "group:editors@example.com"],
|
|
82
|
+
});
|
|
83
|
+
const lines = calls.map((c) => c.join(" "));
|
|
84
|
+
expect(lines.some((l) => l.startsWith("iam service-accounts create caelo-mcp"))).toBe(true);
|
|
85
|
+
expect(lines).toContainEqual(
|
|
86
|
+
"iap web add-iam-policy-binding --resource-type=cloud-run --service=caelo-production-admin-abc --region=europe-west1 --member=serviceAccount:caelo-mcp@p.iam.gserviceaccount.com --role=roles/iap.httpsResourceAccessor --condition=None --project=p --quiet",
|
|
87
|
+
);
|
|
88
|
+
for (const m of ["user:owner@example.com", "group:editors@example.com"]) {
|
|
89
|
+
expect(lines).toContainEqual(
|
|
90
|
+
`iam service-accounts add-iam-policy-binding caelo-mcp@p.iam.gserviceaccount.com --member=${m} --role=roles/iam.serviceAccountTokenCreator --condition=None --project=p --quiet`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("is a no-op re-run when the SA exists (no create)", async () => {
|
|
96
|
+
const { run, calls } = fakeGcloud({ "iap web get-iam-policy": [ok(POLICY)] });
|
|
97
|
+
const r = await ensureMcpIapAccess({ projectId: "p", resource: cloudRun, run });
|
|
98
|
+
expect(r.ok).toBe(true);
|
|
99
|
+
expect(calls.some((c) => c.join(" ").startsWith("iam service-accounts create"))).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("addresses the LB backend service on the gcp provider", async () => {
|
|
103
|
+
const { run, calls } = fakeGcloud({ "iap web get-iam-policy": [ok(POLICY)] });
|
|
104
|
+
await ensureMcpIapAccess({
|
|
105
|
+
projectId: "p",
|
|
106
|
+
resource: { kind: "backend-services", service: "caelo-production-admin-backend-1a2b" },
|
|
107
|
+
run,
|
|
108
|
+
});
|
|
109
|
+
const iapCalls = calls.filter((c) => c[0] === "iap");
|
|
110
|
+
for (const c of iapCalls) {
|
|
111
|
+
expect(c).toContain("--resource-type=backend-services");
|
|
112
|
+
expect(c).toContain("--service=caelo-production-admin-backend-1a2b");
|
|
113
|
+
expect(c.some((a) => a.startsWith("--region"))).toBe(false);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("retries bindings while a new SA propagates", async () => {
|
|
118
|
+
const { run, calls } = fakeGcloud({
|
|
119
|
+
"iam service-accounts describe": [fail("NOT_FOUND")],
|
|
120
|
+
"iap web get-iam-policy": [ok(POLICY)],
|
|
121
|
+
"iap web add-iam-policy-binding": [fail("Service account caelo-mcp@p does not exist.")],
|
|
122
|
+
});
|
|
123
|
+
const slept: number[] = [];
|
|
124
|
+
const r = await ensureMcpIapAccess({
|
|
125
|
+
projectId: "p",
|
|
126
|
+
resource: cloudRun,
|
|
127
|
+
run,
|
|
128
|
+
sleep: async (ms) => {
|
|
129
|
+
slept.push(ms);
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
expect(r.ok).toBe(true);
|
|
133
|
+
expect(
|
|
134
|
+
calls.filter((c) => c.join(" ").startsWith("iap web add-iam-policy-binding")),
|
|
135
|
+
).toHaveLength(2);
|
|
136
|
+
expect(slept).toEqual([2_000]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("reports a failure instead of half-configuring silently", async () => {
|
|
140
|
+
const { run } = fakeGcloud({
|
|
141
|
+
"iap web get-iam-policy": [fail("PERMISSION_DENIED: iap.web.getIamPolicy")],
|
|
142
|
+
});
|
|
143
|
+
const r = await ensureMcpIapAccess({ projectId: "p", resource: cloudRun, run });
|
|
144
|
+
expect(r).toEqual({
|
|
145
|
+
ok: false,
|
|
146
|
+
error: "read IAP policy: PERMISSION_DENIED: iap.web.getIamPolicy",
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
});
|
package/src/mcp-iap.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MCP access through IAP (issue #37), for installs that exist already.
|
|
5
|
+
*
|
|
6
|
+
* External MCP clients reach the IAP-protected admin with a JWT signed by a
|
|
7
|
+
* dedicated service account (see packages/mcp-server/src/ingress-auth.ts).
|
|
8
|
+
* New installs get that SA from the Pulumi stacks; `cms-provision upgrade`
|
|
9
|
+
* calls {@link ensureMcpIapAccess} so installs provisioned before it get the
|
|
10
|
+
* same setup on their next upgrade, with no operator config:
|
|
11
|
+
*
|
|
12
|
+
* 1. service account `caelo-mcp@<project>` (created if missing)
|
|
13
|
+
* 2. `roles/iap.httpsResourceAccessor` for that SA on the admin's IAP
|
|
14
|
+
* resource (Cloud Run service on gcp-firebase, LB backend on gcp)
|
|
15
|
+
* 3. `roles/iam.serviceAccountTokenCreator` on that SA for every user /
|
|
16
|
+
* group the IAP policy already lets in — exactly the people who can
|
|
17
|
+
* open the admin in a browser can also drive it over MCP
|
|
18
|
+
*
|
|
19
|
+
* Every step is an additive IAM binding or a create-if-missing, so re-running
|
|
20
|
+
* is a no-op. The caller puts the returned email into the admin's
|
|
21
|
+
* `CAELO_MCP_IAP_SERVICE_ACCOUNT` env var, which /settings/mcp shows in the
|
|
22
|
+
* `claude mcp add` command.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { gcloud as defaultGcloud } from "./gcloud.js";
|
|
26
|
+
import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
|
|
27
|
+
import { MCP_IAP_SERVICE_ACCOUNT_ID, mcpIapServiceAccountEmail } from "./gcp-names.js";
|
|
28
|
+
|
|
29
|
+
/** The admin's IAP resource, as `gcloud iap web` addresses it. */
|
|
30
|
+
export type IapResource =
|
|
31
|
+
| { readonly kind: "cloud-run"; readonly service: string; readonly region: string }
|
|
32
|
+
| { readonly kind: "backend-services"; readonly service: string };
|
|
33
|
+
|
|
34
|
+
/** `gcloud iap web …` flags addressing {@link IapResource}. */
|
|
35
|
+
export function iapResourceArgs(resource: IapResource): string[] {
|
|
36
|
+
return resource.kind === "cloud-run"
|
|
37
|
+
? ["--resource-type=cloud-run", `--service=${resource.service}`, `--region=${resource.region}`]
|
|
38
|
+
: ["--resource-type=backend-services", `--service=${resource.service}`];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Users and groups IAP lets into the admin (service accounts excluded). */
|
|
42
|
+
export function iapOperators(policyJson: string): string[] {
|
|
43
|
+
const policy = JSON.parse(policyJson) as {
|
|
44
|
+
bindings?: { role: string; members?: string[]; condition?: unknown }[];
|
|
45
|
+
};
|
|
46
|
+
const members = (policy.bindings ?? [])
|
|
47
|
+
.filter((b) => b.role === "roles/iap.httpsResourceAccessor" && !b.condition)
|
|
48
|
+
.flatMap((b) => b.members ?? []);
|
|
49
|
+
return [...new Set(members.filter((m) => m.startsWith("user:") || m.startsWith("group:")))];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function ensureMcpIapAccess(opts: {
|
|
53
|
+
projectId: string;
|
|
54
|
+
resource: IapResource;
|
|
55
|
+
run?: GcloudRunner;
|
|
56
|
+
sleep?: Sleep;
|
|
57
|
+
}): Promise<
|
|
58
|
+
{ ok: true; serviceAccount: string; operators: string[] } | { ok: false; error: string }
|
|
59
|
+
> {
|
|
60
|
+
const run = opts.run ?? defaultGcloud;
|
|
61
|
+
const sleep = opts.sleep ?? realSleep;
|
|
62
|
+
const project = `--project=${opts.projectId}`;
|
|
63
|
+
const sa = mcpIapServiceAccountEmail(opts.projectId);
|
|
64
|
+
|
|
65
|
+
// A freshly created SA takes a few seconds to become bindable.
|
|
66
|
+
const withRetry = (args: string[]) => runWithRetry(run, sleep, args);
|
|
67
|
+
|
|
68
|
+
const describe = await run([
|
|
69
|
+
"iam",
|
|
70
|
+
"service-accounts",
|
|
71
|
+
"describe",
|
|
72
|
+
sa,
|
|
73
|
+
project,
|
|
74
|
+
"--format=value(email)",
|
|
75
|
+
]);
|
|
76
|
+
if (!describe.ok) {
|
|
77
|
+
const create = await run([
|
|
78
|
+
"iam",
|
|
79
|
+
"service-accounts",
|
|
80
|
+
"create",
|
|
81
|
+
MCP_IAP_SERVICE_ACCOUNT_ID,
|
|
82
|
+
"--display-name=Caelo MCP (IAP ingress)",
|
|
83
|
+
"--description=Signs the IAP credential external MCP clients use to reach the admin (issue #37).",
|
|
84
|
+
project,
|
|
85
|
+
]);
|
|
86
|
+
if (!create.ok && !/already exists/i.test(create.stderr)) {
|
|
87
|
+
return { ok: false, error: `create ${sa}: ${create.stderr.trim()}` };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const policy = await run([
|
|
92
|
+
"iap",
|
|
93
|
+
"web",
|
|
94
|
+
"get-iam-policy",
|
|
95
|
+
...iapResourceArgs(opts.resource),
|
|
96
|
+
project,
|
|
97
|
+
"--format=json",
|
|
98
|
+
]);
|
|
99
|
+
if (!policy.ok) return { ok: false, error: `read IAP policy: ${policy.stderr.trim()}` };
|
|
100
|
+
const operators = iapOperators(policy.stdout);
|
|
101
|
+
|
|
102
|
+
const allow = await withRetry([
|
|
103
|
+
"iap",
|
|
104
|
+
"web",
|
|
105
|
+
"add-iam-policy-binding",
|
|
106
|
+
...iapResourceArgs(opts.resource),
|
|
107
|
+
`--member=serviceAccount:${sa}`,
|
|
108
|
+
"--role=roles/iap.httpsResourceAccessor",
|
|
109
|
+
"--condition=None",
|
|
110
|
+
project,
|
|
111
|
+
"--quiet",
|
|
112
|
+
]);
|
|
113
|
+
if (!allow.ok) return { ok: false, error: `allow ${sa} on IAP: ${allow.stderr.trim()}` };
|
|
114
|
+
|
|
115
|
+
for (const member of operators) {
|
|
116
|
+
const grant = await withRetry([
|
|
117
|
+
"iam",
|
|
118
|
+
"service-accounts",
|
|
119
|
+
"add-iam-policy-binding",
|
|
120
|
+
sa,
|
|
121
|
+
`--member=${member}`,
|
|
122
|
+
"--role=roles/iam.serviceAccountTokenCreator",
|
|
123
|
+
"--condition=None",
|
|
124
|
+
project,
|
|
125
|
+
"--quiet",
|
|
126
|
+
]);
|
|
127
|
+
if (!grant.ok)
|
|
128
|
+
return { ok: false, error: `let ${member} sign as ${sa}: ${grant.stderr.trim()}` };
|
|
129
|
+
}
|
|
130
|
+
return { ok: true, serviceAccount: sa, operators };
|
|
131
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The admin's media volume: upgrade gives existing GCP installs the media
|
|
5
|
+
* bucket as a Cloud Storage volume, the same shape the stacks deploy, and
|
|
6
|
+
* adds only what a service is missing. Regression for media living on the
|
|
7
|
+
* Cloud Run container's in-memory filesystem (lost on every new revision and
|
|
8
|
+
* scale-to-zero; Stage then failed with "storage object missing").
|
|
9
|
+
*
|
|
10
|
+
* The service JSON below is what `gcloud run services describe --format=json`
|
|
11
|
+
* returns (Knative serving.knative.dev/v1), i.e. the fake gcloud's describe
|
|
12
|
+
* output for each install state.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, it } from "bun:test";
|
|
16
|
+
import {
|
|
17
|
+
adminEnvContract,
|
|
18
|
+
adminMediaVolume,
|
|
19
|
+
adminMediaVolumeTemplate,
|
|
20
|
+
databaseUrls,
|
|
21
|
+
MEDIA_MOUNT_PATH,
|
|
22
|
+
} from "./stack-contract.js";
|
|
23
|
+
import { liveVolumes, planMediaVolume, serviceRollArgs } from "./stack-converge.js";
|
|
24
|
+
|
|
25
|
+
const BUCKET = "acme-caelo-production-media";
|
|
26
|
+
const desired = adminMediaVolume("acme", "production");
|
|
27
|
+
|
|
28
|
+
/** A described admin service with the given template parts. */
|
|
29
|
+
function describedAdmin(parts: {
|
|
30
|
+
executionEnvironment?: string;
|
|
31
|
+
volumes?: unknown[];
|
|
32
|
+
mounts?: unknown[];
|
|
33
|
+
}): string {
|
|
34
|
+
return JSON.stringify({
|
|
35
|
+
apiVersion: "serving.knative.dev/v1",
|
|
36
|
+
kind: "Service",
|
|
37
|
+
metadata: { name: "caelo-production-admin-aaa" },
|
|
38
|
+
spec: {
|
|
39
|
+
template: {
|
|
40
|
+
metadata: {
|
|
41
|
+
annotations: {
|
|
42
|
+
"autoscaling.knative.dev/maxScale": "3",
|
|
43
|
+
...(parts.executionEnvironment
|
|
44
|
+
? { "run.googleapis.com/execution-environment": parts.executionEnvironment }
|
|
45
|
+
: {}),
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
spec: {
|
|
49
|
+
serviceAccountName: "caelo-production-run-sa@acme.iam.gserviceaccount.com",
|
|
50
|
+
containers: [
|
|
51
|
+
{
|
|
52
|
+
image: "img@sha256:1",
|
|
53
|
+
env: [{ name: "CAELO_PROVIDER", value: "gcp-firebase" }],
|
|
54
|
+
...(parts.mounts ? { volumeMounts: parts.mounts } : {}),
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
...(parts.volumes ? { volumes: parts.volumes } : {}),
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const gcsVolume = (bucket: string, extra: Record<string, unknown> = {}) => ({
|
|
65
|
+
name: "media",
|
|
66
|
+
csi: {
|
|
67
|
+
driver: "gcsfuse.run.googleapis.com",
|
|
68
|
+
volumeAttributes: { bucketName: bucket },
|
|
69
|
+
...extra,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
const mediaMount = { name: "media", mountPath: MEDIA_MOUNT_PATH };
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A fake `gcloud run services update` for the volume flags: applies them to
|
|
76
|
+
* a described service the way Cloud Run does, and fails like gcloud on a
|
|
77
|
+
* duplicate volume name or mount path.
|
|
78
|
+
*/
|
|
79
|
+
function fakeGcloudServicesUpdate(serviceJson: string, argv: readonly string[]): string {
|
|
80
|
+
const svc = JSON.parse(serviceJson);
|
|
81
|
+
const template = svc.spec.template;
|
|
82
|
+
const container = template.spec.containers[0];
|
|
83
|
+
const kv = (v: string) =>
|
|
84
|
+
Object.fromEntries(v.split(",").map((p) => p.split("=") as [string, string]));
|
|
85
|
+
// Accept both `--flag=value` and `--flag value`.
|
|
86
|
+
const flags: [string, string][] = [];
|
|
87
|
+
for (let i = 0; i < argv.length; i++) {
|
|
88
|
+
const a = argv[i] ?? "";
|
|
89
|
+
const eq = a.indexOf("=");
|
|
90
|
+
if (eq > 0) flags.push([a.slice(0, eq), a.slice(eq + 1)]);
|
|
91
|
+
else flags.push([a, argv[++i] ?? ""]);
|
|
92
|
+
}
|
|
93
|
+
for (const [flag, value] of flags) {
|
|
94
|
+
if (flag === "--execution-environment") {
|
|
95
|
+
template.metadata.annotations["run.googleapis.com/execution-environment"] = value;
|
|
96
|
+
} else if (flag === "--add-volume") {
|
|
97
|
+
const v = kv(value);
|
|
98
|
+
template.spec.volumes ??= [];
|
|
99
|
+
if (template.spec.volumes.some((x: { name: string }) => x.name === v.name)) {
|
|
100
|
+
throw new Error(`ERROR: (gcloud.run.services.update) volume ${v.name} already exists`);
|
|
101
|
+
}
|
|
102
|
+
expect(v.type).toBe("cloud-storage");
|
|
103
|
+
template.spec.volumes.push({
|
|
104
|
+
name: v.name,
|
|
105
|
+
csi: { driver: "gcsfuse.run.googleapis.com", volumeAttributes: { bucketName: v.bucket } },
|
|
106
|
+
});
|
|
107
|
+
} else if (flag === "--add-volume-mount") {
|
|
108
|
+
const m = kv(value);
|
|
109
|
+
container.volumeMounts ??= [];
|
|
110
|
+
if (
|
|
111
|
+
container.volumeMounts.some((x: { mountPath: string }) => x.mountPath === m["mount-path"])
|
|
112
|
+
) {
|
|
113
|
+
throw new Error(`ERROR: (gcloud.run.services.update) mount path ${m["mount-path"]} in use`);
|
|
114
|
+
}
|
|
115
|
+
container.volumeMounts.push({ name: m.volume, mountPath: m["mount-path"] });
|
|
116
|
+
} else {
|
|
117
|
+
throw new Error(`fake gcloud: unexpected flag ${flag}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return JSON.stringify(svc);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** What the operator's manual hotfix leaves on the service. */
|
|
124
|
+
const HOTFIXED = describedAdmin({
|
|
125
|
+
executionEnvironment: "gen2",
|
|
126
|
+
volumes: [gcsVolume(BUCKET)],
|
|
127
|
+
mounts: [mediaMount],
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("admin media volume contract", () => {
|
|
131
|
+
it("is the install's media bucket at the admin's default media root", () => {
|
|
132
|
+
expect(desired).toEqual({
|
|
133
|
+
volumeName: "media",
|
|
134
|
+
bucket: BUCKET,
|
|
135
|
+
mountPath: "/app/apps/admin/data/media",
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("the stacks deploy it gen2, read-write, mounted on the admin container", () => {
|
|
140
|
+
expect(adminMediaVolumeTemplate(desired)).toEqual({
|
|
141
|
+
executionEnvironment: "EXECUTION_ENVIRONMENT_GEN2",
|
|
142
|
+
volumes: [{ name: "media", gcs: { bucket: BUCKET, readOnly: false } }],
|
|
143
|
+
volumeMounts: [{ name: "media", mountPath: MEDIA_MOUNT_PATH }],
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("the admin env points MEDIA_ROOT_DIR at the mount", () => {
|
|
148
|
+
const env = adminEnvContract({
|
|
149
|
+
provider: "gcp",
|
|
150
|
+
projectId: "acme",
|
|
151
|
+
env: "production",
|
|
152
|
+
domain: "acme.com",
|
|
153
|
+
stagingHost: "staging.acme.com",
|
|
154
|
+
region: "europe-west1",
|
|
155
|
+
databaseUrls: databaseUrls("10.0.0.3"),
|
|
156
|
+
});
|
|
157
|
+
expect(env).toContainEqual({ name: "MEDIA_ROOT_DIR", value: MEDIA_MOUNT_PATH });
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("planMediaVolume (upgrade)", () => {
|
|
162
|
+
it("regression: an install whose admin has no volume gets gen2 + the volume + the mount", () => {
|
|
163
|
+
const plan = planMediaVolume(liveVolumes(describedAdmin({})), desired);
|
|
164
|
+
expect(plan).toEqual({
|
|
165
|
+
ok: true,
|
|
166
|
+
flags: [
|
|
167
|
+
"--execution-environment=gen2",
|
|
168
|
+
`--add-volume=name=media,type=cloud-storage,bucket=${BUCKET}`,
|
|
169
|
+
`--add-volume-mount=volume=media,mount-path=${MEDIA_MOUNT_PATH}`,
|
|
170
|
+
],
|
|
171
|
+
changes: expect.any(Array),
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("treats the operator's identical manual hotfix as converged (no flags, no new volume)", () => {
|
|
176
|
+
expect(planMediaVolume(liveVolumes(HOTFIXED), desired)).toEqual({
|
|
177
|
+
ok: true,
|
|
178
|
+
flags: [],
|
|
179
|
+
changes: [],
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("is idempotent: once the fake gcloud applied the roll, the next upgrade adds nothing", () => {
|
|
184
|
+
const first = planMediaVolume(liveVolumes(describedAdmin({})), desired);
|
|
185
|
+
if (!first.ok) throw new Error(first.error);
|
|
186
|
+
const rolled = fakeGcloudServicesUpdate(describedAdmin({}), first.flags);
|
|
187
|
+
expect(planMediaVolume(liveVolumes(rolled), desired)).toEqual({
|
|
188
|
+
ok: true,
|
|
189
|
+
flags: [],
|
|
190
|
+
changes: [],
|
|
191
|
+
});
|
|
192
|
+
// The upgrade flags and the operator's manual hotfix produce the same service.
|
|
193
|
+
const hotfix = fakeGcloudServicesUpdate(describedAdmin({}), [
|
|
194
|
+
"--execution-environment",
|
|
195
|
+
"gen2",
|
|
196
|
+
"--add-volume",
|
|
197
|
+
`name=media,type=cloud-storage,bucket=${BUCKET}`,
|
|
198
|
+
"--add-volume-mount",
|
|
199
|
+
`volume=media,mount-path=${MEDIA_MOUNT_PATH}`,
|
|
200
|
+
]);
|
|
201
|
+
expect(liveVolumes(rolled)).toEqual(liveVolumes(hotfix));
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("keeps operator mount options on an otherwise identical volume", () => {
|
|
205
|
+
const live = describedAdmin({
|
|
206
|
+
executionEnvironment: "gen2",
|
|
207
|
+
volumes: [
|
|
208
|
+
gcsVolume(BUCKET, {
|
|
209
|
+
volumeAttributes: { bucketName: BUCKET, mountOptions: "implicit-dirs" },
|
|
210
|
+
}),
|
|
211
|
+
],
|
|
212
|
+
mounts: [mediaMount],
|
|
213
|
+
});
|
|
214
|
+
expect(planMediaVolume(liveVolumes(live), desired)).toEqual({
|
|
215
|
+
ok: true,
|
|
216
|
+
flags: [],
|
|
217
|
+
changes: [],
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("adds only what is missing", () => {
|
|
222
|
+
const gen1 = describedAdmin({
|
|
223
|
+
executionEnvironment: "gen1",
|
|
224
|
+
volumes: [gcsVolume(BUCKET)],
|
|
225
|
+
mounts: [mediaMount],
|
|
226
|
+
});
|
|
227
|
+
expect(planMediaVolume(liveVolumes(gen1), desired)).toMatchObject({
|
|
228
|
+
ok: true,
|
|
229
|
+
flags: ["--execution-environment=gen2"],
|
|
230
|
+
});
|
|
231
|
+
const noMount = describedAdmin({ executionEnvironment: "gen2", volumes: [gcsVolume(BUCKET)] });
|
|
232
|
+
expect(planMediaVolume(liveVolumes(noMount), desired)).toMatchObject({
|
|
233
|
+
ok: true,
|
|
234
|
+
flags: [`--add-volume-mount=volume=media,mount-path=${MEDIA_MOUNT_PATH}`],
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("refuses a media volume of another bucket instead of replacing it", () => {
|
|
239
|
+
const live = describedAdmin({
|
|
240
|
+
executionEnvironment: "gen2",
|
|
241
|
+
volumes: [gcsVolume("other-bucket")],
|
|
242
|
+
mounts: [mediaMount],
|
|
243
|
+
});
|
|
244
|
+
const plan = planMediaVolume(liveVolumes(live), desired);
|
|
245
|
+
expect(plan.ok).toBe(false);
|
|
246
|
+
expect(!plan.ok && plan.error).toContain("other-bucket");
|
|
247
|
+
expect(!plan.ok && plan.error).toContain(BUCKET);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("refuses a read-only or non-Cloud-Storage media volume", () => {
|
|
251
|
+
for (const volume of [
|
|
252
|
+
gcsVolume(BUCKET, { readOnly: true }),
|
|
253
|
+
{ name: "media", emptyDir: { medium: "Memory" } },
|
|
254
|
+
]) {
|
|
255
|
+
const live = describedAdmin({
|
|
256
|
+
executionEnvironment: "gen2",
|
|
257
|
+
volumes: [volume],
|
|
258
|
+
mounts: [mediaMount],
|
|
259
|
+
});
|
|
260
|
+
expect(planMediaVolume(liveVolumes(live), desired).ok).toBe(false);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("refuses another volume at the media path, and the media volume mounted elsewhere", () => {
|
|
265
|
+
const other = describedAdmin({
|
|
266
|
+
volumes: [{ name: "scratch", emptyDir: {} }],
|
|
267
|
+
mounts: [{ name: "scratch", mountPath: MEDIA_MOUNT_PATH }],
|
|
268
|
+
});
|
|
269
|
+
const otherPlan = planMediaVolume(liveVolumes(other), desired);
|
|
270
|
+
expect(!otherPlan.ok && otherPlan.error).toContain('volume "scratch"');
|
|
271
|
+
const elsewhere = describedAdmin({
|
|
272
|
+
executionEnvironment: "gen2",
|
|
273
|
+
volumes: [gcsVolume(BUCKET)],
|
|
274
|
+
mounts: [{ name: "media", mountPath: "/mnt/media" }],
|
|
275
|
+
});
|
|
276
|
+
const elsewherePlan = planMediaVolume(liveVolumes(elsewhere), desired);
|
|
277
|
+
expect(!elsewherePlan.ok && elsewherePlan.error).toContain("/mnt/media");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("rides the image roll's single services update", () => {
|
|
281
|
+
const plan = planMediaVolume(liveVolumes(describedAdmin({})), desired);
|
|
282
|
+
if (!plan.ok) throw new Error(plan.error);
|
|
283
|
+
const args = serviceRollArgs({
|
|
284
|
+
serviceName: "caelo-production-admin-aaa",
|
|
285
|
+
region: "europe-west1",
|
|
286
|
+
projectId: "acme",
|
|
287
|
+
imageRef: "img@sha256:1",
|
|
288
|
+
serviceAccount: "caelo-production-run-sa@acme.iam.gserviceaccount.com",
|
|
289
|
+
envFlags: [`--update-env-vars=MEDIA_ROOT_DIR=${MEDIA_MOUNT_PATH}`],
|
|
290
|
+
volumeFlags: plan.flags,
|
|
291
|
+
});
|
|
292
|
+
expect(args.slice(0, 3)).toEqual(["run", "services", "update"]);
|
|
293
|
+
for (const f of plan.flags) expect(args).toContain(f);
|
|
294
|
+
expect(args.at(-1)).toBe("--quiet");
|
|
295
|
+
});
|
|
296
|
+
});
|