@caelo-cms/provisioning 0.10.47 → 0.10.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/caddy/Caddyfile.production +1 -1
- package/caddy/Caddyfile.staging +1 -1
- package/dist/agent/apply.d.ts +45 -0
- package/dist/agent/apply.d.ts.map +1 -0
- package/dist/agent/apply.js +83 -0
- package/dist/agent/apply.js.map +1 -0
- package/dist/agent/cli-provenance.d.ts +42 -0
- package/dist/agent/cli-provenance.d.ts.map +1 -0
- package/dist/agent/cli-provenance.js +138 -0
- package/dist/agent/cli-provenance.js.map +1 -0
- package/dist/agent/command.d.ts +79 -0
- package/dist/agent/command.d.ts.map +1 -0
- package/dist/agent/command.js +454 -0
- package/dist/agent/command.js.map +1 -0
- package/dist/agent/config.d.ts +30 -0
- package/dist/agent/config.d.ts.map +1 -0
- package/dist/agent/config.js +60 -0
- package/dist/agent/config.js.map +1 -0
- package/dist/agent/lock.d.ts +13 -0
- package/dist/agent/lock.d.ts.map +1 -0
- package/dist/agent/lock.js +100 -0
- package/dist/agent/lock.js.map +1 -0
- package/dist/agent/queue.d.ts +35 -0
- package/dist/agent/queue.d.ts.map +1 -0
- package/dist/agent/queue.js +50 -0
- package/dist/agent/queue.js.map +1 -0
- package/dist/agent/run.d.ts +97 -0
- package/dist/agent/run.d.ts.map +1 -0
- package/dist/agent/run.js +198 -0
- package/dist/agent/run.js.map +1 -0
- package/dist/agent/systemd.d.ts +55 -0
- package/dist/agent/systemd.d.ts.map +1 -0
- package/dist/agent/systemd.js +171 -0
- package/dist/agent/systemd.js.map +1 -0
- package/dist/agent/units.d.ts +44 -0
- package/dist/agent/units.d.ts.map +1 -0
- package/dist/agent/units.js +127 -0
- package/dist/agent/units.js.map +1 -0
- package/dist/agent-db.js +18402 -0
- package/dist/cli.js +119 -16
- package/dist/cli.js.map +1 -1
- package/dist/cloudsql-iam-user.d.ts +39 -0
- package/dist/cloudsql-iam-user.d.ts.map +1 -0
- package/dist/cloudsql-iam-user.js +119 -0
- package/dist/cloudsql-iam-user.js.map +1 -0
- package/dist/compose.d.ts +16 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +26 -1
- package/dist/compose.js.map +1 -1
- package/dist/dns/index.d.ts +2 -0
- package/dist/dns/index.d.ts.map +1 -1
- package/dist/dns/index.js +3 -2
- package/dist/dns/index.js.map +1 -1
- package/dist/dns/manual.d.ts +5 -0
- package/dist/dns/manual.d.ts.map +1 -1
- package/dist/dns/manual.js +28 -12
- package/dist/dns/manual.js.map +1 -1
- package/dist/gcloud.d.ts +2 -0
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +1 -1
- package/dist/gcloud.js.map +1 -1
- package/dist/gcp-names.d.ts +15 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +23 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.js +7 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job-runner.d.ts +13 -0
- package/dist/install-job-runner.d.ts.map +1 -0
- package/dist/install-job-runner.js +155 -0
- package/dist/install-job-runner.js.map +1 -0
- package/dist/install-job.d.ts +210 -0
- package/dist/install-job.d.ts.map +1 -0
- package/dist/install-job.js +449 -0
- package/dist/install-job.js.map +1 -0
- package/dist/install-state.d.ts +10 -1
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +10 -2
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +19 -4
- package/dist/lifecycle.js.map +1 -1
- package/dist/migration-runner.d.ts +5 -0
- package/dist/migration-runner.d.ts.map +1 -1
- package/dist/migration-runner.js +1 -1
- package/dist/migration-runner.js.map +1 -1
- package/dist/operator-access.d.ts +14 -8
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +16 -102
- package/dist/operator-access.js.map +1 -1
- package/dist/release-manifest.d.ts +2 -41
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +18 -121
- package/dist/release-manifest.js.map +1 -1
- package/dist/run-mode.d.ts +27 -0
- package/dist/run-mode.d.ts.map +1 -0
- package/dist/run-mode.js +22 -0
- package/dist/run-mode.js.map +1 -0
- package/dist/runtime-secrets.d.ts +19 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +44 -0
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/self-hosted-roles.d.ts +22 -1
- package/dist/self-hosted-roles.d.ts.map +1 -1
- package/dist/self-hosted-roles.js +81 -12
- package/dist/self-hosted-roles.js.map +1 -1
- package/dist/self-hosted-upgrade.d.ts +17 -0
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +16 -1
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +6 -0
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +10 -1
- package/dist/stack-contract.js.map +1 -1
- package/dist/upgrade/converge.d.ts +6 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +59 -2
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/gcp-lookup.d.ts +6 -0
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +25 -0
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/release-images.d.ts +26 -2
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +35 -2
- package/dist/upgrade/release-images.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts +5 -0
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +28 -1
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-assess.d.ts +85 -0
- package/dist/upgrader-assess.d.ts.map +1 -0
- package/dist/upgrader-assess.js +122 -0
- package/dist/upgrader-assess.js.map +1 -0
- package/dist/upgrader-gcp.d.ts +17 -0
- package/dist/upgrader-gcp.d.ts.map +1 -0
- package/dist/upgrader-gcp.js +87 -0
- package/dist/upgrader-gcp.js.map +1 -0
- package/dist/upgrader-job.d.ts +133 -0
- package/dist/upgrader-job.d.ts.map +1 -0
- package/dist/upgrader-job.js +259 -0
- package/dist/upgrader-job.js.map +1 -0
- package/dist/upgrader-reporter.d.ts +35 -0
- package/dist/upgrader-reporter.d.ts.map +1 -0
- package/dist/upgrader-reporter.js +79 -0
- package/dist/upgrader-reporter.js.map +1 -0
- package/dist/upgrader-setup.d.ts +59 -0
- package/dist/upgrader-setup.d.ts.map +1 -0
- package/dist/upgrader-setup.js +308 -0
- package/dist/upgrader-setup.js.map +1 -0
- package/dist/wizard.d.ts +6 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +32 -4
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp-firebase.d.ts +3 -0
- package/dist/wizards/gcp-firebase.d.ts.map +1 -1
- package/dist/wizards/gcp-firebase.js +1 -0
- package/dist/wizards/gcp-firebase.js.map +1 -1
- package/dist/wizards/gcp-install-job.d.ts +74 -0
- package/dist/wizards/gcp-install-job.d.ts.map +1 -0
- package/dist/wizards/gcp-install-job.js +398 -0
- package/dist/wizards/gcp-install-job.js.map +1 -0
- package/dist/wizards/gcp-prompts.d.ts +3 -0
- package/dist/wizards/gcp-prompts.d.ts.map +1 -0
- package/dist/wizards/gcp-prompts.js +25 -0
- package/dist/wizards/gcp-prompts.js.map +1 -0
- package/dist/wizards/gcp-pulumi.d.ts +4 -2
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +1 -1
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts +40 -0
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +223 -59
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +6 -4
- package/src/adapter.ts +97 -0
- package/src/adc-impersonation.ts +176 -0
- package/src/admin-domain.test.ts +254 -0
- package/src/admin-domain.ts +245 -0
- package/src/admin-memory.test.ts +151 -0
- package/src/agent/apply.test.ts +185 -0
- package/src/agent/apply.ts +124 -0
- package/src/agent/cli-provenance.test.ts +159 -0
- package/src/agent/cli-provenance.ts +191 -0
- package/src/agent/command.test.ts +77 -0
- package/src/agent/command.ts +564 -0
- package/src/agent/config.ts +86 -0
- package/src/agent/lock-config.test.ts +97 -0
- package/src/agent/lock.ts +110 -0
- package/src/agent/queue.test.ts +10 -0
- package/src/agent/queue.ts +91 -0
- package/src/agent/run.test.ts +437 -0
- package/src/agent/run.ts +295 -0
- package/src/agent/systemd.ts +209 -0
- package/src/agent/units.test.ts +223 -0
- package/src/agent/units.ts +163 -0
- package/src/azure-front-door.test.ts +119 -0
- package/src/azure-front-door.ts +56 -0
- package/src/bootstrap-token.ts +20 -0
- package/src/caddy.ts +112 -0
- package/src/cdn-copy.ts +91 -0
- package/src/cert-wait.test.ts +194 -0
- package/src/cert-wait.ts +163 -0
- package/src/checkpoint-secrets.test.ts +380 -0
- package/src/checkpoint-secrets.ts +283 -0
- package/src/cli-routing.test.ts +45 -0
- package/src/cli-routing.ts +43 -0
- package/src/cli.ts +1491 -0
- package/src/cloudsql-iam-user.ts +147 -0
- package/src/compose.ts +334 -0
- package/src/default-firewall.test.ts +411 -0
- package/src/default-firewall.ts +342 -0
- package/src/deployed-release.test.ts +231 -0
- package/src/deployed-release.ts +220 -0
- package/src/dns/cloudflare.ts +182 -0
- package/src/dns/groups.test.ts +76 -0
- package/src/dns/groups.ts +86 -0
- package/src/dns/index.ts +56 -0
- package/src/dns/manual.ts +130 -0
- package/src/dns/types.ts +27 -0
- package/src/gateway-credentials.test.ts +180 -0
- package/src/gateway-credentials.ts +144 -0
- package/src/gcloud-components.test.ts +76 -0
- package/src/gcloud-components.ts +85 -0
- package/src/gcloud-retry.ts +41 -0
- package/src/gcloud-runner.test.ts +61 -0
- package/src/gcloud.test.ts +99 -0
- package/src/gcloud.ts +322 -0
- package/src/gcp-names.ts +135 -0
- package/src/index.test.ts +332 -0
- package/src/index.ts +114 -0
- package/src/infra-contract.ts +32 -0
- package/src/install-job-runner.ts +166 -0
- package/src/install-job.test.ts +566 -0
- package/src/install-job.ts +542 -0
- package/src/install-state.test.ts +239 -0
- package/src/install-state.ts +583 -0
- package/src/internal-token.ts +35 -0
- package/src/lifecycle.ts +900 -0
- package/src/mcp-iap-stacks.test.ts +58 -0
- package/src/mcp-iap.test.ts +149 -0
- package/src/mcp-iap.ts +131 -0
- package/src/media-volume.test.ts +296 -0
- package/src/migration-runner.ts +365 -0
- package/src/operator-access-stacks.test.ts +55 -0
- package/src/operator-access.test.ts +332 -0
- package/src/operator-access.ts +554 -0
- package/src/ops-backend.test.ts +320 -0
- package/src/ops-backend.ts +414 -0
- package/src/provisioner-identity.test.ts +482 -0
- package/src/provisioner-identity.ts +407 -0
- package/src/redirects-emit.ts +175 -0
- package/src/region-data.ts +428 -0
- package/src/regions.test.ts +277 -0
- package/src/regions.ts +370 -0
- package/src/release-contract.ts +57 -0
- package/src/release-manifest.test.ts +300 -0
- package/src/release-manifest.ts +310 -0
- package/src/run-mode.ts +40 -0
- package/src/runtime-secrets.test.ts +627 -0
- package/src/runtime-secrets.ts +754 -0
- package/src/self-command.test.ts +14 -0
- package/src/self-command.ts +21 -0
- package/src/self-hosted-compose.test.ts +177 -0
- package/src/self-hosted-release.ts +240 -0
- package/src/self-hosted-roles.integration.test.ts +161 -0
- package/src/self-hosted-roles.test.ts +124 -0
- package/src/self-hosted-roles.ts +203 -0
- package/src/self-hosted-upgrade.test.ts +394 -0
- package/src/self-hosted-upgrade.ts +548 -0
- package/src/stack-contract-parity.test.ts +199 -0
- package/src/stack-contract.test.ts +391 -0
- package/src/stack-contract.ts +888 -0
- package/src/stack-converge-secrets.test.ts +421 -0
- package/src/stack-converge.test.ts +833 -0
- package/src/stack-converge.ts +909 -0
- package/src/staging-admin.test.ts +246 -0
- package/src/staging-admin.ts +324 -0
- package/src/state-attach.test.ts +211 -0
- package/src/state-attach.ts +132 -0
- package/src/state-backend.test.ts +145 -0
- package/src/state-backend.ts +262 -0
- package/src/state-command.ts +293 -0
- package/src/state-migrate.test.ts +479 -0
- package/src/state-migrate.ts +434 -0
- package/src/state-pulumi.ts +289 -0
- package/src/state-store.test.ts +212 -0
- package/src/state-store.ts +243 -0
- package/src/upgrade/app-roll.ts +486 -0
- package/src/upgrade/backup.ts +109 -0
- package/src/upgrade/converge.ts +437 -0
- package/src/upgrade/gcp-lookup.ts +167 -0
- package/src/upgrade/health.test.ts +279 -0
- package/src/upgrade/health.ts +305 -0
- package/src/upgrade/release-images.test.ts +119 -0
- package/src/upgrade/release-images.ts +393 -0
- package/src/upgrade/reporter.ts +46 -0
- package/src/upgrade/upgrade.test.ts +626 -0
- package/src/upgrade/upgrade.ts +249 -0
- package/src/upgrader-assess.test.ts +306 -0
- package/src/upgrader-assess.ts +250 -0
- package/src/upgrader-gcp.ts +123 -0
- package/src/upgrader-job.test.ts +411 -0
- package/src/upgrader-job.ts +502 -0
- package/src/upgrader-reporter.test.ts +46 -0
- package/src/upgrader-reporter.ts +105 -0
- package/src/upgrader-setup.test.ts +169 -0
- package/src/upgrader-setup.ts +355 -0
- package/src/wizard.ts +425 -0
- package/src/wizards/gcp-cost.ts +188 -0
- package/src/wizards/gcp-firebase.ts +72 -0
- package/src/wizards/gcp-install-job.ts +506 -0
- package/src/wizards/gcp-prompts.ts +26 -0
- package/src/wizards/gcp-pulumi.test.ts +246 -0
- package/src/wizards/gcp-pulumi.ts +516 -0
- package/src/wizards/gcp.ts +1935 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The keyless provisioner (#696, provisioner-identity.ts) against a fake
|
|
5
|
+
* gcloud and a fake fetch. Load-bearing: no code path mints a key; the
|
|
6
|
+
* operator gets TokenCreator on caelo-provisioner only; an older install's
|
|
7
|
+
* key is deleted locally and in IAM — that key and no other; re-runs are
|
|
8
|
+
* no-ops; a missing permission stops with the command to fix it; tokens
|
|
9
|
+
* never leave the probe.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
13
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join, resolve } from "node:path";
|
|
16
|
+
import { checkAdcImpersonation } from "./adc-impersonation.js";
|
|
17
|
+
import type { GcloudResult } from "./gcloud.js";
|
|
18
|
+
import {
|
|
19
|
+
convergeKeylessProvisioner,
|
|
20
|
+
LEGACY_SA_KEY_FILE,
|
|
21
|
+
operatorMember,
|
|
22
|
+
provisionerServiceAccountEmail,
|
|
23
|
+
TOKEN_CREATOR_ROLE,
|
|
24
|
+
} from "./provisioner-identity.js";
|
|
25
|
+
|
|
26
|
+
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
27
|
+
const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
|
|
28
|
+
|
|
29
|
+
type Opts = { readonly stdin?: string; readonly env?: NodeJS.ProcessEnv };
|
|
30
|
+
|
|
31
|
+
function fakeGcloud(answers: Record<string, GcloudResult[]>) {
|
|
32
|
+
const calls: { args: string[]; opts?: Opts }[] = [];
|
|
33
|
+
const run = async (args: string[], opts?: Opts) => {
|
|
34
|
+
calls.push({ args, ...(opts ? { opts } : {}) });
|
|
35
|
+
const key = Object.keys(answers)
|
|
36
|
+
.sort((a, b) => b.length - a.length)
|
|
37
|
+
.find((k) => args.join(" ").startsWith(k));
|
|
38
|
+
return (key && answers[key]?.shift()) || ok();
|
|
39
|
+
};
|
|
40
|
+
return { run, calls, lines: () => calls.map((c) => c.args.join(" ")) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const noSleep = async () => {};
|
|
44
|
+
const PROJECT = "acme-site";
|
|
45
|
+
const SA = provisionerServiceAccountEmail(PROJECT);
|
|
46
|
+
const OPERATOR = "Owner@Example.com";
|
|
47
|
+
const KEY_ID = "0123456789abcdef0123456789abcdef01234567";
|
|
48
|
+
const OTHER_KEY_ID = "fedcba9876543210fedcba9876543210fedcba98";
|
|
49
|
+
const keyName = (id: string) => `projects/${PROJECT}/serviceAccounts/${SA}/keys/${id}`;
|
|
50
|
+
const tokenCreatorPolicy = (member: string) =>
|
|
51
|
+
JSON.stringify({ bindings: [{ role: TOKEN_CREATOR_ROLE, members: [member] }] });
|
|
52
|
+
|
|
53
|
+
let secretsDir = "";
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
secretsDir = mkdtempSync(join(tmpdir(), "caelo-keyless-"));
|
|
56
|
+
});
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
rmSync(secretsDir, { recursive: true, force: true });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
function writeLegacyKey(clientEmail = SA, keyId = KEY_ID): string {
|
|
62
|
+
const path = join(secretsDir, LEGACY_SA_KEY_FILE);
|
|
63
|
+
writeFileSync(
|
|
64
|
+
path,
|
|
65
|
+
JSON.stringify({
|
|
66
|
+
type: "service_account",
|
|
67
|
+
private_key_id: keyId,
|
|
68
|
+
private_key: "not-a-real-private-key",
|
|
69
|
+
client_email: clientEmail,
|
|
70
|
+
}),
|
|
71
|
+
{ mode: 0o600 },
|
|
72
|
+
);
|
|
73
|
+
return path;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const target = () => ({ projectId: PROJECT, secretsDir, operatorAccount: OPERATOR });
|
|
77
|
+
|
|
78
|
+
describe("operatorMember", () => {
|
|
79
|
+
it("a person is a user:, a service account a serviceAccount:, both lower-cased", () => {
|
|
80
|
+
expect(operatorMember("Owner@Example.com")).toBe("user:owner@example.com");
|
|
81
|
+
expect(operatorMember("ci@p.iam.gserviceaccount.com")).toBe(
|
|
82
|
+
"serviceAccount:ci@p.iam.gserviceaccount.com",
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("convergeKeylessProvisioner — new install", () => {
|
|
88
|
+
it("grants TokenCreator on caelo-provisioner only and never mints or touches a key", async () => {
|
|
89
|
+
const g = fakeGcloud({
|
|
90
|
+
"iam service-accounts describe": [ok(`${SA}\n`)],
|
|
91
|
+
"iam service-accounts get-iam-policy": [ok("{}")],
|
|
92
|
+
});
|
|
93
|
+
const r = await convergeKeylessProvisioner(target(), { run: g.run, sleep: noSleep });
|
|
94
|
+
expect(r).toEqual({ ok: true, grant: "granted", legacyKey: "none", lines: [] });
|
|
95
|
+
const grants = g.calls.filter((c) => c.args.includes("add-iam-policy-binding"));
|
|
96
|
+
expect(grants).toHaveLength(1);
|
|
97
|
+
expect(grants[0]?.args.slice(0, 4)).toEqual([
|
|
98
|
+
"iam",
|
|
99
|
+
"service-accounts",
|
|
100
|
+
"add-iam-policy-binding",
|
|
101
|
+
SA,
|
|
102
|
+
]);
|
|
103
|
+
expect(grants[0]?.args).toContain("--member=user:owner@example.com");
|
|
104
|
+
expect(grants[0]?.args).toContain(`--role=${TOKEN_CREATOR_ROLE}`);
|
|
105
|
+
// Never project-wide, never a key operation, nothing written to disk.
|
|
106
|
+
expect(g.lines().some((l) => l.startsWith("projects add-iam-policy-binding"))).toBe(false);
|
|
107
|
+
expect(g.lines().some((l) => l.includes(" keys "))).toBe(false);
|
|
108
|
+
expect(g.lines().some((l) => l.includes("print-access-token"))).toBe(false);
|
|
109
|
+
expect(readdirSync(secretsDir)).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("an operator who already holds the role needs no IAM write", async () => {
|
|
113
|
+
const g = fakeGcloud({
|
|
114
|
+
"iam service-accounts describe": [ok(SA)],
|
|
115
|
+
"iam service-accounts get-iam-policy": [ok(tokenCreatorPolicy("user:owner@example.com"))],
|
|
116
|
+
});
|
|
117
|
+
const r = await convergeKeylessProvisioner(target(), { run: g.run, sleep: noSleep });
|
|
118
|
+
expect(r.ok && r.grant).toBe("already");
|
|
119
|
+
expect(g.lines().some((l) => l.includes("add-iam-policy-binding"))).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("a missing permission to grant stops with the command a project Owner can run", async () => {
|
|
123
|
+
const path = writeLegacyKey();
|
|
124
|
+
const g = fakeGcloud({
|
|
125
|
+
"iam service-accounts describe": [ok(SA)],
|
|
126
|
+
"iam service-accounts get-iam-policy": [ok("{}")],
|
|
127
|
+
"iam service-accounts add-iam-policy-binding": [
|
|
128
|
+
fail("ERROR: (gcloud.iam.service-accounts.add-iam-policy-binding) PERMISSION_DENIED"),
|
|
129
|
+
],
|
|
130
|
+
});
|
|
131
|
+
const r = await convergeKeylessProvisioner(target(), { run: g.run, sleep: noSleep });
|
|
132
|
+
expect(r.ok).toBe(false);
|
|
133
|
+
if (r.ok) return;
|
|
134
|
+
expect(r.error).toContain("iam.serviceAccounts.setIamPolicy");
|
|
135
|
+
expect(r.error).toContain(
|
|
136
|
+
`gcloud iam service-accounts add-iam-policy-binding ${SA} --project=${PROJECT} --member=user:owner@example.com --role=${TOKEN_CREATOR_ROLE}`,
|
|
137
|
+
);
|
|
138
|
+
// The key stays until impersonation works: it is still the only credential.
|
|
139
|
+
expect(existsSync(path)).toBe(true);
|
|
140
|
+
expect(g.lines().some((l) => l.includes(" keys "))).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("a project without caelo-provisioner and without a key has nothing to migrate", async () => {
|
|
144
|
+
const g = fakeGcloud({
|
|
145
|
+
"iam service-accounts describe": [fail("NOT_FOUND: Unknown service account")],
|
|
146
|
+
});
|
|
147
|
+
const r = await convergeKeylessProvisioner(target(), { run: g.run, sleep: noSleep });
|
|
148
|
+
expect(r).toEqual({ ok: true, grant: "no-provisioner", legacyKey: "none", lines: [] });
|
|
149
|
+
expect(g.calls).toHaveLength(1);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("convergeKeylessProvisioner — account created moments ago", () => {
|
|
154
|
+
it("waits out IAM propagation instead of reporting no provisioner", async () => {
|
|
155
|
+
const g = fakeGcloud({
|
|
156
|
+
"iam service-accounts describe": [fail("NOT_FOUND: Unknown service account"), ok(SA)],
|
|
157
|
+
"iam service-accounts get-iam-policy": [ok("{}")],
|
|
158
|
+
});
|
|
159
|
+
const r = await convergeKeylessProvisioner(
|
|
160
|
+
{ ...target(), justCreated: true },
|
|
161
|
+
{ run: g.run, sleep: noSleep },
|
|
162
|
+
);
|
|
163
|
+
expect(r.ok && r.grant).toBe("granted");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("convergeKeylessProvisioner — existing install with a key (upgrade)", () => {
|
|
168
|
+
const withKeys = (ids: string[]) =>
|
|
169
|
+
fakeGcloud({
|
|
170
|
+
"iam service-accounts describe": [ok(SA)],
|
|
171
|
+
"iam service-accounts get-iam-policy": [ok("{}")],
|
|
172
|
+
"iam service-accounts keys list": [
|
|
173
|
+
ok(JSON.stringify(ids.map((id) => ({ name: keyName(id), keyType: "USER_MANAGED" })))),
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("deletes the matching IAM key and the local file — and no other key", async () => {
|
|
178
|
+
const path = writeLegacyKey();
|
|
179
|
+
const g = withKeys([KEY_ID, OTHER_KEY_ID]);
|
|
180
|
+
const r = await convergeKeylessProvisioner(target(), { run: g.run, sleep: noSleep });
|
|
181
|
+
expect(r.ok).toBe(true);
|
|
182
|
+
if (!r.ok) return;
|
|
183
|
+
expect(r.legacyKey).toBe("removed");
|
|
184
|
+
const deletes = g.calls.filter((c) =>
|
|
185
|
+
c.args.join(" ").startsWith("iam service-accounts keys delete"),
|
|
186
|
+
);
|
|
187
|
+
expect(deletes).toHaveLength(1);
|
|
188
|
+
expect(deletes[0]?.args[4]).toBe(KEY_ID);
|
|
189
|
+
expect(deletes[0]?.args).toContain(`--iam-account=${SA}`);
|
|
190
|
+
expect(existsSync(path)).toBe(false);
|
|
191
|
+
// The unrecorded key is reported with its delete command, not deleted.
|
|
192
|
+
const text = r.lines.join("\n");
|
|
193
|
+
expect(text).toContain(OTHER_KEY_ID);
|
|
194
|
+
expect(text).toContain(`gcloud iam service-accounts keys delete ${OTHER_KEY_ID}`);
|
|
195
|
+
// Grant before the key goes: impersonation must work before the key is gone.
|
|
196
|
+
const grantAt = g.lines().findIndex((l) => l.includes("add-iam-policy-binding"));
|
|
197
|
+
const deleteAt = g.lines().findIndex((l) => l.includes("keys delete"));
|
|
198
|
+
expect(grantAt).toBeGreaterThanOrEqual(0);
|
|
199
|
+
expect(grantAt).toBeLessThan(deleteAt);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("a conditional binding does not count as a grant", async () => {
|
|
203
|
+
writeLegacyKey();
|
|
204
|
+
const conditional = JSON.stringify({
|
|
205
|
+
bindings: [
|
|
206
|
+
{
|
|
207
|
+
role: TOKEN_CREATOR_ROLE,
|
|
208
|
+
members: ["user:owner@example.com"],
|
|
209
|
+
condition: {
|
|
210
|
+
title: "expired",
|
|
211
|
+
expression: 'request.time < timestamp("2020-01-01T00:00:00Z")',
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
});
|
|
216
|
+
const g = fakeGcloud({
|
|
217
|
+
"iam service-accounts describe": [ok(SA)],
|
|
218
|
+
"iam service-accounts get-iam-policy": [ok(conditional)],
|
|
219
|
+
"iam service-accounts keys list": [ok(JSON.stringify([{ name: keyName(KEY_ID) }]))],
|
|
220
|
+
});
|
|
221
|
+
const r = await convergeKeylessProvisioner(target(), { run: g.run, sleep: noSleep });
|
|
222
|
+
expect(r.ok && r.grant).toBe("granted");
|
|
223
|
+
const add = g.calls.find((c) => c.args.includes("add-iam-policy-binding"));
|
|
224
|
+
expect(add?.args).toContain("--condition=None");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("keeps the key when impersonation does not work yet, even after waiting", async () => {
|
|
228
|
+
const path = writeLegacyKey();
|
|
229
|
+
const denied = () => fail("PERMISSION_DENIED: iam.serviceAccounts.getAccessToken");
|
|
230
|
+
const g = fakeGcloud({
|
|
231
|
+
"iam service-accounts describe": [ok(SA)],
|
|
232
|
+
"iam service-accounts get-iam-policy": [ok("{}")],
|
|
233
|
+
"auth print-access-token": [
|
|
234
|
+
denied(),
|
|
235
|
+
denied(),
|
|
236
|
+
denied(),
|
|
237
|
+
denied(),
|
|
238
|
+
denied(),
|
|
239
|
+
denied(),
|
|
240
|
+
denied(),
|
|
241
|
+
],
|
|
242
|
+
});
|
|
243
|
+
const slept: number[] = [];
|
|
244
|
+
const r = await convergeKeylessProvisioner(target(), {
|
|
245
|
+
run: g.run,
|
|
246
|
+
sleep: async (ms) => void slept.push(ms),
|
|
247
|
+
});
|
|
248
|
+
expect(r.ok).toBe(false);
|
|
249
|
+
if (!r.ok) expect(r.error).toContain("its key was kept");
|
|
250
|
+
expect(slept.length).toBeGreaterThan(0);
|
|
251
|
+
expect(existsSync(path)).toBe(true);
|
|
252
|
+
expect(g.lines().some((l) => l.includes(" keys "))).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("verifies impersonation through gcloud before deleting, and never keeps the token", async () => {
|
|
256
|
+
writeLegacyKey();
|
|
257
|
+
const g = fakeGcloud({
|
|
258
|
+
"iam service-accounts describe": [ok(SA)],
|
|
259
|
+
"iam service-accounts get-iam-policy": [ok(tokenCreatorPolicy("user:owner@example.com"))],
|
|
260
|
+
"auth print-access-token": [ok("ya29.must-not-leak\n")],
|
|
261
|
+
"iam service-accounts keys list": [ok(JSON.stringify([{ name: keyName(KEY_ID) }]))],
|
|
262
|
+
});
|
|
263
|
+
const r = await convergeKeylessProvisioner(target(), { run: g.run, sleep: noSleep });
|
|
264
|
+
expect(r.ok && r.legacyKey).toBe("removed");
|
|
265
|
+
const verifyAt = g.lines().findIndex((l) => l.startsWith("auth print-access-token"));
|
|
266
|
+
const deleteAt = g.lines().findIndex((l) => l.includes("keys delete"));
|
|
267
|
+
expect(g.lines()[verifyAt]).toContain(`--impersonate-service-account=${SA}`);
|
|
268
|
+
expect(verifyAt).toBeLessThan(deleteAt);
|
|
269
|
+
expect(JSON.stringify(r)).not.toContain("must-not-leak");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("is idempotent: a second run finds no key and changes nothing", async () => {
|
|
273
|
+
writeLegacyKey();
|
|
274
|
+
await convergeKeylessProvisioner(target(), { run: withKeys([KEY_ID]).run, sleep: noSleep });
|
|
275
|
+
const again = fakeGcloud({
|
|
276
|
+
"iam service-accounts describe": [ok(SA)],
|
|
277
|
+
"iam service-accounts get-iam-policy": [ok(tokenCreatorPolicy("user:owner@example.com"))],
|
|
278
|
+
});
|
|
279
|
+
const r = await convergeKeylessProvisioner(target(), { run: again.run, sleep: noSleep });
|
|
280
|
+
expect(r).toEqual({ ok: true, grant: "already", legacyKey: "none", lines: [] });
|
|
281
|
+
expect(again.lines().some((l) => l.includes(" keys ") || l.includes("add-iam"))).toBe(false);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("a key already deleted in IAM only removes the local file", async () => {
|
|
285
|
+
const path = writeLegacyKey();
|
|
286
|
+
const g = withKeys([]);
|
|
287
|
+
const r = await convergeKeylessProvisioner(target(), { run: g.run, sleep: noSleep });
|
|
288
|
+
expect(r.ok && r.legacyKey).toBe("removed");
|
|
289
|
+
expect(g.lines().some((l) => l.includes("keys delete"))).toBe(false);
|
|
290
|
+
expect(existsSync(path)).toBe(false);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("a failed IAM delete keeps the file so the next run can match it", async () => {
|
|
294
|
+
const path = writeLegacyKey();
|
|
295
|
+
const g = fakeGcloud({
|
|
296
|
+
"iam service-accounts describe": [ok(SA)],
|
|
297
|
+
"iam service-accounts get-iam-policy": [ok("{}")],
|
|
298
|
+
"iam service-accounts keys list": [ok(JSON.stringify([{ name: keyName(KEY_ID) }]))],
|
|
299
|
+
"iam service-accounts keys delete": [fail("PERMISSION_DENIED")],
|
|
300
|
+
});
|
|
301
|
+
const r = await convergeKeylessProvisioner(target(), { run: g.run, sleep: noSleep });
|
|
302
|
+
expect(r.ok).toBe(false);
|
|
303
|
+
if (!r.ok) expect(r.error).toContain("iam.serviceAccountKeys.delete");
|
|
304
|
+
expect(existsSync(path)).toBe(true);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("a key file for another account is left alone and nothing is deleted", async () => {
|
|
308
|
+
const path = writeLegacyKey("someone@other.iam.gserviceaccount.com");
|
|
309
|
+
const g = withKeys([KEY_ID]);
|
|
310
|
+
const r = await convergeKeylessProvisioner(target(), { run: g.run, sleep: noSleep });
|
|
311
|
+
expect(r.ok).toBe(false);
|
|
312
|
+
expect(existsSync(path)).toBe(true);
|
|
313
|
+
expect(g.lines().some((l) => l.includes("keys delete"))).toBe(false);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("an unreadable key file is not guessed at", async () => {
|
|
317
|
+
writeFileSync(join(secretsDir, LEGACY_SA_KEY_FILE), "garbage", { mode: 0o600 });
|
|
318
|
+
const g = withKeys([KEY_ID, OTHER_KEY_ID]);
|
|
319
|
+
const r = await convergeKeylessProvisioner(target(), { run: g.run, sleep: noSleep });
|
|
320
|
+
expect(r.ok).toBe(false);
|
|
321
|
+
expect(g.lines().some((l) => l.includes("keys delete"))).toBe(false);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe("checkAdcImpersonation", () => {
|
|
326
|
+
const SOURCE = "ya29.source-token-must-never-leak";
|
|
327
|
+
const MINTED = "ya29.impersonated-token-must-never-leak";
|
|
328
|
+
const response = (status: number, body: unknown) =>
|
|
329
|
+
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
330
|
+
|
|
331
|
+
function fakeFetch(answers: Response[], tokeninfoEmail?: string) {
|
|
332
|
+
const urls: string[] = [];
|
|
333
|
+
const impl = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
334
|
+
const url = String(input);
|
|
335
|
+
urls.push(url);
|
|
336
|
+
if (url.includes("tokeninfo")) {
|
|
337
|
+
expect(String(init?.body)).toContain("access_token=");
|
|
338
|
+
return response(200, { email: tokeninfoEmail ?? "" });
|
|
339
|
+
}
|
|
340
|
+
expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${SOURCE}`);
|
|
341
|
+
return answers.shift() ?? response(500, {});
|
|
342
|
+
}) as typeof fetch;
|
|
343
|
+
return { impl, urls };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
it("runs the ADC lookup without ambient key/token variables", async () => {
|
|
347
|
+
const g = fakeGcloud({ "auth application-default print-access-token": [fail("no ADC")] });
|
|
348
|
+
const r = await checkAdcImpersonation(
|
|
349
|
+
{ projectId: PROJECT, operatorAccount: OPERATOR, freshGrant: false },
|
|
350
|
+
{
|
|
351
|
+
run: g.run,
|
|
352
|
+
sleep: noSleep,
|
|
353
|
+
fetch: fakeFetch([]).impl,
|
|
354
|
+
env: {
|
|
355
|
+
PATH: "/usr/bin",
|
|
356
|
+
GOOGLE_APPLICATION_CREDENTIALS: "/home/op/.caelo-x/secrets/sa-key.json",
|
|
357
|
+
GOOGLE_OAUTH_ACCESS_TOKEN: "static",
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
);
|
|
361
|
+
expect(r.ok).toBe(false);
|
|
362
|
+
if (!r.ok) {
|
|
363
|
+
expect(r.reason).toBe("no-adc");
|
|
364
|
+
expect(r.error).toContain("gcloud auth application-default login");
|
|
365
|
+
}
|
|
366
|
+
const env = g.calls[0]?.opts?.env ?? {};
|
|
367
|
+
expect(env.PATH).toBe("/usr/bin");
|
|
368
|
+
expect(env.GOOGLE_APPLICATION_CREDENTIALS).toBeUndefined();
|
|
369
|
+
expect(env.GOOGLE_OAUTH_ACCESS_TOKEN).toBeUndefined();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("succeeds when ADC may mint a token for the provisioner, and keeps no token", async () => {
|
|
373
|
+
const g = fakeGcloud({
|
|
374
|
+
"auth application-default print-access-token": [ok(`${SOURCE}\n`)],
|
|
375
|
+
"iam service-accounts describe": [ok(`${SA}\n`)],
|
|
376
|
+
});
|
|
377
|
+
const f = fakeFetch([response(200, { accessToken: MINTED, expireTime: "x" })]);
|
|
378
|
+
const r = await checkAdcImpersonation(
|
|
379
|
+
{ projectId: PROJECT, operatorAccount: OPERATOR, freshGrant: false },
|
|
380
|
+
{ run: g.run, sleep: noSleep, fetch: f.impl },
|
|
381
|
+
);
|
|
382
|
+
expect(r).toEqual({ ok: true });
|
|
383
|
+
expect(f.urls[0]).toContain(`serviceAccounts/${encodeURIComponent(SA)}:generateAccessToken`);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("never calls the IAM Credentials API for an account IAM doesn't confirm", async () => {
|
|
387
|
+
const g = fakeGcloud({
|
|
388
|
+
"auth application-default print-access-token": [ok(SOURCE)],
|
|
389
|
+
"iam service-accounts describe": [fail("NOT_FOUND: Unknown service account")],
|
|
390
|
+
});
|
|
391
|
+
const f = fakeFetch([]);
|
|
392
|
+
const r = await checkAdcImpersonation(
|
|
393
|
+
{ projectId: PROJECT, operatorAccount: OPERATOR, freshGrant: false },
|
|
394
|
+
{ run: g.run, sleep: noSleep, fetch: f.impl },
|
|
395
|
+
);
|
|
396
|
+
expect(r.ok).toBe(false);
|
|
397
|
+
if (!r.ok) expect(r.error).toContain(`Could not find ${SA}`);
|
|
398
|
+
expect(f.urls).toEqual([]);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("waits out IAM propagation after a fresh grant", async () => {
|
|
402
|
+
const g = fakeGcloud({
|
|
403
|
+
"auth application-default print-access-token": [ok(SOURCE)],
|
|
404
|
+
"iam service-accounts describe": [ok(`${SA}\n`)],
|
|
405
|
+
});
|
|
406
|
+
const denied = () =>
|
|
407
|
+
response(403, {
|
|
408
|
+
error: { message: "Permission 'iam.serviceAccounts.getAccessToken' denied" },
|
|
409
|
+
});
|
|
410
|
+
const f = fakeFetch([denied(), denied(), response(200, { accessToken: MINTED })]);
|
|
411
|
+
const slept: number[] = [];
|
|
412
|
+
const r = await checkAdcImpersonation(
|
|
413
|
+
{ projectId: PROJECT, operatorAccount: OPERATOR, freshGrant: true },
|
|
414
|
+
{ run: g.run, sleep: async (ms) => void slept.push(ms), fetch: f.impl },
|
|
415
|
+
);
|
|
416
|
+
expect(r).toEqual({ ok: true });
|
|
417
|
+
expect(slept).toHaveLength(2);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("a denial names the ADC account when it differs from gcloud's, without leaking a token", async () => {
|
|
421
|
+
const g = fakeGcloud({
|
|
422
|
+
"auth application-default print-access-token": [ok(SOURCE)],
|
|
423
|
+
"iam service-accounts describe": [ok(`${SA}\n`)],
|
|
424
|
+
});
|
|
425
|
+
const f = fakeFetch(
|
|
426
|
+
[
|
|
427
|
+
response(403, {
|
|
428
|
+
error: { message: "Permission 'iam.serviceAccounts.getAccessToken' denied" },
|
|
429
|
+
}),
|
|
430
|
+
],
|
|
431
|
+
"someone-else@example.com",
|
|
432
|
+
);
|
|
433
|
+
const r = await checkAdcImpersonation(
|
|
434
|
+
{ projectId: PROJECT, operatorAccount: OPERATOR, freshGrant: false },
|
|
435
|
+
{ run: g.run, sleep: noSleep, fetch: f.impl },
|
|
436
|
+
);
|
|
437
|
+
expect(r.ok).toBe(false);
|
|
438
|
+
if (r.ok) return;
|
|
439
|
+
expect(r.reason).toBe("denied");
|
|
440
|
+
expect(r.error).toContain("someone-else@example.com");
|
|
441
|
+
expect(r.error).toContain(`gcloud auth application-default login\` as ${OPERATOR}`);
|
|
442
|
+
expect(JSON.stringify(r)).not.toContain(SOURCE);
|
|
443
|
+
expect(JSON.stringify(r)).not.toContain(MINTED);
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
describe("no service-account key is minted anywhere in provisioning (#696)", () => {
|
|
448
|
+
const root = resolve(import.meta.dir, "..");
|
|
449
|
+
function sources(dir: string): string[] {
|
|
450
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
|
451
|
+
if (e.name === "node_modules" || e.name.startsWith(".")) return [];
|
|
452
|
+
const p = join(dir, e.name);
|
|
453
|
+
if (e.isDirectory()) return sources(p);
|
|
454
|
+
return /\.(ts|js|sh|ya?ml)$/.test(e.name) && !e.name.endsWith(".test.ts") ? [p] : [];
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
it("no `keys create` (gcloud) and no key-creating API in any source file", () => {
|
|
459
|
+
const files = sources(root);
|
|
460
|
+
expect(files.length).toBeGreaterThan(20);
|
|
461
|
+
const offenders = files.filter((f) => {
|
|
462
|
+
const src = readFileSync(f, "utf8");
|
|
463
|
+
return (
|
|
464
|
+
// `gcloud kms keys create` (the state key, ops-backend.ts) makes a
|
|
465
|
+
// Cloud KMS key, not a service-account key.
|
|
466
|
+
/(?<!kms["'`\s,]+)keys["'`\s,]+create/.test(src) ||
|
|
467
|
+
/createServiceAccountKey|serviceAccounts\.keys\.create|ServiceAccountKey\(/.test(src)
|
|
468
|
+
);
|
|
469
|
+
});
|
|
470
|
+
expect(offenders).toEqual([]);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("Pulumi never gets a key file: the wizard passes the SA to impersonate", () => {
|
|
474
|
+
const up = readFileSync(join(root, "src", "wizards", "gcp-pulumi.ts"), "utf8");
|
|
475
|
+
expect(up).not.toMatch(/GOOGLE_APPLICATION_CREDENTIALS:\s*inputs/);
|
|
476
|
+
expect(up).not.toContain("saKeyPath");
|
|
477
|
+
const wizard = readFileSync(join(root, "src", "wizards", "gcp.ts"), "utf8");
|
|
478
|
+
expect(wizard).not.toContain("saKeyPath");
|
|
479
|
+
expect(wizard).toContain("impersonateServiceAccount: saEmail");
|
|
480
|
+
expect(wizard).toContain("convergeKeylessProvisioner(");
|
|
481
|
+
});
|
|
482
|
+
});
|