@catladder/cli 0.0.0-main-2355fd7c → 0.0.0-main-018e2dee

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.
Files changed (83) hide show
  1. package/dist/apps/cli/src/apps/catenv/catenv.js +3 -0
  2. package/dist/apps/cli/src/apps/catenv/catenv.js.map +1 -1
  3. package/dist/apps/cli/src/apps/cli/commands/project/doctor/GcloudProjectInspector.d.ts +4 -0
  4. package/dist/apps/cli/src/apps/cli/commands/project/doctor/GcloudProjectInspector.js +12 -0
  5. package/dist/apps/cli/src/apps/cli/commands/project/doctor/GcloudProjectInspector.js.map +1 -1
  6. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkCloudRun.d.ts +2 -2
  7. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkCloudRun.js +3 -1
  8. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkCloudRun.js.map +1 -1
  9. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkLocalAccess.d.ts +10 -0
  10. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkLocalAccess.js +98 -0
  11. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkLocalAccess.js.map +1 -0
  12. package/dist/apps/cli/src/apps/cli/commands/project/doctor/index.js +1 -1
  13. package/dist/apps/cli/src/apps/cli/commands/project/doctor/index.js.map +1 -1
  14. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupContext.js +2 -0
  15. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupContext.js.map +1 -1
  16. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupLocalAccess.d.ts +9 -0
  17. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupLocalAccess.js +31 -0
  18. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupLocalAccess.js.map +1 -0
  19. package/dist/apps/cli/src/config/getProjectConfig.js +9 -1
  20. package/dist/apps/cli/src/config/getProjectConfig.js.map +1 -1
  21. package/dist/apps/cli/src/gcloud/localAccess.d.ts +53 -0
  22. package/dist/apps/cli/src/gcloud/localAccess.js +176 -0
  23. package/dist/apps/cli/src/gcloud/localAccess.js.map +1 -0
  24. package/dist/apps/cli/src/gcloud/serviceAccounts.js +9 -2
  25. package/dist/apps/cli/src/gcloud/serviceAccounts.js.map +1 -1
  26. package/dist/apps/cli/src/localAccess/declared.d.ts +14 -0
  27. package/dist/apps/cli/src/localAccess/declared.js +34 -0
  28. package/dist/apps/cli/src/localAccess/declared.js.map +1 -0
  29. package/dist/apps/cli/src/localAccess/keyFiles.d.ts +28 -0
  30. package/dist/apps/cli/src/localAccess/keyFiles.js +91 -0
  31. package/dist/apps/cli/src/localAccess/keyFiles.js.map +1 -0
  32. package/dist/apps/cli/src/utils/writePrivateFile.d.ts +7 -0
  33. package/dist/apps/cli/src/utils/writePrivateFile.js +25 -0
  34. package/dist/apps/cli/src/utils/writePrivateFile.js.map +1 -0
  35. package/dist/apps/cli/src/vault/BitwardenVault.js +13 -22
  36. package/dist/apps/cli/src/vault/BitwardenVault.js.map +1 -1
  37. package/dist/apps/cli/src/vault/VaultManager.d.ts +6 -0
  38. package/dist/apps/cli/src/vault/VaultManager.js +18 -0
  39. package/dist/apps/cli/src/vault/VaultManager.js.map +1 -1
  40. package/dist/apps/cli/src/vault/bw.d.ts +6 -2
  41. package/dist/apps/cli/src/vault/bw.js +32 -6
  42. package/dist/apps/cli/src/vault/bw.js.map +1 -1
  43. package/dist/apps/cli/src/vault/cache.js +5 -2
  44. package/dist/apps/cli/src/vault/cache.js.map +1 -1
  45. package/dist/bundles/catenv/index.js +436 -31
  46. package/dist/bundles/cli/index.js +786 -35
  47. package/dist/bundles/skills/catladder-secrets/SKILL.md +35 -3
  48. package/dist/packages/pipeline/src/index.d.ts +1 -0
  49. package/dist/packages/pipeline/src/index.js +1 -0
  50. package/dist/packages/pipeline/src/index.js.map +1 -1
  51. package/dist/packages/pipeline/src/localAccess/index.d.ts +53 -0
  52. package/dist/packages/pipeline/src/localAccess/index.js +177 -0
  53. package/dist/packages/pipeline/src/localAccess/index.js.map +1 -0
  54. package/dist/packages/pipeline/src/types/config.d.ts +75 -0
  55. package/dist/packages/pipeline/src/types/config.js.map +1 -1
  56. package/dist/skills/catladder-secrets/SKILL.md +35 -3
  57. package/dist/tsconfig.tsbuildinfo +1 -1
  58. package/package.json +1 -1
  59. package/src/apps/catenv/catenv.ts +4 -0
  60. package/src/apps/cli/commands/project/doctor/GcloudProjectInspector.ts +25 -1
  61. package/src/apps/cli/commands/project/doctor/__tests__/checkLocalAccess.test.ts +127 -0
  62. package/src/apps/cli/commands/project/doctor/checkCloudRun.ts +5 -1
  63. package/src/apps/cli/commands/project/doctor/checkLocalAccess.ts +146 -0
  64. package/src/apps/cli/commands/project/doctor/index.ts +1 -1
  65. package/src/apps/cli/commands/project/setup/setupContext.ts +2 -0
  66. package/src/apps/cli/commands/project/setup/setupLocalAccess.ts +44 -0
  67. package/src/config/__tests__/getEnvVarsResolved.test.ts +45 -0
  68. package/src/config/getProjectConfig.ts +11 -1
  69. package/src/gcloud/__tests__/localAccess.test.ts +82 -0
  70. package/src/gcloud/__tests__/syncLocalAccess.test.ts +196 -0
  71. package/src/gcloud/localAccess.ts +257 -0
  72. package/src/gcloud/serviceAccounts.ts +8 -2
  73. package/src/localAccess/__tests__/keyFiles.test.ts +166 -0
  74. package/src/localAccess/declared.ts +38 -0
  75. package/src/localAccess/keyFiles.ts +126 -0
  76. package/src/utils/__tests__/writePrivateFile.test.ts +40 -0
  77. package/src/utils/writePrivateFile.ts +20 -0
  78. package/src/vault/BitwardenVault.ts +8 -9
  79. package/src/vault/VaultManager.ts +28 -0
  80. package/src/vault/__tests__/BitwardenVault.test.ts +4 -4
  81. package/src/vault/__tests__/cache.test.ts +37 -0
  82. package/src/vault/bw.ts +34 -9
  83. package/src/vault/cache.ts +5 -2
