@catladder/cli 0.0.0-next-4e7c6848 → 0.0.0-next-6a4450e0
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/bundles/catenv/index.js +1392 -116
- package/dist/bundles/cli/index.js +1631 -112
- package/dist/cli/src/adapters/baseContext.d.ts +4 -0
- package/dist/cli/src/adapters/baseContext.js +5 -0
- package/dist/cli/src/adapters/baseContext.js.map +1 -1
- package/dist/cli/src/adapters/nonInteractive.d.ts +9 -0
- package/dist/cli/src/adapters/nonInteractive.js +32 -0
- package/dist/cli/src/adapters/nonInteractive.js.map +1 -0
- package/dist/cli/src/adapters/programmatic.d.ts +1 -0
- package/dist/cli/src/adapters/programmatic.js +6 -1
- package/dist/cli/src/adapters/programmatic.js.map +1 -1
- package/dist/cli/src/adapters/terminal.d.ts +1 -0
- package/dist/cli/src/adapters/terminal.js +10 -1
- package/dist/cli/src/adapters/terminal.js.map +1 -1
- package/dist/cli/src/adapters/vaultManagerAccess.d.ts +8 -0
- package/dist/cli/src/adapters/vaultManagerAccess.js +26 -0
- package/dist/cli/src/adapters/vaultManagerAccess.js.map +1 -0
- package/dist/cli/src/apps/catenv/catenv.d.ts +2 -0
- package/dist/cli/src/apps/catenv/catenv.js +8 -1
- package/dist/cli/src/apps/catenv/catenv.js.map +1 -1
- package/dist/cli/src/apps/catenv/printVariables.d.ts +2 -2
- package/dist/cli/src/apps/catenv/printVariables.js +4 -4
- package/dist/cli/src/apps/catenv/printVariables.js.map +1 -1
- package/dist/cli/src/apps/catenv/types.d.ts +9 -0
- package/dist/cli/src/apps/catenv/writeDotEnvFiles.d.ts +2 -2
- package/dist/cli/src/apps/catenv/writeDotEnvFiles.js +1 -1
- package/dist/cli/src/apps/catenv/writeDotEnvFiles.js.map +1 -1
- package/dist/cli/src/apps/cli/commands/project/commandConfigSecrets.js +70 -3
- package/dist/cli/src/apps/cli/commands/project/commandConfigSecrets.js.map +1 -1
- package/dist/cli/src/catenv.js +3 -2
- package/dist/cli/src/catenv.js.map +1 -1
- package/dist/cli/src/commands/index.d.ts +1 -0
- package/dist/cli/src/commands/index.js +8 -1
- package/dist/cli/src/commands/index.js.map +1 -1
- package/dist/cli/src/commands/project/commandSecretsSyncGithub.d.ts +46 -0
- package/dist/cli/src/commands/project/commandSecretsSyncGithub.js +150 -0
- package/dist/cli/src/commands/project/commandSecretsSyncGithub.js.map +1 -0
- package/dist/cli/src/config/getProjectConfig.d.ts +1 -1
- package/dist/cli/src/config/getProjectConfig.js +12 -2
- package/dist/cli/src/config/getProjectConfig.js.map +1 -1
- package/dist/cli/src/core/types.d.ts +15 -0
- package/dist/cli/src/core/types.js +8 -1
- package/dist/cli/src/core/types.js.map +1 -1
- package/dist/cli/src/git/gitProjectInformation.d.ts +1 -1
- package/dist/cli/src/git/gitProjectInformation.js +3 -3
- package/dist/cli/src/git/gitProjectInformation.js.map +1 -1
- package/dist/cli/src/utils/github.d.ts +20 -0
- package/dist/cli/src/utils/github.js +61 -0
- package/dist/cli/src/utils/github.js.map +1 -0
- package/dist/cli/src/vault/BitwardenVault.d.ts +24 -0
- package/dist/cli/src/vault/BitwardenVault.js +107 -0
- package/dist/cli/src/vault/BitwardenVault.js.map +1 -0
- package/dist/cli/src/vault/GitlabVault.d.ts +11 -0
- package/dist/cli/src/vault/GitlabVault.js +24 -0
- package/dist/cli/src/vault/GitlabVault.js.map +1 -0
- package/dist/cli/src/vault/VaultManager.d.ts +43 -0
- package/dist/cli/src/vault/VaultManager.js +131 -0
- package/dist/cli/src/vault/VaultManager.js.map +1 -0
- package/dist/cli/src/vault/bw.d.ts +15 -0
- package/dist/cli/src/vault/bw.js +89 -0
- package/dist/cli/src/vault/bw.js.map +1 -0
- package/dist/cli/src/vault/cache.d.ts +38 -0
- package/dist/cli/src/vault/cache.js +53 -0
- package/dist/cli/src/vault/cache.js.map +1 -0
- package/dist/cli/src/vault/index.d.ts +5 -0
- package/dist/cli/src/vault/index.js +48 -0
- package/dist/cli/src/vault/index.js.map +1 -0
- package/dist/cli/src/vault/types.d.ts +33 -0
- package/dist/cli/src/vault/types.js +5 -0
- package/dist/cli/src/vault/types.js.map +1 -0
- package/dist/pipeline/src/backends/github/GithubBackend.d.ts +4 -1
- package/dist/pipeline/src/backends/github/GithubBackend.js +70 -12
- package/dist/pipeline/src/backends/github/GithubBackend.js.map +1 -1
- package/dist/pipeline/src/backends/github/ciVariables.js +3 -1
- package/dist/pipeline/src/backends/github/ciVariables.js.map +1 -1
- package/dist/pipeline/src/backends/github/createGithubJobs.d.ts +13 -3
- package/dist/pipeline/src/backends/github/createGithubJobs.js +80 -14
- package/dist/pipeline/src/backends/github/createGithubJobs.js.map +1 -1
- package/dist/pipeline/src/backends/github/githubReleaseJobs.d.ts +2 -1
- package/dist/pipeline/src/backends/github/githubReleaseJobs.js +46 -33
- package/dist/pipeline/src/backends/github/githubReleaseJobs.js.map +1 -1
- package/dist/pipeline/src/backends/github/index.d.ts +2 -1
- package/dist/pipeline/src/backends/github/index.js +2 -1
- package/dist/pipeline/src/backends/github/index.js.map +1 -1
- package/dist/pipeline/src/backends/github/scriptFiles.d.ts +25 -0
- package/dist/pipeline/src/backends/github/scriptFiles.js +50 -0
- package/dist/pipeline/src/backends/github/scriptFiles.js.map +1 -0
- package/dist/pipeline/src/backends/gitlab/GitlabBackend.d.ts +1 -0
- package/dist/pipeline/src/backends/gitlab/GitlabBackend.js +18 -8
- package/dist/pipeline/src/backends/gitlab/GitlabBackend.js.map +1 -1
- package/dist/pipeline/src/backends/gitlab/ciVariables.js +2 -1
- package/dist/pipeline/src/backends/gitlab/ciVariables.js.map +1 -1
- package/dist/pipeline/src/backends/gitlab/createGitlabJobs.d.ts +3 -2
- package/dist/pipeline/src/backends/gitlab/createGitlabJobs.js +84 -10
- package/dist/pipeline/src/backends/gitlab/createGitlabJobs.js.map +1 -1
- package/dist/pipeline/src/backends/gitlab/createGitlabPipeline.js +1 -1
- package/dist/pipeline/src/backends/gitlab/createGitlabPipeline.js.map +1 -1
- package/dist/pipeline/src/backends/gitlab/gitlabReleaseJobs.js +2 -2
- package/dist/pipeline/src/backends/gitlab/gitlabReleaseJobs.js.map +1 -1
- package/dist/pipeline/src/backends/index.d.ts +17 -1
- package/dist/pipeline/src/backends/index.js +33 -1
- package/dist/pipeline/src/backends/index.js.map +1 -1
- package/dist/pipeline/src/backends/types.d.ts +2 -1
- package/dist/pipeline/src/bash/ciVariables.d.ts +1 -1
- package/dist/pipeline/src/bash/ciVariables.js.map +1 -1
- package/dist/pipeline/src/build/node/yarn.js +4 -1
- package/dist/pipeline/src/build/node/yarn.js.map +1 -1
- package/dist/pipeline/src/build/types.d.ts +1 -1
- package/dist/pipeline/src/constants.js +1 -1
- package/dist/pipeline/src/context/getEnvironmentVariables.d.ts +8 -0
- package/dist/pipeline/src/context/getEnvironmentVariables.js +3 -3
- package/dist/pipeline/src/context/getEnvironmentVariables.js.map +1 -1
- package/dist/pipeline/src/context/transformJobOnlyVars.js +2 -2
- package/dist/pipeline/src/context/transformJobOnlyVars.js.map +1 -1
- package/dist/pipeline/src/context/utils/envVars.d.ts +7 -0
- package/dist/pipeline/src/context/utils/envVars.js +22 -1
- package/dist/pipeline/src/context/utils/envVars.js.map +1 -1
- package/dist/pipeline/src/customImages/hash.d.ts +15 -0
- package/dist/pipeline/src/customImages/hash.js +71 -0
- package/dist/pipeline/src/customImages/hash.js.map +1 -0
- package/dist/pipeline/src/customImages/jobImagesPlan.d.ts +59 -0
- package/dist/pipeline/src/customImages/jobImagesPlan.js +172 -0
- package/dist/pipeline/src/customImages/jobImagesPlan.js.map +1 -0
- package/dist/pipeline/src/customImages/shippedImages.d.ts +28 -0
- package/dist/pipeline/src/customImages/shippedImages.js +60 -0
- package/dist/pipeline/src/customImages/shippedImages.js.map +1 -0
- package/dist/pipeline/src/deploy/cloudRun/index.js +15 -5
- package/dist/pipeline/src/deploy/cloudRun/index.js.map +1 -1
- package/dist/pipeline/src/deploy/cloudRun/utils/database.js +4 -1
- package/dist/pipeline/src/deploy/cloudRun/utils/database.js.map +1 -1
- package/dist/pipeline/src/pipeline/generatePipelineFiles.js +16 -1
- package/dist/pipeline/src/pipeline/generatePipelineFiles.js.map +1 -1
- package/dist/pipeline/src/runner/index.d.ts +20 -1
- package/dist/pipeline/src/runner/index.js +16 -3
- package/dist/pipeline/src/runner/index.js.map +1 -1
- package/dist/pipeline/src/types/config.d.ts +63 -5
- package/dist/pipeline/src/types/github-types.d.ts +5 -0
- package/dist/pipeline/src/types/jobs.d.ts +10 -2
- package/dist/pipeline/src/types/jobs.js.map +1 -1
- package/dist/pipeline/src/utils/gitlab.d.ts +5 -0
- package/dist/pipeline/src/utils/gitlab.js +6 -0
- package/dist/pipeline/src/utils/gitlab.js.map +1 -1
- package/dist/pipeline/src/utils/writeFiles.d.ts +6 -0
- package/dist/pipeline/src/utils/writeFiles.js +8 -0
- package/dist/pipeline/src/utils/writeFiles.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/adapters/baseContext.ts +10 -0
- package/src/adapters/nonInteractive.ts +30 -0
- package/src/adapters/programmatic.ts +7 -1
- package/src/adapters/terminal.ts +9 -1
- package/src/adapters/vaultManagerAccess.ts +25 -0
- package/src/apps/catenv/catenv.ts +5 -1
- package/src/apps/catenv/printVariables.ts +16 -6
- package/src/apps/catenv/types.ts +9 -0
- package/src/apps/catenv/writeDotEnvFiles.ts +3 -3
- package/src/apps/cli/commands/project/commandConfigSecrets.ts +114 -9
- package/src/catenv.ts +22 -2
- package/src/commands/index.ts +1 -0
- package/src/commands/project/commandSecretsSyncGithub.ts +172 -0
- package/src/config/getProjectConfig.ts +21 -7
- package/src/core/types.ts +23 -0
- package/src/git/gitProjectInformation.ts +5 -3
- package/src/utils/github.ts +76 -0
- package/src/vault/BitwardenVault.ts +161 -0
- package/src/vault/GitlabVault.ts +25 -0
- package/src/vault/VaultManager.ts +211 -0
- package/src/vault/bw.ts +110 -0
- package/src/vault/cache.ts +80 -0
- package/src/vault/index.ts +5 -0
- package/src/vault/types.ts +42 -0
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from "@catladder/pipeline";
|
|
13
13
|
|
|
14
14
|
import type { IO } from "../core/types";
|
|
15
|
-
import {
|
|
15
|
+
import { getVariableValueByRawName } from "../utils/gitlab";
|
|
16
16
|
|
|
17
17
|
import { getGitRoot } from "../utils/projects";
|
|
18
18
|
|
|
@@ -121,10 +121,24 @@ export const getGitlabVar = async (
|
|
|
121
121
|
};
|
|
122
122
|
|
|
123
123
|
const resolveSecrets = async (
|
|
124
|
-
io: IO
|
|
124
|
+
io: IO,
|
|
125
125
|
varSets: EnvironmentEnvVars[],
|
|
126
126
|
): Promise<Record<string, string>> => {
|
|
127
|
-
|
|
127
|
+
// the secret variables actually referenced by the env var values —
|
|
128
|
+
// when they are all in the local cache, the vault is not contacted
|
|
129
|
+
const referencedNames = [
|
|
130
|
+
...new Set(
|
|
131
|
+
varSets.flatMap(({ envVars }) =>
|
|
132
|
+
Object.values(envVars).flatMap(
|
|
133
|
+
(value) => `${value}`.match(/\$CL_\w+/g) ?? [],
|
|
134
|
+
),
|
|
135
|
+
),
|
|
136
|
+
),
|
|
137
|
+
].map((reference) => reference.slice(1));
|
|
138
|
+
|
|
139
|
+
const vaultManager = await io.getVaultManager();
|
|
140
|
+
const secrets = await vaultManager.readSecrets(referencedNames, io);
|
|
141
|
+
const secretEntries = Object.entries(secrets);
|
|
128
142
|
|
|
129
143
|
return Object.fromEntries(
|
|
130
144
|
varSets.flatMap(({ envVars, secretEnvVarKeys }) =>
|
|
@@ -137,9 +151,9 @@ const resolveSecrets = async (
|
|
|
137
151
|
)
|
|
138
152
|
.map(([key, value]) => [
|
|
139
153
|
key,
|
|
140
|
-
|
|
141
|
-
(acc,
|
|
142
|
-
acc.replace(new RegExp("\\$" +
|
|
154
|
+
secretEntries.reduce(
|
|
155
|
+
(acc, [name, secretValue]) =>
|
|
156
|
+
acc.replace(new RegExp("\\$" + name, "g"), secretValue),
|
|
143
157
|
`${value}`,
|
|
144
158
|
),
|
|
145
159
|
]),
|
|
@@ -148,7 +162,7 @@ const resolveSecrets = async (
|
|
|
148
162
|
};
|
|
149
163
|
|
|
150
164
|
export const getEnvVarsResolved = async (
|
|
151
|
-
io: IO
|
|
165
|
+
io: IO,
|
|
152
166
|
env: string,
|
|
153
167
|
componentName: string | null,
|
|
154
168
|
) => {
|
package/src/core/types.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { VaultManager } from "../vault/VaultManager";
|
|
1
2
|
import type { ComponentContext } from "@catladder/pipeline";
|
|
2
3
|
|
|
3
4
|
// ─── Input Definitions ───────────────────────────────────────────────
|
|
@@ -76,6 +77,19 @@ export type InputsSchema = Record<string, InputDef>;
|
|
|
76
77
|
* that are NOT part of a command's declared input schema.
|
|
77
78
|
*/
|
|
78
79
|
export interface IO {
|
|
80
|
+
/**
|
|
81
|
+
* whether this context can interact with the user (prompts, editors).
|
|
82
|
+
* Non-interactive contexts throw {@link NonInteractiveError} when an
|
|
83
|
+
* interaction would be required.
|
|
84
|
+
*/
|
|
85
|
+
readonly interactive: boolean;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* the secrets vault of this context, created lazily from the project
|
|
89
|
+
* config
|
|
90
|
+
*/
|
|
91
|
+
getVaultManager(): Promise<VaultManager>;
|
|
92
|
+
|
|
79
93
|
log(message: string): void;
|
|
80
94
|
/**
|
|
81
95
|
* Ask for confirmation. Returns true/false.
|
|
@@ -131,6 +145,15 @@ export interface CommandDef<TInputs extends InputsSchema = InputsSchema> {
|
|
|
131
145
|
|
|
132
146
|
// ─── Errors ──────────────────────────────────────────────────────────
|
|
133
147
|
|
|
148
|
+
export class NonInteractiveError extends Error {
|
|
149
|
+
constructor(interaction: string) {
|
|
150
|
+
super(
|
|
151
|
+
`${interaction} requires an interactive terminal, but this context is not interactive`,
|
|
152
|
+
);
|
|
153
|
+
this.name = "NonInteractiveError";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
134
157
|
export class MissingInputError extends Error {
|
|
135
158
|
constructor(
|
|
136
159
|
public readonly inputName: string,
|
|
@@ -12,17 +12,19 @@ export const getProjectRootPath = async (): Promise<string> => {
|
|
|
12
12
|
/**
|
|
13
13
|
* Get the git remote hostname and project path from the git config.
|
|
14
14
|
*/
|
|
15
|
-
export const getGitRemoteHostAndPath = async (
|
|
15
|
+
export const getGitRemoteHostAndPath = async (
|
|
16
|
+
remoteName = "origin",
|
|
17
|
+
): Promise<{
|
|
16
18
|
gitRemoteHost: string;
|
|
17
19
|
gitRemotePath: string;
|
|
18
20
|
}> => {
|
|
19
|
-
const remoteUrl = await gitConfigGet(
|
|
21
|
+
const remoteUrl = await gitConfigGet(`remote.${remoteName}.url`);
|
|
20
22
|
const remoteReg = /(https:\/\/|git@)([^:/]+)[:/]([^.]*)(\.git)?/;
|
|
21
23
|
const match = remoteUrl.match(remoteReg) ?? [];
|
|
22
24
|
const [, , gitRemoteHost, gitRemotePath] = match;
|
|
23
25
|
if (!gitRemoteHost?.length || !gitRemotePath?.length) {
|
|
24
26
|
throw new Error(
|
|
25
|
-
`Failed to parse git remote hostname and path from git configs remote.
|
|
27
|
+
`Failed to parse git remote hostname and path from git configs remote.${remoteName}.url! ${remoteUrl}`,
|
|
26
28
|
);
|
|
27
29
|
}
|
|
28
30
|
return { gitRemoteHost, gitRemotePath };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { execFile as execFileCb, spawn } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
|
|
4
|
+
const execFile = promisify(execFileCb);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* whether the github cli (gh) is installed and authenticated
|
|
8
|
+
*/
|
|
9
|
+
export const isGhAuthenticated = async (): Promise<boolean> => {
|
|
10
|
+
try {
|
|
11
|
+
await execFile("gh", ["auth", "status"]);
|
|
12
|
+
return true;
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* the github repository (owner/name) a git remote points to.
|
|
20
|
+
* Which remote belongs to github is configured per pipeline type
|
|
21
|
+
* (`pipelines.github.gitRemote`, defaults to origin).
|
|
22
|
+
*/
|
|
23
|
+
export const getGithubRepoFromRemote = async (
|
|
24
|
+
remoteName: string,
|
|
25
|
+
): Promise<string | undefined> => {
|
|
26
|
+
try {
|
|
27
|
+
const { stdout } = await execFile("git", ["remote", "get-url", remoteName]);
|
|
28
|
+
const match = stdout.match(
|
|
29
|
+
/github\.com[:/]([\w.-]+\/[\w.-]+?)(\.git)?\s*$/,
|
|
30
|
+
);
|
|
31
|
+
return match?.[1];
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const ghSetViaStdin = (
|
|
38
|
+
kind: "secret" | "variable",
|
|
39
|
+
repo: string,
|
|
40
|
+
name: string,
|
|
41
|
+
value: string,
|
|
42
|
+
): Promise<void> =>
|
|
43
|
+
new Promise<void>((resolve, reject) => {
|
|
44
|
+
const child = spawn("gh", [kind, "set", name, "--repo", repo], {
|
|
45
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
46
|
+
});
|
|
47
|
+
let stderr = "";
|
|
48
|
+
child.stderr?.on("data", (data) => (stderr += data));
|
|
49
|
+
child.on("error", reject);
|
|
50
|
+
child.on("close", (code) =>
|
|
51
|
+
code === 0
|
|
52
|
+
? resolve()
|
|
53
|
+
: reject(new Error(`gh ${kind} set ${name} failed: ${stderr.trim()}`)),
|
|
54
|
+
);
|
|
55
|
+
child.stdin?.end(value);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* sets a github actions repository secret via the gh cli.
|
|
60
|
+
* The value is passed via stdin so it never shows up in a process list.
|
|
61
|
+
*/
|
|
62
|
+
export const setGithubSecret = async (
|
|
63
|
+
repo: string,
|
|
64
|
+
name: string,
|
|
65
|
+
value: string,
|
|
66
|
+
): Promise<void> => ghSetViaStdin("secret", repo, name, value);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* sets a github actions repository variable (plain, unmasked) via the
|
|
70
|
+
* gh cli — for vault values classified as not sensitive
|
|
71
|
+
*/
|
|
72
|
+
export const setGithubVariable = async (
|
|
73
|
+
repo: string,
|
|
74
|
+
name: string,
|
|
75
|
+
value: string,
|
|
76
|
+
): Promise<void> => ghSetViaStdin("variable", repo, name, value);
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { getSecretVarName } from "@catladder/pipeline";
|
|
2
|
+
import type { Config } from "@catladder/pipeline";
|
|
3
|
+
import { parse, stringify } from "yaml";
|
|
4
|
+
import type { IO } from "../core/types";
|
|
5
|
+
import { getBwSession, runBw, runBwJson } from "./bw";
|
|
6
|
+
import type { SecretsVault } from "./types";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_COLLECTION = "catladder";
|
|
9
|
+
|
|
10
|
+
const SECURE_NOTE_TYPE = 2;
|
|
11
|
+
|
|
12
|
+
type BwCollection = { id: string; name: string; organizationId?: string };
|
|
13
|
+
type BwItem = {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
notes?: string | null;
|
|
17
|
+
organizationId?: string | null;
|
|
18
|
+
collectionIds?: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* one yaml note per env and component:
|
|
23
|
+
* `<customerName>/<appName>/<env>/<componentName>/secrets.yml`
|
|
24
|
+
*/
|
|
25
|
+
const itemName = (config: Config, env: string, componentName: string): string =>
|
|
26
|
+
`${config.customerName}/${config.appName}/${env}/${componentName}/secrets.yml`;
|
|
27
|
+
|
|
28
|
+
const itemPrefix = (config: Config) =>
|
|
29
|
+
`${config.customerName}/${config.appName}/`;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* parses env and componentName back out of an item name
|
|
33
|
+
*/
|
|
34
|
+
const parseItemName = (config: Config, name: string) => {
|
|
35
|
+
const match = name
|
|
36
|
+
.slice(itemPrefix(config).length)
|
|
37
|
+
.match(/^([^/]+)\/([^/]+)\/secrets\.yml$/);
|
|
38
|
+
return match ? { env: match[1], componentName: match[2] } : null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const findCollection = async (
|
|
42
|
+
session: string,
|
|
43
|
+
collectionName: string,
|
|
44
|
+
): Promise<BwCollection> => {
|
|
45
|
+
const collections = await runBwJson<BwCollection[]>(session, [
|
|
46
|
+
"list",
|
|
47
|
+
"collections",
|
|
48
|
+
"--search",
|
|
49
|
+
collectionName,
|
|
50
|
+
]);
|
|
51
|
+
const collection = collections.find((c) => c.name === collectionName);
|
|
52
|
+
if (!collection) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`bitwarden collection '${collectionName}' not found — create it and make sure you have access`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return collection;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const encodePayload = (payload: unknown) =>
|
|
61
|
+
Buffer.from(JSON.stringify(payload), "utf-8").toString("base64");
|
|
62
|
+
|
|
63
|
+
export class BitwardenVault implements SecretsVault {
|
|
64
|
+
readonly id: string;
|
|
65
|
+
|
|
66
|
+
private readonly collectionName: string;
|
|
67
|
+
|
|
68
|
+
constructor(
|
|
69
|
+
private readonly config: Config,
|
|
70
|
+
options: { collection?: string },
|
|
71
|
+
/**
|
|
72
|
+
* whether a locked bitwarden may be unlocked interactively
|
|
73
|
+
* (false with catenv --vault-mode no-prompt)
|
|
74
|
+
*/
|
|
75
|
+
private readonly allowPrompt: boolean,
|
|
76
|
+
) {
|
|
77
|
+
this.collectionName = options.collection ?? DEFAULT_COLLECTION;
|
|
78
|
+
this.id = `bitwarden:${this.collectionName}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private session(io: IO | null) {
|
|
82
|
+
return getBwSession(io, { allowPrompt: this.allowPrompt });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async readAllSecrets(io: IO | null): Promise<Record<string, string>> {
|
|
86
|
+
const { config, collectionName } = this;
|
|
87
|
+
const bwSession = await this.session(io);
|
|
88
|
+
const collection = await findCollection(bwSession, collectionName);
|
|
89
|
+
const items = await runBwJson<BwItem[]>(bwSession, [
|
|
90
|
+
"list",
|
|
91
|
+
"items",
|
|
92
|
+
"--collectionid",
|
|
93
|
+
collection.id,
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
const secrets: Record<string, string> = {};
|
|
97
|
+
for (const item of items) {
|
|
98
|
+
const parsed = parseItemName(config, item.name);
|
|
99
|
+
if (!parsed || !item.notes) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const values = parse(item.notes) as Record<string, unknown> | null;
|
|
103
|
+
Object.entries(values ?? {}).forEach(([key, value]) => {
|
|
104
|
+
secrets[getSecretVarName(parsed.env, parsed.componentName, key)] =
|
|
105
|
+
typeof value === "string" ? value : JSON.stringify(value);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return secrets;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async writeSecrets(
|
|
112
|
+
io: IO,
|
|
113
|
+
env: string,
|
|
114
|
+
componentName: string,
|
|
115
|
+
secrets: Record<string, unknown>,
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
const { config, collectionName } = this;
|
|
118
|
+
const bwSession = await this.session(io);
|
|
119
|
+
const collection = await findCollection(bwSession, collectionName);
|
|
120
|
+
const name = itemName(config, env, componentName);
|
|
121
|
+
|
|
122
|
+
const existing = (
|
|
123
|
+
await runBwJson<BwItem[]>(bwSession, [
|
|
124
|
+
"list",
|
|
125
|
+
"items",
|
|
126
|
+
"--collectionid",
|
|
127
|
+
collection.id,
|
|
128
|
+
"--search",
|
|
129
|
+
name,
|
|
130
|
+
])
|
|
131
|
+
).find((item) => item.name === name);
|
|
132
|
+
|
|
133
|
+
const notes = stringify(secrets);
|
|
134
|
+
|
|
135
|
+
if (existing) {
|
|
136
|
+
await runBw(bwSession, [
|
|
137
|
+
"edit",
|
|
138
|
+
"item",
|
|
139
|
+
existing.id,
|
|
140
|
+
encodePayload({ ...existing, notes }),
|
|
141
|
+
]);
|
|
142
|
+
} else {
|
|
143
|
+
await runBw(bwSession, [
|
|
144
|
+
"create",
|
|
145
|
+
"item",
|
|
146
|
+
encodePayload({
|
|
147
|
+
type: SECURE_NOTE_TYPE,
|
|
148
|
+
secureNote: { type: 0 },
|
|
149
|
+
name,
|
|
150
|
+
notes,
|
|
151
|
+
...(collection.organizationId
|
|
152
|
+
? {
|
|
153
|
+
organizationId: collection.organizationId,
|
|
154
|
+
collectionIds: [collection.id],
|
|
155
|
+
}
|
|
156
|
+
: {}),
|
|
157
|
+
}),
|
|
158
|
+
]);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { IO } from "../core/types";
|
|
2
|
+
import { getAllVariables, upsertAllVariables } from "../utils/gitlab";
|
|
3
|
+
import type { SecretsVault } from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* the legacy (and default) vault: the gitlab project variables double
|
|
7
|
+
* as the secret store
|
|
8
|
+
*/
|
|
9
|
+
export class GitlabVault implements SecretsVault {
|
|
10
|
+
readonly id = "gitlab";
|
|
11
|
+
|
|
12
|
+
async readAllSecrets(io: IO | null): Promise<Record<string, string>> {
|
|
13
|
+
const variables = await getAllVariables(io);
|
|
14
|
+
return Object.fromEntries(variables.map((v) => [v.key, v.value]));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async writeSecrets(
|
|
18
|
+
io: IO,
|
|
19
|
+
env: string,
|
|
20
|
+
componentName: string,
|
|
21
|
+
secrets: Record<string, unknown>,
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
await upsertAllVariables(io, secrets, env, componentName);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { Config } from "@catladder/pipeline";
|
|
2
|
+
import {
|
|
3
|
+
getEnvironment,
|
|
4
|
+
getSecretVarName,
|
|
5
|
+
getVaultConfig,
|
|
6
|
+
} from "@catladder/pipeline";
|
|
7
|
+
import type { IO } from "../core/types";
|
|
8
|
+
import { BitwardenVault } from "./BitwardenVault";
|
|
9
|
+
import type { CachedSecrets } from "./cache";
|
|
10
|
+
import {
|
|
11
|
+
readSecretsCache,
|
|
12
|
+
updateSecretsCache,
|
|
13
|
+
writeSecretsCache,
|
|
14
|
+
} from "./cache";
|
|
15
|
+
import { GitlabVault } from "./GitlabVault";
|
|
16
|
+
import type { SecretsMode, SecretsVault } from "./types";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* only secrets of this env are cached locally — the other envs'
|
|
20
|
+
* secrets have no business sitting on developer machines
|
|
21
|
+
*/
|
|
22
|
+
const CACHED_ENV = "local";
|
|
23
|
+
|
|
24
|
+
export type VaultManagerOptions = {
|
|
25
|
+
mode?: SecretsMode;
|
|
26
|
+
/**
|
|
27
|
+
* default IO for vault interactions (logging, unlock prompts).
|
|
28
|
+
* Method-level io takes precedence.
|
|
29
|
+
*/
|
|
30
|
+
io?: IO | null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* owns the configured vault, the secrets mode and the local cache.
|
|
35
|
+
* The vault is the readable source of truth for secrets; CI backends
|
|
36
|
+
* only receive mirrored copies.
|
|
37
|
+
*/
|
|
38
|
+
export class VaultManager {
|
|
39
|
+
readonly mode: SecretsMode;
|
|
40
|
+
|
|
41
|
+
readonly vault: SecretsVault;
|
|
42
|
+
|
|
43
|
+
private readonly io: IO | null;
|
|
44
|
+
|
|
45
|
+
/** refresh mode bypasses the cache only once per manager */
|
|
46
|
+
private refreshed = false;
|
|
47
|
+
|
|
48
|
+
constructor(
|
|
49
|
+
private readonly config: Config,
|
|
50
|
+
options: VaultManagerOptions = {},
|
|
51
|
+
) {
|
|
52
|
+
this.mode = options.mode ?? "auto";
|
|
53
|
+
this.io = options.io ?? null;
|
|
54
|
+
this.vault = this.createVault();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private createVault(): SecretsVault {
|
|
58
|
+
const vaultConfig = getVaultConfig(this.config);
|
|
59
|
+
switch (vaultConfig.type) {
|
|
60
|
+
case "gitlab":
|
|
61
|
+
return new GitlabVault();
|
|
62
|
+
case "bitwarden":
|
|
63
|
+
return new BitwardenVault(
|
|
64
|
+
this.config,
|
|
65
|
+
vaultConfig,
|
|
66
|
+
this.mode === "auto" || this.mode === "refresh",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* the vault secrets, served from the local cache when it contains
|
|
73
|
+
* all required names; otherwise (depending on the mode) refreshed
|
|
74
|
+
* from the vault. Only the "local" env is (re-)cached.
|
|
75
|
+
*/
|
|
76
|
+
async readSecrets(
|
|
77
|
+
requiredNames: string[],
|
|
78
|
+
io: IO | null = this.io,
|
|
79
|
+
): Promise<Record<string, string>> {
|
|
80
|
+
const bypassCache = this.mode === "refresh" && !this.refreshed;
|
|
81
|
+
const cache = bypassCache ? null : await readSecretsCache();
|
|
82
|
+
// known entries include recorded misses (null values), so declared
|
|
83
|
+
// but intentionally unset secrets don't force a vault refresh
|
|
84
|
+
const known =
|
|
85
|
+
cache?.vaultId === this.vault.id
|
|
86
|
+
? flattenSecrets(cache.secrets)
|
|
87
|
+
: undefined;
|
|
88
|
+
|
|
89
|
+
const missing = requiredNames.filter((name) => !(name in (known ?? {})));
|
|
90
|
+
// nothing missing (or nothing required at all): don't touch the vault
|
|
91
|
+
if (missing.length === 0 && (known || requiredNames.length === 0)) {
|
|
92
|
+
return withoutMisses(known ?? {});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (this.mode === "offline") {
|
|
96
|
+
if (missing.length > 0) {
|
|
97
|
+
io?.log(
|
|
98
|
+
`⚠️ vault is offline (--vault-mode offline) and ${missing.length} secrets are not in the local cache: ${missing.join(", ")}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return withoutMisses(known ?? {});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const secrets = await this.vault.readAllSecrets(io);
|
|
105
|
+
this.refreshed = true;
|
|
106
|
+
await writeSecretsCache(this.vault.id, await this.cacheableSubset(secrets));
|
|
107
|
+
return secrets;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* writes secrets of one env/component to the vault and updates the
|
|
112
|
+
* local cache (write-through, "local" env only)
|
|
113
|
+
*/
|
|
114
|
+
async writeSecrets(
|
|
115
|
+
env: string,
|
|
116
|
+
componentName: string,
|
|
117
|
+
secrets: Record<string, unknown>,
|
|
118
|
+
io: IO | null = this.io,
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
if (!io) {
|
|
121
|
+
throw new Error("writing to the vault requires an interactive context");
|
|
122
|
+
}
|
|
123
|
+
await this.vault.writeSecrets(io, env, componentName, secrets);
|
|
124
|
+
if (env === CACHED_ENV) {
|
|
125
|
+
await updateSecretsCache(
|
|
126
|
+
this.vault.id,
|
|
127
|
+
env,
|
|
128
|
+
componentName,
|
|
129
|
+
Object.fromEntries(
|
|
130
|
+
Object.entries(secrets).map(([key, value]) => [
|
|
131
|
+
key,
|
|
132
|
+
typeof value === "string" ? value : JSON.stringify(value),
|
|
133
|
+
]),
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* the subset of the vault secrets that may be cached: the declared
|
|
141
|
+
* secrets of the "local" env, per component, as a hierarchy
|
|
142
|
+
*/
|
|
143
|
+
private async cacheableSubset(
|
|
144
|
+
flatSecrets: Record<string, string>,
|
|
145
|
+
): Promise<CachedSecrets> {
|
|
146
|
+
const local: CachedSecrets[string] = {};
|
|
147
|
+
for (const componentName of Object.keys(this.config.components)) {
|
|
148
|
+
const keys = await this.declaredSecretKeys(componentName);
|
|
149
|
+
const values = Object.fromEntries(
|
|
150
|
+
keys.map((key): [string, string | null] => [
|
|
151
|
+
key,
|
|
152
|
+
// null records "absent in the vault" (a known miss)
|
|
153
|
+
flatSecrets[getSecretVarName(CACHED_ENV, componentName, key)] ?? null,
|
|
154
|
+
]),
|
|
155
|
+
);
|
|
156
|
+
if (Object.keys(values).length > 0) {
|
|
157
|
+
local[componentName] = values;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return Object.keys(local).length > 0 ? { [CACHED_ENV]: local } : {};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async declaredSecretKeys(componentName: string): Promise<string[]> {
|
|
164
|
+
try {
|
|
165
|
+
const { secretEnvVarKeys, jobOnlyVars } = await getEnvironment({
|
|
166
|
+
config: this.config,
|
|
167
|
+
componentName,
|
|
168
|
+
env: CACHED_ENV,
|
|
169
|
+
});
|
|
170
|
+
return [
|
|
171
|
+
...secretEnvVarKeys,
|
|
172
|
+
...jobOnlyVars.build.secretEnvVarKeys,
|
|
173
|
+
...jobOnlyVars.deploy.secretEnvVarKeys,
|
|
174
|
+
].map(({ key }) => key);
|
|
175
|
+
} catch {
|
|
176
|
+
// components without a local env simply have nothing to cache
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* the cache hierarchy as a flat map of raw secret variable names
|
|
184
|
+
* (null values = recorded misses)
|
|
185
|
+
*/
|
|
186
|
+
const flattenSecrets = (
|
|
187
|
+
secrets: CachedSecrets,
|
|
188
|
+
): Record<string, string | null> =>
|
|
189
|
+
Object.fromEntries(
|
|
190
|
+
Object.entries(secrets).flatMap(([env, components]) =>
|
|
191
|
+
Object.entries(components).flatMap(([componentName, values]) =>
|
|
192
|
+
Object.entries(values).map(([key, value]) => [
|
|
193
|
+
getSecretVarName(env, componentName, key),
|
|
194
|
+
value,
|
|
195
|
+
]),
|
|
196
|
+
),
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* only actual values — recorded misses stay unresolved, like any other
|
|
202
|
+
* unset variable
|
|
203
|
+
*/
|
|
204
|
+
const withoutMisses = (
|
|
205
|
+
known: Record<string, string | null>,
|
|
206
|
+
): Record<string, string> =>
|
|
207
|
+
Object.fromEntries(
|
|
208
|
+
Object.entries(known).filter(
|
|
209
|
+
(entry): entry is [string, string] => entry[1] !== null,
|
|
210
|
+
),
|
|
211
|
+
);
|