@catladder/cli 5.0.1 → 5.1.0
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/apps/cli/src/apps/cli/commands/project/doctor/checkGithub.js +3 -3
- package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithub.js.map +1 -1
- package/dist/apps/cli/src/apps/cli/commands/project/setup/setupAccessTokens.js +2 -2
- package/dist/apps/cli/src/apps/cli/commands/project/setup/setupAccessTokens.js.map +1 -1
- package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.js +2 -2
- package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.js.map +1 -1
- package/dist/apps/cli/src/apps/cli/commands/project/setup/setupKubernetes.js +23 -7
- package/dist/apps/cli/src/apps/cli/commands/project/setup/setupKubernetes.js.map +1 -1
- package/dist/apps/cli/src/commands/index.d.ts +1 -0
- package/dist/apps/cli/src/commands/index.js +4 -1
- package/dist/apps/cli/src/commands/index.js.map +1 -1
- package/dist/apps/cli/src/commands/project/commandConfigSecrets.js +2 -1
- package/dist/apps/cli/src/commands/project/commandConfigSecrets.js.map +1 -1
- package/dist/apps/cli/src/commands/project/commandMrPin.d.ts +21 -0
- package/dist/apps/cli/src/commands/project/commandMrPin.js +119 -0
- package/dist/apps/cli/src/commands/project/commandMrPin.js.map +1 -0
- package/dist/apps/cli/src/commands/project/commandSecretsSyncGithub.d.ts +1 -42
- package/dist/apps/cli/src/commands/project/commandSecretsSyncGithub.js +5 -124
- package/dist/apps/cli/src/commands/project/commandSecretsSyncGithub.js.map +1 -1
- package/dist/apps/cli/src/commands/project/secrets/commandSecretsPush.js +2 -1
- package/dist/apps/cli/src/commands/project/secrets/commandSecretsPush.js.map +1 -1
- package/dist/apps/cli/src/commands/project/secrets/commandSecretsSet.js +2 -2
- package/dist/apps/cli/src/commands/project/secrets/commandSecretsSet.js.map +1 -1
- package/dist/apps/cli/src/commands/project/secrets/document.d.ts +1 -12
- package/dist/apps/cli/src/commands/project/secrets/document.js +1 -79
- package/dist/apps/cli/src/commands/project/secrets/document.js.map +1 -1
- package/dist/apps/cli/src/gcloud/serviceAccounts.js +15 -4
- package/dist/apps/cli/src/gcloud/serviceAccounts.js.map +1 -1
- package/dist/apps/cli/src/secrets/github.d.ts +41 -0
- package/dist/apps/cli/src/secrets/github.js +127 -0
- package/dist/apps/cli/src/secrets/github.js.map +1 -0
- package/dist/apps/cli/src/secrets/index.d.ts +2 -0
- package/dist/apps/cli/src/secrets/index.js +19 -0
- package/dist/apps/cli/src/secrets/index.js.map +1 -0
- package/dist/apps/cli/src/secrets/writeSecrets.d.ts +18 -0
- package/dist/apps/cli/src/secrets/writeSecrets.js +88 -0
- package/dist/apps/cli/src/secrets/writeSecrets.js.map +1 -0
- package/dist/apps/cli/src/utils/gitlab.d.ts +10 -0
- package/dist/apps/cli/src/utils/gitlab.js +30 -5
- package/dist/apps/cli/src/utils/gitlab.js.map +1 -1
- package/dist/apps/cli/src/vault/BitwardenVault.d.ts +6 -0
- package/dist/apps/cli/src/vault/BitwardenVault.js +11 -1
- package/dist/apps/cli/src/vault/BitwardenVault.js.map +1 -1
- package/dist/apps/cli/src/vault/GitlabVault.d.ts +2 -2
- package/dist/apps/cli/src/vault/GitlabVault.js +3 -2
- package/dist/apps/cli/src/vault/GitlabVault.js.map +1 -1
- package/dist/apps/cli/src/vault/VaultManager.d.ts +2 -2
- package/dist/apps/cli/src/vault/VaultManager.js +2 -2
- package/dist/apps/cli/src/vault/VaultManager.js.map +1 -1
- package/dist/apps/cli/src/vault/types.d.ts +16 -2
- package/dist/bundles/catenv/index.js +115 -13
- package/dist/bundles/cli/index.js +749 -431
- package/dist/bundles/skills/catladder-cli/references/commands.md +13 -0
- package/dist/bundles/skills/catladder-deploys/SKILL.md +7 -0
- package/dist/bundles/skills/catladder-migrate-ci-backend/SKILL.md +7 -2
- package/dist/bundles/skills/catladder-pipelines/SKILL.md +27 -1
- package/dist/bundles/skills/catladder-secrets/SKILL.md +16 -0
- package/dist/packages/pipeline/src/autoStop.d.ts +22 -0
- package/dist/packages/pipeline/src/autoStop.js +30 -0
- package/dist/packages/pipeline/src/autoStop.js.map +1 -0
- package/dist/packages/pipeline/src/backends/gitlab/GitlabBackend.js +2 -0
- package/dist/packages/pipeline/src/backends/gitlab/GitlabBackend.js.map +1 -1
- package/dist/packages/pipeline/src/backends/gitlab/createGitlabPipeline.d.ts +4 -1
- package/dist/packages/pipeline/src/backends/gitlab/createGitlabPipeline.js +21 -1
- package/dist/packages/pipeline/src/backends/gitlab/createGitlabPipeline.js.map +1 -1
- package/dist/packages/pipeline/src/deploy/base/deploy.js +8 -2
- package/dist/packages/pipeline/src/deploy/base/deploy.js.map +1 -1
- package/dist/packages/pipeline/src/index.d.ts +1 -0
- package/dist/packages/pipeline/src/index.js +1 -0
- package/dist/packages/pipeline/src/index.js.map +1 -1
- package/dist/packages/pipeline/src/types/config.d.ts +32 -0
- package/dist/skills/catladder-cli/references/commands.md +13 -0
- package/dist/skills/catladder-deploys/SKILL.md +7 -0
- package/dist/skills/catladder-migrate-ci-backend/SKILL.md +7 -2
- package/dist/skills/catladder-pipelines/SKILL.md +27 -1
- package/dist/skills/catladder-secrets/SKILL.md +16 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/apps/cli/commands/project/doctor/checkGithub.ts +1 -1
- package/src/apps/cli/commands/project/setup/setupAccessTokens.ts +3 -2
- package/src/apps/cli/commands/project/setup/setupGithub.ts +1 -1
- package/src/apps/cli/commands/project/setup/setupKubernetes.ts +31 -20
- package/src/commands/index.ts +1 -0
- package/src/commands/project/commandConfigSecrets.ts +1 -1
- package/src/commands/project/commandMrPin.ts +184 -0
- package/src/commands/project/commandSecretsSyncGithub.ts +7 -154
- package/src/commands/project/secrets/commandSecretsPush.ts +2 -5
- package/src/commands/project/secrets/commandSecretsSet.ts +2 -2
- package/src/commands/project/secrets/document.ts +2 -127
- package/src/gcloud/serviceAccounts.ts +16 -7
- package/src/secrets/__tests__/writeSecrets.test.ts +147 -0
- package/src/secrets/github.ts +157 -0
- package/src/secrets/index.ts +2 -0
- package/src/secrets/writeSecrets.ts +138 -0
- package/src/utils/gitlab.ts +34 -7
- package/src/vault/BitwardenVault.ts +10 -1
- package/src/vault/GitlabVault.ts +9 -2
- package/src/vault/VaultManager.ts +3 -2
- package/src/vault/__tests__/BitwardenVault.test.ts +88 -0
- package/src/vault/types.ts +17 -1
|
@@ -2,7 +2,7 @@ import type { ComponentContext } from "@catladder/pipeline";
|
|
|
2
2
|
|
|
3
3
|
import { exec } from "child-process-promise";
|
|
4
4
|
import type { IO } from "../core/types";
|
|
5
|
-
import {
|
|
5
|
+
import { writeSecretsAndMirror } from "../secrets";
|
|
6
6
|
import { getGcloudServiceAccountNames } from "./serviceAccountNames";
|
|
7
7
|
|
|
8
8
|
export const accountExists = async (fullIdentifier: string) => {
|
|
@@ -76,13 +76,22 @@ export const upsertGcloudServiceAccountAndSaveSecret = async (
|
|
|
76
76
|
instance.log("upserting service account " + account.name + "...");
|
|
77
77
|
const key = await upsertGcloudServiceAccount(context, account);
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
// through the vault (and from there to every enabled ci backend) —
|
|
80
|
+
// the key is a secret like any other, the deploy jobs read it under
|
|
81
|
+
// its declared name. Writing it to gitlab directly would break every
|
|
82
|
+
// project that has no gitlab.
|
|
83
|
+
await writeSecretsAndMirror(
|
|
80
84
|
instance,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
[
|
|
86
|
+
{
|
|
87
|
+
env: context.env,
|
|
88
|
+
componentName: context.name,
|
|
89
|
+
secrets: { [secretName]: key },
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
// a fresh key is created on every run, the previous one is deleted
|
|
93
|
+
// at google — a backup copy of it would only be a dead secret
|
|
94
|
+
{ backup: false },
|
|
86
95
|
);
|
|
87
96
|
instance.log("done!");
|
|
88
97
|
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { Config } from "@catladder/pipeline";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { IO } from "../../core/types";
|
|
4
|
+
import { writeSecretsAndMirror } from "../writeSecrets";
|
|
5
|
+
|
|
6
|
+
const { getProjectConfig, getEnvironment } = vi.hoisted(() => ({
|
|
7
|
+
getProjectConfig: vi.fn(),
|
|
8
|
+
getEnvironment: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
const { upsertAllVariables } = vi.hoisted(() => ({
|
|
11
|
+
upsertAllVariables: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
const { isGhAuthenticated } = vi.hoisted(() => ({
|
|
14
|
+
isGhAuthenticated: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
const { collectSecretTargets, getConfiguredGithubRepo, pushSecretsToGithub } =
|
|
17
|
+
vi.hoisted(() => ({
|
|
18
|
+
collectSecretTargets: vi.fn(),
|
|
19
|
+
getConfiguredGithubRepo: vi.fn(),
|
|
20
|
+
pushSecretsToGithub: vi.fn(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock("../../config/getProjectConfig", () => ({
|
|
24
|
+
getProjectConfig,
|
|
25
|
+
getEnvironment,
|
|
26
|
+
}));
|
|
27
|
+
vi.mock("../../utils/gitlab", () => ({ upsertAllVariables }));
|
|
28
|
+
vi.mock("../../utils/github", () => ({ isGhAuthenticated }));
|
|
29
|
+
vi.mock("../github", () => ({
|
|
30
|
+
collectSecretTargets,
|
|
31
|
+
getConfiguredGithubRepo,
|
|
32
|
+
pushSecretsToGithub,
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
const vaultWriteSecrets = vi.fn();
|
|
36
|
+
const io = {
|
|
37
|
+
log: vi.fn(),
|
|
38
|
+
getVaultManager: async () => ({ writeSecrets: vaultWriteSecrets }),
|
|
39
|
+
} as unknown as IO;
|
|
40
|
+
|
|
41
|
+
const config = (partial: Partial<Config>): Config =>
|
|
42
|
+
({
|
|
43
|
+
customerName: "pan",
|
|
44
|
+
appName: "app",
|
|
45
|
+
components: {},
|
|
46
|
+
...partial,
|
|
47
|
+
}) as Config;
|
|
48
|
+
|
|
49
|
+
const writes = [
|
|
50
|
+
{ env: "dev", componentName: "www", secrets: { API_KEY: "sesame" } },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
vi.clearAllMocks();
|
|
55
|
+
getEnvironment.mockResolvedValue({
|
|
56
|
+
secretEnvVarKeys: [{ key: "API_KEY" }],
|
|
57
|
+
jobOnlyVars: {
|
|
58
|
+
build: { secretEnvVarKeys: [] },
|
|
59
|
+
deploy: { secretEnvVarKeys: [] },
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
isGhAuthenticated.mockResolvedValue(true);
|
|
63
|
+
getConfiguredGithubRepo.mockResolvedValue("panter/app");
|
|
64
|
+
collectSecretTargets.mockResolvedValue(
|
|
65
|
+
new Map([["CL_dev_www_API_KEY", new Set(["dev"])]]),
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("writeSecretsAndMirror", () => {
|
|
70
|
+
it("never touches the gitlab api on a project without a gitlab pipeline", async () => {
|
|
71
|
+
getProjectConfig.mockResolvedValue(
|
|
72
|
+
config({
|
|
73
|
+
pipelines: { github: true },
|
|
74
|
+
secrets: { vault: { type: "bitwarden" } },
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
await writeSecretsAndMirror(io, writes);
|
|
79
|
+
|
|
80
|
+
expect(vaultWriteSecrets).toHaveBeenCalledWith(
|
|
81
|
+
"dev",
|
|
82
|
+
"www",
|
|
83
|
+
{ API_KEY: "sesame" },
|
|
84
|
+
io,
|
|
85
|
+
{},
|
|
86
|
+
);
|
|
87
|
+
expect(upsertAllVariables).not.toHaveBeenCalled();
|
|
88
|
+
expect(pushSecretsToGithub).toHaveBeenCalledWith(
|
|
89
|
+
io,
|
|
90
|
+
"panter/app",
|
|
91
|
+
[{ name: "CL_dev_www_API_KEY", value: "sesame", kind: "secret" }],
|
|
92
|
+
expect.any(Map),
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("does not mirror to gitlab when gitlab IS the vault (already written)", async () => {
|
|
97
|
+
getProjectConfig.mockResolvedValue(config({ pipelines: { gitlab: true } }));
|
|
98
|
+
|
|
99
|
+
await writeSecretsAndMirror(io, writes);
|
|
100
|
+
|
|
101
|
+
expect(vaultWriteSecrets).toHaveBeenCalledTimes(1);
|
|
102
|
+
expect(upsertAllVariables).not.toHaveBeenCalled();
|
|
103
|
+
expect(pushSecretsToGithub).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("mirrors to gitlab when it is only a backend, and forwards the backup option", async () => {
|
|
107
|
+
getProjectConfig.mockResolvedValue(
|
|
108
|
+
config({
|
|
109
|
+
pipelines: { gitlab: true, github: true },
|
|
110
|
+
secrets: { vault: "bitwarden" },
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
await writeSecretsAndMirror(io, writes, { backup: false });
|
|
115
|
+
|
|
116
|
+
expect(vaultWriteSecrets).toHaveBeenCalledWith(
|
|
117
|
+
"dev",
|
|
118
|
+
"www",
|
|
119
|
+
{ API_KEY: "sesame" },
|
|
120
|
+
io,
|
|
121
|
+
{ backup: false },
|
|
122
|
+
);
|
|
123
|
+
expect(upsertAllVariables).toHaveBeenCalledWith(
|
|
124
|
+
io,
|
|
125
|
+
{ API_KEY: "sesame" },
|
|
126
|
+
"dev",
|
|
127
|
+
"www",
|
|
128
|
+
false,
|
|
129
|
+
);
|
|
130
|
+
expect(pushSecretsToGithub).toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("keeps the secrets in the vault when the github mirror cannot run", async () => {
|
|
134
|
+
getProjectConfig.mockResolvedValue(
|
|
135
|
+
config({
|
|
136
|
+
pipelines: { github: true },
|
|
137
|
+
secrets: { vault: "bitwarden" },
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
isGhAuthenticated.mockResolvedValue(false);
|
|
141
|
+
|
|
142
|
+
await writeSecretsAndMirror(io, writes);
|
|
143
|
+
|
|
144
|
+
expect(vaultWriteSecrets).toHaveBeenCalledTimes(1);
|
|
145
|
+
expect(pushSecretsToGithub).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { Config } from "@catladder/pipeline";
|
|
2
|
+
import {
|
|
3
|
+
getPipelineGitRemote,
|
|
4
|
+
getSecretVarName,
|
|
5
|
+
GithubBackend,
|
|
6
|
+
} from "@catladder/pipeline";
|
|
7
|
+
import {
|
|
8
|
+
getAllComponentsWithAllEnvsHierarchical,
|
|
9
|
+
getEnvironment,
|
|
10
|
+
getProjectConfig,
|
|
11
|
+
} from "../config/getProjectConfig";
|
|
12
|
+
import type { IO } from "../core/types";
|
|
13
|
+
import {
|
|
14
|
+
ensureGithubEnvironment,
|
|
15
|
+
getGithubRepoFromRemote,
|
|
16
|
+
setGithubSecret,
|
|
17
|
+
setGithubVariable,
|
|
18
|
+
} from "../utils/github";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* all secrets declared in the config, with their values read from the
|
|
22
|
+
* vault (github secrets are write-only and can never act as a source)
|
|
23
|
+
*/
|
|
24
|
+
export const collectSecretsFromVault = async (io: IO, envFilter?: string[]) => {
|
|
25
|
+
const envAndComponents = await getAllComponentsWithAllEnvsHierarchical();
|
|
26
|
+
|
|
27
|
+
const kinds = new Map<string, "secret" | "variable">();
|
|
28
|
+
for (const [componentName, envs] of Object.entries(envAndComponents)) {
|
|
29
|
+
for (const env of envs) {
|
|
30
|
+
if (envFilter && !envFilter.includes(env)) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const { secretEnvVarKeys, jobOnlyVars } = await getEnvironment(
|
|
34
|
+
env,
|
|
35
|
+
componentName,
|
|
36
|
+
);
|
|
37
|
+
const keys = [
|
|
38
|
+
...secretEnvVarKeys,
|
|
39
|
+
...jobOnlyVars.build.secretEnvVarKeys,
|
|
40
|
+
...jobOnlyVars.deploy.secretEnvVarKeys,
|
|
41
|
+
];
|
|
42
|
+
keys.forEach(({ key, kind }) =>
|
|
43
|
+
kinds.set(getSecretVarName(env, componentName, key), kind ?? "secret"),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const names = [...kinds.keys()];
|
|
49
|
+
const values = await (await io.getVaultManager()).readSecrets(names, io);
|
|
50
|
+
const secrets = names
|
|
51
|
+
.filter((name) => values[name] !== undefined)
|
|
52
|
+
.map((name) => ({
|
|
53
|
+
name,
|
|
54
|
+
value: values[name],
|
|
55
|
+
kind: kinds.get(name) ?? ("secret" as const),
|
|
56
|
+
}));
|
|
57
|
+
const missing = names.filter((name) => values[name] === undefined);
|
|
58
|
+
return { secrets, missing };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** marker for repo-level (environment-less) targets */
|
|
62
|
+
const REPO_LEVEL = "";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* derives which github environment(s) each secret/variable must live in
|
|
66
|
+
* by scanning the GENERATED workflows: a `${{ secrets.X }}` / `${{
|
|
67
|
+
* vars.X }}` reference in a job's env targets that job's environment
|
|
68
|
+
* (or the repo level when the job has none). Deriving from the real
|
|
69
|
+
* output means the sync can never drift from what the workflows expect.
|
|
70
|
+
*
|
|
71
|
+
* Environment secrets are the answer to github's cap of 100 secrets
|
|
72
|
+
* per REPO (a multi-env project easily exceeds it): the cap applies
|
|
73
|
+
* per environment instead.
|
|
74
|
+
*/
|
|
75
|
+
export const collectSecretTargets = async (
|
|
76
|
+
config: Config,
|
|
77
|
+
): Promise<Map<string, Set<string>>> => {
|
|
78
|
+
const workflows = await new GithubBackend().createWorkflows(config);
|
|
79
|
+
const targets = new Map<string, Set<string>>();
|
|
80
|
+
for (const workflow of Object.values(workflows)) {
|
|
81
|
+
for (const job of Object.values(workflow.jobs)) {
|
|
82
|
+
const environment =
|
|
83
|
+
typeof job.environment === "object"
|
|
84
|
+
? job.environment.name
|
|
85
|
+
: job.environment;
|
|
86
|
+
for (const value of Object.values(job.env ?? {})) {
|
|
87
|
+
if (typeof value !== "string") continue;
|
|
88
|
+
for (const match of value.matchAll(
|
|
89
|
+
/\$\{\{\s*(?:secrets|vars)\.([A-Za-z0-9_]+)\s*\}\}/g,
|
|
90
|
+
)) {
|
|
91
|
+
const name = match[1];
|
|
92
|
+
if (!targets.has(name)) {
|
|
93
|
+
targets.set(name, new Set());
|
|
94
|
+
}
|
|
95
|
+
targets.get(name)!.add(environment ?? REPO_LEVEL);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return targets;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* pushes vault values to github via the gh cli: sensitive ones as
|
|
105
|
+
* secrets (masked), "variable"-kind ones as variables (plain) — each
|
|
106
|
+
* into the environment(s) whose jobs reference them
|
|
107
|
+
*/
|
|
108
|
+
export const pushSecretsToGithub = async (
|
|
109
|
+
io: IO,
|
|
110
|
+
repo: string,
|
|
111
|
+
secrets: { name: string; value: string; kind: "secret" | "variable" }[],
|
|
112
|
+
targets: Map<string, Set<string>>,
|
|
113
|
+
) => {
|
|
114
|
+
const environments = [
|
|
115
|
+
...new Set(
|
|
116
|
+
[...targets.values()].flatMap((envs) =>
|
|
117
|
+
[...envs].filter((env) => env !== REPO_LEVEL),
|
|
118
|
+
),
|
|
119
|
+
),
|
|
120
|
+
];
|
|
121
|
+
for (const environment of environments) {
|
|
122
|
+
await ensureGithubEnvironment(repo, environment);
|
|
123
|
+
io.log(`🌍 environment ${environment}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const { name, value, kind } of secrets) {
|
|
127
|
+
const envs = targets.get(name);
|
|
128
|
+
if (!envs) {
|
|
129
|
+
io.log(`⏭️ ${name} (not referenced by any generated workflow)`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
for (const environment of envs) {
|
|
133
|
+
const suffix = environment === REPO_LEVEL ? "" : ` → ${environment}`;
|
|
134
|
+
if (kind === "variable") {
|
|
135
|
+
await setGithubVariable(repo, name, value, environment || undefined);
|
|
136
|
+
io.log(`📖 ${name} (variable)${suffix}`);
|
|
137
|
+
} else {
|
|
138
|
+
await setGithubSecret(repo, name, value, environment || undefined);
|
|
139
|
+
io.log(`✅ ${name}${suffix}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* resolves the github repository from the configured git remote
|
|
147
|
+
* (`pipelines.github.gitRemote`, defaults to origin)
|
|
148
|
+
*/
|
|
149
|
+
export const getConfiguredGithubRepo = async (): Promise<
|
|
150
|
+
string | undefined
|
|
151
|
+
> => {
|
|
152
|
+
const config = await getProjectConfig();
|
|
153
|
+
if (!config) {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
return getGithubRepoFromRemote(getPipelineGitRemote(config, "github"));
|
|
157
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { Config } from "@catladder/pipeline";
|
|
2
|
+
import {
|
|
3
|
+
getEnabledPipelineTypes,
|
|
4
|
+
getSecretVarName,
|
|
5
|
+
getVaultConfig,
|
|
6
|
+
} from "@catladder/pipeline";
|
|
7
|
+
import { getEnvironment, getProjectConfig } from "../config/getProjectConfig";
|
|
8
|
+
import type { IO } from "../core/types";
|
|
9
|
+
import { isGhAuthenticated } from "../utils/github";
|
|
10
|
+
import { upsertAllVariables } from "../utils/gitlab";
|
|
11
|
+
import type { WriteSecretsOptions } from "../vault/types";
|
|
12
|
+
import {
|
|
13
|
+
collectSecretTargets,
|
|
14
|
+
getConfiguredGithubRepo,
|
|
15
|
+
pushSecretsToGithub,
|
|
16
|
+
} from "./github";
|
|
17
|
+
|
|
18
|
+
/** secrets of one env of one component, ready to be written */
|
|
19
|
+
export type SecretsWrite = {
|
|
20
|
+
env: string;
|
|
21
|
+
componentName: string;
|
|
22
|
+
secrets: Record<string, unknown>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* the one door for writing secret values: they go into the vault (the
|
|
27
|
+
* source of truth) and are mirrored to every enabled CI backend —
|
|
28
|
+
* which vault and which backends exist is invisible to the caller.
|
|
29
|
+
*
|
|
30
|
+
* Everything that produces a secret value must come through here: the
|
|
31
|
+
* secrets commands, but also the credentials `project setup`
|
|
32
|
+
* provisions (gcloud service account keys, kubernetes tokens).
|
|
33
|
+
*/
|
|
34
|
+
export const writeSecretsAndMirror = async (
|
|
35
|
+
io: IO,
|
|
36
|
+
writes: SecretsWrite[],
|
|
37
|
+
options: WriteSecretsOptions = {},
|
|
38
|
+
): Promise<void> => {
|
|
39
|
+
const config = await getProjectConfig();
|
|
40
|
+
if (!config) {
|
|
41
|
+
throw new Error("no catladder config found");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
io.log("");
|
|
45
|
+
io.log("writing secrets to the vault, please wait...");
|
|
46
|
+
io.log("");
|
|
47
|
+
for (const { env, componentName, secrets } of writes) {
|
|
48
|
+
io.log("writing " + env + ":" + componentName + "...\n");
|
|
49
|
+
await (
|
|
50
|
+
await io.getVaultManager()
|
|
51
|
+
).writeSecrets(env, componentName, secrets, io, options);
|
|
52
|
+
io.log("");
|
|
53
|
+
io.log("✅ " + env + ":" + componentName);
|
|
54
|
+
io.log("--------------------------------\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
await mirrorSecretsToCiBackends(io, config, writes, options);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* the vault is the source of truth; every enabled CI backend receives
|
|
62
|
+
* a mirrored copy of the written secrets (gitlab variables, github
|
|
63
|
+
* secrets), so all CI systems stay in sync
|
|
64
|
+
*/
|
|
65
|
+
const mirrorSecretsToCiBackends = async (
|
|
66
|
+
io: IO,
|
|
67
|
+
config: Config,
|
|
68
|
+
writes: SecretsWrite[],
|
|
69
|
+
options: WriteSecretsOptions,
|
|
70
|
+
) => {
|
|
71
|
+
const enabled = getEnabledPipelineTypes(config);
|
|
72
|
+
|
|
73
|
+
// gitlab mirror (unless gitlab is the vault itself, then it is
|
|
74
|
+
// already written)
|
|
75
|
+
if (getVaultConfig(config).type !== "gitlab" && enabled.includes("gitlab")) {
|
|
76
|
+
for (const { env, componentName, secrets } of writes) {
|
|
77
|
+
await upsertAllVariables(
|
|
78
|
+
io,
|
|
79
|
+
secrets,
|
|
80
|
+
env,
|
|
81
|
+
componentName,
|
|
82
|
+
options.backup ?? true,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
io.log("✅ mirrored to gitlab");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!enabled.includes("github")) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const kinds = new Map<string, "secret" | "variable">();
|
|
92
|
+
for (const { env, componentName } of writes) {
|
|
93
|
+
const { secretEnvVarKeys, jobOnlyVars } = await getEnvironment(
|
|
94
|
+
env,
|
|
95
|
+
componentName,
|
|
96
|
+
);
|
|
97
|
+
[
|
|
98
|
+
...secretEnvVarKeys,
|
|
99
|
+
...jobOnlyVars.build.secretEnvVarKeys,
|
|
100
|
+
...jobOnlyVars.deploy.secretEnvVarKeys,
|
|
101
|
+
].forEach(({ key, kind }) =>
|
|
102
|
+
kinds.set(getSecretVarName(env, componentName, key), kind ?? "secret"),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const secrets = writes.flatMap(({ env, componentName, secrets }) =>
|
|
106
|
+
Object.entries(secrets).map(([key, value]) => {
|
|
107
|
+
const name = getSecretVarName(env, componentName, key);
|
|
108
|
+
return {
|
|
109
|
+
name,
|
|
110
|
+
value: typeof value === "string" ? value : JSON.stringify(value ?? ""),
|
|
111
|
+
kind: kinds.get(name) ?? ("secret" as const),
|
|
112
|
+
};
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
if (secrets.length === 0) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (!(await isGhAuthenticated())) {
|
|
119
|
+
io.log(
|
|
120
|
+
"⚠️ github pipeline is enabled, but the github cli (gh) is not authenticated — run `project secrets-sync-github` later to mirror the secrets",
|
|
121
|
+
);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const repo = await getConfiguredGithubRepo();
|
|
125
|
+
if (!repo) {
|
|
126
|
+
io.log(
|
|
127
|
+
"⚠️ github pipeline is enabled, but no github repository was found — run `project secrets-sync-github` later to mirror the secrets",
|
|
128
|
+
);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
io.log(`mirroring ${secrets.length} secrets to github ('${repo}')...`);
|
|
132
|
+
await pushSecretsToGithub(
|
|
133
|
+
io,
|
|
134
|
+
repo,
|
|
135
|
+
secrets,
|
|
136
|
+
await collectSecretTargets(config),
|
|
137
|
+
);
|
|
138
|
+
};
|
package/src/utils/gitlab.ts
CHANGED
|
@@ -57,6 +57,21 @@ export const getGitlabToken = async (io: IO | null) => {
|
|
|
57
57
|
return getPreference(TOKEN_KEY);
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* a gitlab api call that came back 404. Carries the path so a caller
|
|
62
|
+
* that wants to treat "does not exist yet" as normal can say so
|
|
63
|
+
* (`instanceof`) instead of matching on a message.
|
|
64
|
+
*/
|
|
65
|
+
export class GitlabNotFoundError extends Error {
|
|
66
|
+
constructor(
|
|
67
|
+
readonly path: string,
|
|
68
|
+
readonly host: string,
|
|
69
|
+
) {
|
|
70
|
+
super(`gitlab api: ${path} not found on ${host}`);
|
|
71
|
+
this.name = "GitlabNotFoundError";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
60
75
|
type Method = "GET" | "PUT" | "POST" | "DELETE";
|
|
61
76
|
export const doGitlabRequest = async <T = any>(
|
|
62
77
|
io: IO | null,
|
|
@@ -87,7 +102,7 @@ export const doGitlabRequest = async <T = any>(
|
|
|
87
102
|
return null;
|
|
88
103
|
}
|
|
89
104
|
if (result.status === 404) {
|
|
90
|
-
throw new
|
|
105
|
+
throw new GitlabNotFoundError(path, gitRemoteHost);
|
|
91
106
|
}
|
|
92
107
|
|
|
93
108
|
throw new Error(
|
|
@@ -124,12 +139,24 @@ export const doGitlabRequestAllPages = async <T = any>(
|
|
|
124
139
|
export const getProjectInfo = async (
|
|
125
140
|
io: IO | null,
|
|
126
141
|
): Promise<{ id: string; web_url: string }> => {
|
|
127
|
-
const { gitRemotePath } = await getGitRemoteHostAndPath();
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
142
|
+
const { gitRemoteHost, gitRemotePath } = await getGitRemoteHostAndPath();
|
|
143
|
+
try {
|
|
144
|
+
return await doGitlabRequest(
|
|
145
|
+
io,
|
|
146
|
+
`projects/${encodeURIComponent(gitRemotePath)}`,
|
|
147
|
+
);
|
|
148
|
+
} catch (e) {
|
|
149
|
+
if (e instanceof GitlabNotFoundError) {
|
|
150
|
+
// the classic: the remote is not a gitlab at all (a github one,
|
|
151
|
+
// say), so the request goes to a host without /api/v4 and every
|
|
152
|
+
// path answers 404
|
|
153
|
+
throw new Error(
|
|
154
|
+
`no gitlab project '${gitRemotePath}' on '${gitRemoteHost}'. ` +
|
|
155
|
+
"Check the git remote and `pipelines` in catladder.ts — this command talks to the gitlab api.",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
throw e;
|
|
159
|
+
}
|
|
133
160
|
};
|
|
134
161
|
|
|
135
162
|
type GitlabVariable = {
|
|
@@ -108,6 +108,12 @@ export class BitwardenVault implements SecretsVault {
|
|
|
108
108
|
return secrets;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* upserts into the item's yaml note. The note is rewritten as a
|
|
113
|
+
* whole, so the values that are not part of this write have to be
|
|
114
|
+
* carried over — a single-key write (`secrets-set`, a credential
|
|
115
|
+
* provisioned by `project setup`) must not drop the rest.
|
|
116
|
+
*/
|
|
111
117
|
async writeSecrets(
|
|
112
118
|
io: IO,
|
|
113
119
|
env: string,
|
|
@@ -130,7 +136,10 @@ export class BitwardenVault implements SecretsVault {
|
|
|
130
136
|
])
|
|
131
137
|
).find((item) => item.name === name);
|
|
132
138
|
|
|
133
|
-
const
|
|
139
|
+
const previous = existing?.notes
|
|
140
|
+
? ((parse(existing.notes) as Record<string, unknown> | null) ?? {})
|
|
141
|
+
: {};
|
|
142
|
+
const notes = stringify({ ...previous, ...secrets });
|
|
134
143
|
|
|
135
144
|
if (existing) {
|
|
136
145
|
await runBw(bwSession, [
|
package/src/vault/GitlabVault.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IO } from "../core/types";
|
|
2
2
|
import { getAllVariables, upsertAllVariables } from "../utils/gitlab";
|
|
3
|
-
import type { SecretsVault } from "./types";
|
|
3
|
+
import type { SecretsVault, WriteSecretsOptions } from "./types";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* the legacy (and default) vault: the gitlab project variables double
|
|
@@ -19,7 +19,14 @@ export class GitlabVault implements SecretsVault {
|
|
|
19
19
|
env: string,
|
|
20
20
|
componentName: string,
|
|
21
21
|
secrets: Record<string, unknown>,
|
|
22
|
+
options: WriteSecretsOptions = {},
|
|
22
23
|
): Promise<void> {
|
|
23
|
-
await upsertAllVariables(
|
|
24
|
+
await upsertAllVariables(
|
|
25
|
+
io,
|
|
26
|
+
secrets,
|
|
27
|
+
env,
|
|
28
|
+
componentName,
|
|
29
|
+
options.backup ?? true,
|
|
30
|
+
);
|
|
24
31
|
}
|
|
25
32
|
}
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
writeSecretsCache,
|
|
14
14
|
} from "./cache";
|
|
15
15
|
import { GitlabVault } from "./GitlabVault";
|
|
16
|
-
import type { SecretsMode, SecretsVault } from "./types";
|
|
16
|
+
import type { SecretsMode, SecretsVault, WriteSecretsOptions } from "./types";
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* only secrets of this env are cached locally — the other envs'
|
|
@@ -119,11 +119,12 @@ export class VaultManager {
|
|
|
119
119
|
componentName: string,
|
|
120
120
|
secrets: Record<string, unknown>,
|
|
121
121
|
io: IO | null = this.io,
|
|
122
|
+
options: WriteSecretsOptions = {},
|
|
122
123
|
): Promise<void> {
|
|
123
124
|
if (!io) {
|
|
124
125
|
throw new Error("writing to the vault requires an interactive context");
|
|
125
126
|
}
|
|
126
|
-
await this.vault.writeSecrets(io, env, componentName, secrets);
|
|
127
|
+
await this.vault.writeSecrets(io, env, componentName, secrets, options);
|
|
127
128
|
if (env === CACHED_ENV) {
|
|
128
129
|
await updateSecretsCache(
|
|
129
130
|
this.vault.id,
|