@@ -0,0 +1,196 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { ComponentContext } from "@catladder/pipeline";
3
+ import { getSecretVarName } from "@catladder/pipeline";
4
+
5
+ const calls: string[] = [];
6
+ let responses: (cmd: string) => string | undefined;
7
+ vi.mock("child-process-promise", () => ({
8
+ exec: async (cmd: string) => {
9
+ calls.push(cmd);
10
+ return { stdout: responses(cmd) ?? "" };
11
+ },
12
+ }));
13
+ vi.mock("../serviceAccounts", () => ({ accountExists: async () => true }));
14
+ vi.mock("fs/promises", async (orig) => ({
15
+ ...(await orig<object>()),
16
+ readFile: async () =>
17
+ JSON.stringify({ private_key_id: "new", private_key: "k" }),
18
+ }));
19
+
20
+ import {
21
+ getLocalAccessServiceAccountNames,
22
+ syncLocalAccessServiceAccount,
23
+ } from "../localAccess";
24
+
25
+ const context = {
26
+ env: "dev",
27
+ name: "web",
28
+ fullConfig: { customerName: "acme", appName: "shop" },
29
+ } as ComponentContext;
30
+
31
+ const saMember = `serviceAccount:${getLocalAccessServiceAccountNames(context, "p").fullIdentifier}`;
32
+
33
+ const setup = (opts: { stored?: string; keys: string[]; bindings: any[] }) => {
34
+ calls.length = 0;
35
+ responses = (cmd) => {
36
+ if (cmd.includes("get-iam-policy"))
37
+ return JSON.stringify({
38
+ bindings: opts.bindings.map((b) => ({
39
+ ...b,
40
+ members: b.members.map((m: string) => (m === "SA" ? saMember : m)),
41
+ })),
42
+ });
43
+ if (cmd.includes("keys list"))
44
+ return JSON.stringify(opts.keys.map((k) => ({ name: `a/keys/${k}` })));
45
+ return undefined;
46
+ };
47
+ const vault = {
48
+ readAllSecrets: vi.fn(async () =>
49
+ opts.stored
50
+ ? {
51
+ [getSecretVarName(
52
+ "local",
53
+ "web",
54
+ "LOCAL_ACCESS_GCP_DEV_CREDENTIALS",
55
+ )]: opts.stored,
56
+ }
57
+ : {},
58
+ ),
59
+ writeSecrets: vi.fn(),
60
+ };
61
+ const manager = {
62
+ vault,
63
+ writeSecrets: vi.fn(),
64
+ cacheSecrets: vi.fn(),
65
+ readSecrets: vi.fn(),
66
+ };
67
+ const io = { log: vi.fn(), getVaultManager: async () => manager } as any;
68
+ return { io, manager, vault };
69
+ };
70
+
71
+ beforeEach(() => {
72
+ calls.length = 0;
73
+ });
74
+
75
+ describe("syncLocalAccessServiceAccount", () => {
76
+ it("adds missing roles, never touches other members, and reads the vault uncached", async () => {
77
+ const { io, manager, vault } = setup({
78
+ keys: ["new"],
79
+ stored: JSON.stringify({ private_key_id: "new" }),
80
+ bindings: [{ role: "roles/run.admin", members: ["user:x@y.z"] }],
81
+ });
82
+ await syncLocalAccessServiceAccount(io, context, "p", [
83
+ "roles/logging.viewer",
84
+ ]);
85
+ expect(
86
+ calls.filter((c) => c.includes("add-iam-policy-binding")),
87
+ ).toHaveLength(1);
88
+ expect(calls.some((c) => c.includes("remove-iam-policy-binding"))).toBe(
89
+ false,
90
+ );
91
+ expect(vault.readAllSecrets).toHaveBeenCalled();
92
+ expect(manager.readSecrets).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it("reuses a valid stored key (no create, no delete, no vault write)", async () => {
96
+ const { io, manager } = setup({
97
+ keys: ["k1"],
98
+ stored: JSON.stringify({ private_key_id: "k1" }),
99
+ bindings: [],
100
+ });
101
+ await syncLocalAccessServiceAccount(io, context, "p", [
102
+ "roles/logging.viewer",
103
+ ]);
104
+ expect(calls.some((c) => c.includes("keys create"))).toBe(false);
105
+ expect(calls.some((c) => c.includes("keys delete"))).toBe(false);
106
+ expect(manager.writeSecrets).not.toHaveBeenCalled();
107
+ });
108
+
109
+ it("removes the account's own undeclared bindings only", async () => {
110
+ const { io } = setup({
111
+ keys: ["new"],
112
+ stored: JSON.stringify({ private_key_id: "new" }),
113
+ bindings: [
114
+ { role: "roles/logging.viewer", members: ["SA"] },
115
+ { role: "roles/run.admin", members: ["SA", "user:x@y.z"] },
116
+ { role: "roles/editor", members: ["user:x@y.z"] },
117
+ ],
118
+ });
119
+ await syncLocalAccessServiceAccount(io, context, "p", [
120
+ "roles/logging.viewer",
121
+ ]);
122
+ const removes = calls.filter((c) =>
123
+ c.includes("remove-iam-policy-binding"),
124
+ );
125
+ expect(removes).toHaveLength(1);
126
+ expect(removes[0]).toContain("--role=roles/run.admin");
127
+ expect(removes[0]).toContain(`--member=${saMember}`);
128
+ expect(calls.some((c) => c.includes("add-iam-policy-binding"))).toBe(false);
129
+ });
130
+
131
+ it("deletes leftover keys next to a valid stored key", async () => {
132
+ const { io, manager } = setup({
133
+ keys: ["k1", "stray"],
134
+ stored: JSON.stringify({ private_key_id: "k1" }),
135
+ bindings: [{ role: "roles/logging.viewer", members: ["SA"] }],
136
+ });
137
+ await syncLocalAccessServiceAccount(io, context, "p", [
138
+ "roles/logging.viewer",
139
+ ]);
140
+ const deletes = calls.filter((c) => c.includes("keys delete"));
141
+ expect(deletes).toHaveLength(1);
142
+ expect(deletes[0]).toContain("keys delete stray ");
143
+ expect(calls.some((c) => c.includes("keys create"))).toBe(false);
144
+ expect(manager.writeSecrets).not.toHaveBeenCalled();
145
+ });
146
+
147
+ it("removes bindings and the account when nothing is declared any more", async () => {
148
+ const { io } = setup({
149
+ keys: [],
150
+ bindings: [{ role: "roles/logging.viewer", members: ["SA"] }],
151
+ });
152
+ await syncLocalAccessServiceAccount(io, context, "p", []);
153
+ const removeIdx = calls.findIndex((c) =>
154
+ c.includes("remove-iam-policy-binding"),
155
+ );
156
+ const deleteIdx = calls.findIndex((c) =>
157
+ c.includes("service-accounts delete"),
158
+ );
159
+ expect(removeIdx).toBeGreaterThan(-1);
160
+ expect(deleteIdx).toBeGreaterThan(removeIdx);
161
+ });
162
+
163
+ it("keeps the vault and old keys untouched when creating the key fails", async () => {
164
+ const { io, manager } = setup({
165
+ keys: ["old"],
166
+ bindings: [{ role: "roles/logging.viewer", members: ["SA"] }],
167
+ });
168
+ const inner = responses;
169
+ responses = (cmd) => {
170
+ if (cmd.includes("keys create")) throw new Error("quota exceeded");
171
+ return inner(cmd);
172
+ };
173
+ await expect(
174
+ syncLocalAccessServiceAccount(io, context, "p", ["roles/logging.viewer"]),
175
+ ).rejects.toThrow("quota exceeded");
176
+ expect(manager.writeSecrets).not.toHaveBeenCalled();
177
+ expect(calls.some((c) => c.includes("keys delete"))).toBe(false);
178
+ });
179
+
180
+ it("rotates: stores the new key, then deletes only the old ones", async () => {
181
+ const { io, manager } = setup({
182
+ keys: ["old1", "old2", "new"],
183
+ bindings: [],
184
+ });
185
+ await syncLocalAccessServiceAccount(io, context, "p", [
186
+ "roles/logging.viewer",
187
+ ]);
188
+ const create = calls.findIndex((c) => c.includes("keys create"));
189
+ const deletes = calls.filter((c) => c.includes("keys delete"));
190
+ expect(create).toBeGreaterThan(-1);
191
+ expect(deletes).toHaveLength(2);
192
+ expect(deletes.some((c) => c.includes(" new "))).toBe(false);
193
+ expect(manager.writeSecrets).toHaveBeenCalledTimes(1);
194
+ expect(manager.writeSecrets.mock.calls[0][0]).toBe("local");
195
+ });
196
+ });
@@ -0,0 +1,257 @@
1
+ import type { ComponentContext } from "@catladder/pipeline";
2
+ import {
3
+ diffRoleBindings,
4
+ getLocalAccessGcpSecretName,
5
+ getSecretVarName,
6
+ LOCAL_ACCESS_GCP_SA_NAME,
7
+ } from "@catladder/pipeline";
8
+ import { exec } from "child-process-promise";
9
+ import { mkdtemp, readFile, rm } from "fs/promises";
10
+ import { tmpdir } from "os";
11
+ import { join } from "path";
12
+ import type { IO } from "../core/types";
13
+ import { getGcloudServiceAccountNames } from "./serviceAccountNames";
14
+ import { accountExists } from "./serviceAccounts";
15
+
16
+ /**
17
+ * the dedicated read-only service account of a component/env that
18
+ * `project setup` provisions from the `localAccess` declaration.
19
+ * Shared with `project doctor` and catenv.
20
+ */
21
+ export const getLocalAccessServiceAccountNames = (
22
+ context: ComponentContext,
23
+ projectId: string,
24
+ ) =>
25
+ getGcloudServiceAccountNames(context, {
26
+ name: LOCAL_ACCESS_GCP_SA_NAME,
27
+ projectId,
28
+ });
29
+
30
+ type IamBinding = {
31
+ role: string;
32
+ members?: string[];
33
+ condition?: { title?: string; expression?: string };
34
+ };
35
+
36
+ /**
37
+ * all unconditional roles bound to `member` in the given iam policy
38
+ * bindings. Conditional bindings are reported separately, see
39
+ * {@link getConditionalRolesOfMemberInBindings}.
40
+ */
41
+ export const getRolesOfMemberInBindings = (
42
+ bindings: IamBinding[],
43
+ member: string,
44
+ ): string[] =>
45
+ bindings
46
+ .filter((binding) => !binding.condition)
47
+ .filter((binding) => binding.members?.includes(member))
48
+ .map((binding) => binding.role)
49
+ .sort();
50
+
51
+ /**
52
+ * roles bound to `member` under an iam condition. These cannot be
53
+ * removed by role name alone, so they are never touched by `project
54
+ * setup` — only reported.
55
+ */
56
+ export const getConditionalRolesOfMemberInBindings = (
57
+ bindings: IamBinding[],
58
+ member: string,
59
+ ): string[] =>
60
+ bindings
61
+ .filter((binding) => binding.condition)
62
+ .filter((binding) => binding.members?.includes(member))
63
+ .map((binding) => binding.role)
64
+ .sort();
65
+
66
+ /**
67
+ * parses the output of `gcloud iam service-accounts keys create` and
68
+ * makes sure it is a usable service account key
69
+ */
70
+ export const parseServiceAccountKey = (
71
+ raw: string,
72
+ ): { key: string; privateKeyId: string } => {
73
+ let parsed: { private_key_id?: unknown; private_key?: unknown };
74
+ try {
75
+ parsed = JSON.parse(raw);
76
+ } catch {
77
+ throw new Error("gcloud did not return a valid service account key");
78
+ }
79
+ if (
80
+ typeof parsed.private_key_id !== "string" ||
81
+ typeof parsed.private_key !== "string"
82
+ ) {
83
+ throw new Error("gcloud did not return a valid service account key");
84
+ }
85
+ return { key: raw, privateKeyId: parsed.private_key_id };
86
+ };
87
+
88
+ const getProjectBindings = async (projectId: string): Promise<IamBinding[]> =>
89
+ JSON.parse(
90
+ (await exec(`gcloud projects get-iam-policy ${projectId} --format=json`))
91
+ .stdout,
92
+ ).bindings ?? [];
93
+
94
+ export const listUserManagedKeyIds = async (
95
+ fullIdentifier: string,
96
+ ): Promise<string[]> => {
97
+ const keys: { name: string }[] = JSON.parse(
98
+ (
99
+ await exec(
100
+ `gcloud iam service-accounts keys list --iam-account=${fullIdentifier} --managed-by=user --format=json`,
101
+ )
102
+ ).stdout,
103
+ );
104
+ return keys.map((key) => key.name.split("/").pop()!);
105
+ };
106
+
107
+ /**
108
+ * makes the account's bindings equal to `roles`: adds missing, removes
109
+ * everything else (the account is dedicated, every other binding is
110
+ * drift). Idempotent — a no-op when nothing differs.
111
+ */
112
+ const syncBindings = async (
113
+ io: IO,
114
+ projectId: string,
115
+ fullIdentifier: string,
116
+ roles: string[],
117
+ ) => {
118
+ const member = `serviceAccount:${fullIdentifier}`;
119
+ const bindings = await getProjectBindings(projectId);
120
+ const conditional = getConditionalRolesOfMemberInBindings(bindings, member);
121
+ if (conditional.length > 0) {
122
+ io.log(
123
+ `⚠️ ${fullIdentifier} has conditional bindings (${conditional.join(", ")}) — setup does not touch them, remove them by hand`,
124
+ );
125
+ }
126
+ const { missing, extra } = diffRoleBindings(
127
+ roles,
128
+ getRolesOfMemberInBindings(bindings, member),
129
+ );
130
+ for (const role of missing) {
131
+ io.log(` + ${role}`);
132
+ await exec(
133
+ `gcloud projects add-iam-policy-binding ${projectId} --member=${member} --role=${role} --condition=None`,
134
+ );
135
+ }
136
+ for (const role of extra) {
137
+ io.log(` - ${role}`);
138
+ await exec(
139
+ `gcloud projects remove-iam-policy-binding ${projectId} --member=${member} --role=${role} --condition=None`,
140
+ );
141
+ }
142
+ };
143
+
144
+ /**
145
+ * brings the local access service account of a component/env in line
146
+ * with the declared `roles`:
147
+ *
148
+ * - roles declared: account exists, carries exactly these bindings,
149
+ * and a key for it is in the secret store (under the `local` env, so
150
+ * it is never mirrored to ci). The key is only created when the
151
+ * stored one is missing or no longer valid — re-running is a no-op.
152
+ * - no roles declared: bindings are removed and the account (with its
153
+ * keys) deleted if it exists.
154
+ */
155
+ export const syncLocalAccessServiceAccount = async (
156
+ io: IO,
157
+ context: ComponentContext,
158
+ projectId: string,
159
+ roles: string[],
160
+ ): Promise<void> => {
161
+ const { fullName, fullIdentifier } = getLocalAccessServiceAccountNames(
162
+ context,
163
+ projectId,
164
+ );
165
+ const exists = await accountExists(fullIdentifier);
166
+
167
+ if (roles.length === 0) {
168
+ if (exists) {
169
+ io.log(`local access no longer declared, removing ${fullIdentifier}...`);
170
+ await syncBindings(io, projectId, fullIdentifier, []);
171
+ await exec(
172
+ `gcloud iam service-accounts delete ${fullIdentifier} --quiet --project="${projectId}"`,
173
+ );
174
+ }
175
+ return;
176
+ }
177
+
178
+ if (!exists) {
179
+ io.log(`creating local access service account ${fullIdentifier}...`);
180
+ await exec(
181
+ `gcloud iam service-accounts create ${fullName} --display-name="${context.fullConfig.customerName}-${context.fullConfig.appName} ${context.env}:${context.name} | Catladder local access (read-only)" --project="${projectId}" --description="Read-only access for local use (agents, scripts)"`,
182
+ );
183
+ }
184
+ await syncBindings(io, projectId, fullIdentifier, roles);
185
+
186
+ const secretName = getLocalAccessGcpSecretName(context.env);
187
+ const vaultManager = await io.getVaultManager();
188
+ const rawName = getSecretVarName("local", context.name, secretName);
189
+ // straight from the vault, NOT through the local cache: a stale cache
190
+ // (older than the first setup, or holding an older key) would make us
191
+ // believe the key is gone, delete the valid one and lock out every
192
+ // other machine.
193
+ const stored = (await vaultManager.vault.readAllSecrets(io))[rawName];
194
+
195
+ const validKeyIds = await listUserManagedKeyIds(fullIdentifier);
196
+ let storedKeyId: string | undefined;
197
+ try {
198
+ storedKeyId = JSON.parse(stored).private_key_id;
199
+ } catch {
200
+ // no (or unparsable) stored key
201
+ }
202
+ if (storedKeyId && validKeyIds.includes(storedKeyId)) {
203
+ // keep the cache in line with the vault
204
+ await vaultManager.cacheSecrets("local", context.name, {
205
+ [secretName]: stored,
206
+ });
207
+ // at most one key per account: also drop leftovers of a failed run
208
+ // or keys added by hand
209
+ await deleteKeys(
210
+ fullIdentifier,
211
+ validKeyIds.filter((id) => id !== storedKeyId),
212
+ );
213
+ return;
214
+ }
215
+
216
+ io.log("creating local access key...");
217
+ // create first: a failed create must not leave the vault empty
218
+ const { key, privateKeyId } = await createServiceAccountKey(fullIdentifier);
219
+ // straight into the vault, NOT through writeSecretsAndMirror: this
220
+ // key is for local use only and must never reach ci variables.
221
+ // A fresh key replaces the old one, a backup would be dead.
222
+ await vaultManager.writeSecrets(
223
+ "local",
224
+ context.name,
225
+ { [secretName]: key },
226
+ io,
227
+ { backup: false },
228
+ );
229
+ // only now drop the keys that are not the new one
230
+ await deleteKeys(
231
+ fullIdentifier,
232
+ validKeyIds.filter((id) => id !== privateKeyId),
233
+ );
234
+ };
235
+
236
+ const deleteKeys = async (fullIdentifier: string, keyIds: string[]) => {
237
+ for (const keyId of keyIds) {
238
+ await exec(
239
+ `gcloud iam service-accounts keys delete ${keyId} --quiet --iam-account=${fullIdentifier}`,
240
+ );
241
+ }
242
+ };
243
+
244
+ const createServiceAccountKey = async (fullIdentifier: string) => {
245
+ // a private temp file (0700 folder) instead of /dev/stdout | cat:
246
+ // the pipe hid gcloud's exit code
247
+ const folder = await mkdtemp(join(tmpdir(), "catladder-local-access-"));
248
+ try {
249
+ const file = join(folder, "key.json");
250
+ await exec(
251
+ `gcloud iam service-accounts keys create "${file}" --iam-account=${fullIdentifier}`,
252
+ );
253
+ return parseServiceAccountKey(await readFile(file, "utf-8"));
254
+ } finally {
255
+ await rm(folder, { recursive: true, force: true });
256
+ }
257
+ };
@@ -9,8 +9,14 @@ export const accountExists = async (fullIdentifier: string) => {
9
9
  try {
10
10
  await exec(`gcloud iam service-accounts describe ${fullIdentifier}`);
11
11
  return true;
12
- } catch {
13
- return false;
12
+ } catch (e) {
13
+ // only a missing account means "does not exist": an auth or
14
+ // permission error must not look like one (setup would skip the
15
+ // deletion, doctor would report it missing)
16
+ if (`${e?.stderr ?? ""}${e?.message ?? ""}`.includes("NOT_FOUND")) {
17
+ return false;
18
+ }
19
+ throw e;
14
20
  }
15
21
  };
16
22
 
@@ -0,0 +1,166 @@
1
+ import { mkdtemp, mkdir, readFile, stat, writeFile, rm } from "fs/promises";
2
+ import { existsSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import type { Config } from "@catladder/pipeline";
7
+ import { getSecretVarName } from "@catladder/pipeline";
8
+
9
+ let root: string;
10
+ vi.mock("../../git/gitProjectInformation", () => ({
11
+ getProjectRootPath: async () => root,
12
+ }));
13
+
14
+ import {
15
+ getLocalAccessKeyFileName,
16
+ getLocalAccessKeyFileVarName,
17
+ getLocalAccessKeyFileVars,
18
+ writeLocalAccessKeyFiles,
19
+ } from "../keyFiles";
20
+
21
+ const cloudRun = {
22
+ type: "google-cloudrun",
23
+ projectId: "p",
24
+ region: "r",
25
+ } as any;
26
+
27
+ const config = (extra: Partial<Config>): Config =>
28
+ ({
29
+ appName: "app",
30
+ customerName: "acme",
31
+ components: { web: { dir: ".", build: false, deploy: cloudRun } },
32
+ ...extra,
33
+ }) as Config;
34
+
35
+ const ioWith = (secrets: Record<string, string>) => {
36
+ const logs: string[] = [];
37
+ return {
38
+ logs,
39
+ io: {
40
+ log: (m: string) => logs.push(m),
41
+ getVaultManager: async () => ({ readSecrets: async () => secrets }),
42
+ } as any,
43
+ };
44
+ };
45
+
46
+ beforeEach(async () => {
47
+ root = await mkdtemp(join(tmpdir(), "keyfiles-test-"));
48
+ });
49
+ afterEach(async () => {
50
+ await rm(root, { recursive: true, force: true });
51
+ });
52
+
53
+ describe("local access key file naming", () => {
54
+ it("derives a file name per env and component", () => {
55
+ expect(getLocalAccessKeyFileName("review", "web-app")).toBe(
56
+ "gcp-review-web_app.json",
57
+ );
58
+ });
59
+ it("exposes the path under a var that is not GOOGLE_APPLICATION_CREDENTIALS", () => {
60
+ expect(getLocalAccessKeyFileVarName("review")).toBe(
61
+ "CATLADDER_LOCAL_ACCESS_GCP_REVIEW_KEY_FILE",
62
+ );
63
+ });
64
+ });
65
+
66
+ describe("writeLocalAccessKeyFiles", () => {
67
+ const secretName = getSecretVarName(
68
+ "local",
69
+ "web",
70
+ "LOCAL_ACCESS_GCP_DEV_CREDENTIALS",
71
+ );
72
+ const declared = config({
73
+ environments: { dev: { localAccess: { gcp: { presets: ["logs"] } } } },
74
+ });
75
+
76
+ it("creates nothing without a declaration", async () => {
77
+ const { io } = ioWith({});
78
+ await writeLocalAccessKeyFiles(io, config({}));
79
+ expect(existsSync(join(root, ".catladder"))).toBe(false);
80
+ });
81
+
82
+ it("does not throw on an invalid declaration", async () => {
83
+ const { io } = ioWith({});
84
+ await writeLocalAccessKeyFiles(
85
+ io,
86
+ config({ localAccess: { gcp: { roles: ["roles/owner"] } } }),
87
+ );
88
+ expect(existsSync(join(root, ".catladder"))).toBe(false);
89
+ });
90
+
91
+ it("writes the key with mode 0600 and removes stale files", async () => {
92
+ const folder = join(root, ".catladder", "local-access");
93
+ await mkdir(folder, { recursive: true });
94
+ await writeFile(join(folder, "stale.json"), "x");
95
+ const { io } = ioWith({ [secretName]: '{"private_key_id":"a"}' });
96
+ await writeLocalAccessKeyFiles(io, declared);
97
+ const file = join(folder, "gcp-dev-web.json");
98
+ expect(await readFile(file, "utf-8")).toBe('{"private_key_id":"a"}');
99
+ expect((await stat(file)).mode & 0o777).toBe(0o600);
100
+ expect(existsSync(join(folder, "stale.json"))).toBe(false);
101
+ });
102
+
103
+ it("warns when the key is missing", async () => {
104
+ const { io, logs } = ioWith({});
105
+ await writeLocalAccessKeyFiles(io, declared);
106
+ expect(logs.join("\n")).toMatch(/no local access key for dev:web/);
107
+ });
108
+ });
109
+
110
+ describe("getLocalAccessKeyFileVars", () => {
111
+ const folder = () => join(root, ".catladder", "local-access");
112
+ const keyFile = () => join(folder(), "gcp-dev-web.json");
113
+ const withKey = async () => {
114
+ await mkdir(folder(), { recursive: true });
115
+ await writeFile(keyFile(), "{}");
116
+ };
117
+ const vertexDev = config({
118
+ environments: {
119
+ dev: {
120
+ localAccess: {
121
+ gcp: {
122
+ presets: ["vertex"],
123
+ credentialsEnvVar: "GOOGLE_APPLICATION_CREDENTIALS",
124
+ },
125
+ },
126
+ },
127
+ },
128
+ });
129
+
130
+ it("exports existing key files under the catladder var", async () => {
131
+ await withKey();
132
+ const c = config({
133
+ environments: { dev: { localAccess: { gcp: { presets: ["logs"] } } } },
134
+ });
135
+ expect(await getLocalAccessKeyFileVars(c, "web")).toEqual({
136
+ CATLADDER_LOCAL_ACCESS_GCP_DEV_KEY_FILE: keyFile(),
137
+ });
138
+ });
139
+
140
+ it("also exports the declared credentialsEnvVar", async () => {
141
+ await withKey();
142
+ expect(await getLocalAccessKeyFileVars(vertexDev, "web")).toEqual({
143
+ CATLADDER_LOCAL_ACCESS_GCP_DEV_KEY_FILE: keyFile(),
144
+ GOOGLE_APPLICATION_CREDENTIALS: keyFile(),
145
+ });
146
+ });
147
+
148
+ it("exports nothing while the key file is missing", async () => {
149
+ expect(await getLocalAccessKeyFileVars(vertexDev, "web")).toEqual({});
150
+ });
151
+
152
+ it("skips a conflicting credentialsEnvVar instead of throwing", async () => {
153
+ await withKey();
154
+ const c = config({
155
+ localAccess: {
156
+ gcp: {
157
+ presets: ["vertex"],
158
+ credentialsEnvVar: "GOOGLE_APPLICATION_CREDENTIALS",
159
+ },
160
+ },
161
+ });
162
+ const vars = await getLocalAccessKeyFileVars(c, "web");
163
+ expect(vars).not.toHaveProperty("GOOGLE_APPLICATION_CREDENTIALS");
164
+ expect(vars).toHaveProperty("CATLADDER_LOCAL_ACCESS_GCP_DEV_KEY_FILE");
165
+ });
166
+ });
@@ -0,0 +1,38 @@
1
+ import type { Config } from "@catladder/pipeline";
2
+ import {
3
+ getLocalAccessGcpCredentialsEnvVars,
4
+ getLocalAccessGcpEnvs,
5
+ } from "@catladder/pipeline";
6
+
7
+ /**
8
+ * the envs of a component declaring gcp local access. An invalid
9
+ * declaration yields none instead of throwing: catenv and the vault
10
+ * cache must keep working (`project doctor` and `project setup` report
11
+ * the invalid declaration loudly).
12
+ */
13
+ export const getDeclaredLocalAccessEnvs = (
14
+ config: Config,
15
+ componentName: string,
16
+ ): string[] => {
17
+ try {
18
+ return getLocalAccessGcpEnvs(config, componentName);
19
+ } catch {
20
+ return [];
21
+ }
22
+ };
23
+
24
+ /**
25
+ * the declared `credentialsEnvVar` names of a component, mapped to their
26
+ * env. Empty instead of throwing on an invalid declaration, see
27
+ * {@link getDeclaredLocalAccessEnvs}.
28
+ */
29
+ export const getDeclaredLocalAccessCredentialsEnvVars = (
30
+ config: Config,
31
+ componentName: string,
32
+ ): Record<string, string> => {
33
+ try {
34
+ return getLocalAccessGcpCredentialsEnvVars(config, componentName);
35
+ } catch {
36
+ return {};
37
+ }
38
+ };