@catladder/cli 0.0.0-next-4e7c6848 → 0.0.0-next-8b9ad5ca
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 +617 -19
- package/dist/bundles/cli/index.js +811 -18
- 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 +52 -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 +37 -0
- package/dist/cli/src/commands/project/commandSecretsSyncGithub.js +127 -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 +15 -0
- package/dist/cli/src/utils/github.js +54 -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/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/constants.js +1 -1
- package/dist/pipeline/src/deploy/cloudRun/index.js +11 -4
- package/dist/pipeline/src/deploy/cloudRun/index.js.map +1 -1
- package/dist/pipeline/src/types/config.d.ts +37 -3
- 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 +94 -9
- package/src/catenv.ts +22 -2
- package/src/commands/index.ts +1 -0
- package/src/commands/project/commandSecretsSyncGithub.ts +149 -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 +59 -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
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,59 @@
|
|
|
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
|
+
/**
|
|
38
|
+
* sets a github actions repository secret via the gh cli.
|
|
39
|
+
* The value is passed via stdin so it never shows up in a process list.
|
|
40
|
+
*/
|
|
41
|
+
export const setGithubSecret = async (
|
|
42
|
+
repo: string,
|
|
43
|
+
name: string,
|
|
44
|
+
value: string,
|
|
45
|
+
): Promise<void> =>
|
|
46
|
+
new Promise<void>((resolve, reject) => {
|
|
47
|
+
const child = spawn("gh", ["secret", "set", name, "--repo", repo], {
|
|
48
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
49
|
+
});
|
|
50
|
+
let stderr = "";
|
|
51
|
+
child.stderr?.on("data", (data) => (stderr += data));
|
|
52
|
+
child.on("error", reject);
|
|
53
|
+
child.on("close", (code) =>
|
|
54
|
+
code === 0
|
|
55
|
+
? resolve()
|
|
56
|
+
: reject(new Error(`gh secret set ${name} failed: ${stderr.trim()}`)),
|
|
57
|
+
);
|
|
58
|
+
child.stdin?.end(value);
|
|
59
|
+
});
|
|
@@ -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
|
+
);
|
package/src/vault/bw.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { execFile as execFileCb, spawn } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import type { IO } from "../core/types";
|
|
4
|
+
import {
|
|
5
|
+
getPreference,
|
|
6
|
+
hasPreference,
|
|
7
|
+
setPreference,
|
|
8
|
+
} from "../utils/preferences";
|
|
9
|
+
|
|
10
|
+
const execFile = promisify(execFileCb);
|
|
11
|
+
|
|
12
|
+
const SESSION_PREFERENCE = "bwsession";
|
|
13
|
+
|
|
14
|
+
class BitwardenLockedError extends Error {
|
|
15
|
+
constructor() {
|
|
16
|
+
super(
|
|
17
|
+
"bitwarden is locked and prompting is disabled — run `bw unlock` or catenv without --vault-mode no-prompt",
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* runs an interactive bw command that prints a session key on stdout
|
|
24
|
+
* (login/unlock) while the user types the password on the terminal
|
|
25
|
+
*/
|
|
26
|
+
const interactiveSessionCommand = (args: string[]): Promise<string> =>
|
|
27
|
+
new Promise((resolve, reject) => {
|
|
28
|
+
const child = spawn("bw", [...args, "--raw"], {
|
|
29
|
+
stdio: ["inherit", "pipe", "inherit"],
|
|
30
|
+
});
|
|
31
|
+
let session = "";
|
|
32
|
+
child.stdout?.on("data", (data) => (session += data.toString("utf-8")));
|
|
33
|
+
child.on("error", reject);
|
|
34
|
+
child.on("close", (code) =>
|
|
35
|
+
code === 0 && session.trim()
|
|
36
|
+
? resolve(session.trim())
|
|
37
|
+
: reject(new Error(`bw ${args[0]} failed`)),
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const getBwStatus = async (
|
|
42
|
+
session: string | null,
|
|
43
|
+
): Promise<"unlocked" | "locked" | "unauthenticated"> => {
|
|
44
|
+
try {
|
|
45
|
+
const { stdout } = await execFile("bw", ["status"], {
|
|
46
|
+
env: { ...process.env, ...(session ? { BW_SESSION: session } : {}) },
|
|
47
|
+
});
|
|
48
|
+
return JSON.parse(stdout).status;
|
|
49
|
+
} catch {
|
|
50
|
+
return "unauthenticated";
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* a working bw session: reuses the cached one, otherwise unlocks (or
|
|
56
|
+
* logs in) interactively — unless prompting is disabled
|
|
57
|
+
*/
|
|
58
|
+
export const getBwSession = async (
|
|
59
|
+
io: IO | null,
|
|
60
|
+
{ allowPrompt }: { allowPrompt: boolean },
|
|
61
|
+
): Promise<string> => {
|
|
62
|
+
const cached = (await hasPreference(SESSION_PREFERENCE))
|
|
63
|
+
? String(await getPreference(SESSION_PREFERENCE))
|
|
64
|
+
: null;
|
|
65
|
+
|
|
66
|
+
const status = await getBwStatus(cached);
|
|
67
|
+
if (status === "unlocked" && cached) {
|
|
68
|
+
return cached;
|
|
69
|
+
}
|
|
70
|
+
if (!allowPrompt) {
|
|
71
|
+
throw new BitwardenLockedError();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
io?.log("");
|
|
75
|
+
io?.log(
|
|
76
|
+
status === "unauthenticated"
|
|
77
|
+
? "# Please login to Bitwarden:"
|
|
78
|
+
: "# Bitwarden is locked, please unlock:",
|
|
79
|
+
);
|
|
80
|
+
io?.log("");
|
|
81
|
+
const session = await interactiveSessionCommand([
|
|
82
|
+
status === "unauthenticated" ? "login" : "unlock",
|
|
83
|
+
]);
|
|
84
|
+
await setPreference(SESSION_PREFERENCE, session);
|
|
85
|
+
await runBw(session, ["sync"]);
|
|
86
|
+
return session;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* runs a bw command with the given session and returns stdout
|
|
91
|
+
*/
|
|
92
|
+
export const runBw = async (
|
|
93
|
+
session: string,
|
|
94
|
+
args: string[],
|
|
95
|
+
): Promise<string> => {
|
|
96
|
+
const { stdout } = await execFile(
|
|
97
|
+
"bw",
|
|
98
|
+
[...args, "--raw", "--nointeraction"],
|
|
99
|
+
{
|
|
100
|
+
env: { ...process.env, BW_SESSION: session },
|
|
101
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
return stdout;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const runBwJson = async <T>(
|
|
108
|
+
session: string,
|
|
109
|
+
args: string[],
|
|
110
|
+
): Promise<T> => JSON.parse(await runBw(session, args));
|