hammerkit 1.21.2 → 1.23.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/build.schema.json +82 -2
- package/dist/cli.js +19 -0
- package/dist/executer/environment-mock.js +4 -1
- package/dist/index.js +4 -1
- package/dist/planner/service-definition.js +3 -1
- package/dist/planner/utils/append-work-service.js +1 -1
- package/dist/planner/utils/append-work-task.js +1 -1
- package/dist/planner/work-item-status.js +3 -2
- package/dist/planner/work-secret.js +87 -10
- package/dist/schema/build-file-base-task-schema.js +2 -0
- package/dist/schema/build-file-container-service-schema.js +2 -0
- package/dist/schema/build-file-schema.js +3 -0
- package/dist/schema/build-file-secret-provider-schema.js +36 -0
- package/dist/schema/build-file-secret-schema.js +6 -3
- package/dist/schema/reference-parser.js +3 -0
- package/dist/secrets/command-secret-provider.js +108 -0
- package/dist/secrets/prefetch-secrets.js +27 -0
- package/dist/secrets/provider-values.js +47 -0
- package/dist/secrets/secret-catalog.js +78 -0
- package/dist/secrets/secret-provider.js +19 -0
- package/dist/utils/redact.js +75 -19
- package/package.json +1 -1
package/build.schema.json
CHANGED
|
@@ -204,7 +204,10 @@
|
|
|
204
204
|
"properties": {
|
|
205
205
|
"from": {
|
|
206
206
|
"type": "string",
|
|
207
|
-
"pattern": "^
|
|
207
|
+
"pattern": "^[A-Za-z][A-Za-z0-9_-]*:.+$"
|
|
208
|
+
},
|
|
209
|
+
"account": {
|
|
210
|
+
"type": "string"
|
|
208
211
|
},
|
|
209
212
|
"env": {
|
|
210
213
|
"type": "string",
|
|
@@ -221,9 +224,12 @@
|
|
|
221
224
|
"from"
|
|
222
225
|
],
|
|
223
226
|
"additionalProperties": false,
|
|
224
|
-
"description": "secret from env:NAME
|
|
227
|
+
"description": "secret from env:NAME, file:path or <provider>:<ref>, injected as env or a read-only file"
|
|
225
228
|
}
|
|
226
229
|
},
|
|
230
|
+
"account": {
|
|
231
|
+
"type": "string"
|
|
232
|
+
},
|
|
227
233
|
"resources": {
|
|
228
234
|
"type": "object",
|
|
229
235
|
"properties": {
|
|
@@ -319,6 +325,9 @@
|
|
|
319
325
|
"secrets": {
|
|
320
326
|
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/secrets"
|
|
321
327
|
},
|
|
328
|
+
"account": {
|
|
329
|
+
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/account"
|
|
330
|
+
},
|
|
322
331
|
"resources": {
|
|
323
332
|
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/resources"
|
|
324
333
|
},
|
|
@@ -446,6 +455,9 @@
|
|
|
446
455
|
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/secrets/items"
|
|
447
456
|
}
|
|
448
457
|
},
|
|
458
|
+
"account": {
|
|
459
|
+
"type": "string"
|
|
460
|
+
},
|
|
449
461
|
"resources": {
|
|
450
462
|
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/resources",
|
|
451
463
|
"description": "cpu and memory requests and limits"
|
|
@@ -748,6 +760,74 @@
|
|
|
748
760
|
"additionalProperties": false
|
|
749
761
|
}
|
|
750
762
|
},
|
|
763
|
+
"secretProviders": {
|
|
764
|
+
"type": "object",
|
|
765
|
+
"additionalProperties": {
|
|
766
|
+
"type": "object",
|
|
767
|
+
"properties": {
|
|
768
|
+
"command": {
|
|
769
|
+
"type": "array",
|
|
770
|
+
"items": {
|
|
771
|
+
"type": "string"
|
|
772
|
+
},
|
|
773
|
+
"minItems": 1,
|
|
774
|
+
"description": "command and arguments, no shell; {{ref}} is replaced by the secret reference"
|
|
775
|
+
},
|
|
776
|
+
"env": {
|
|
777
|
+
"type": "object",
|
|
778
|
+
"additionalProperties": {
|
|
779
|
+
"type": "string"
|
|
780
|
+
},
|
|
781
|
+
"description": "static environment of the command, next to PATH and the account env"
|
|
782
|
+
},
|
|
783
|
+
"timeout": {
|
|
784
|
+
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/timeout",
|
|
785
|
+
"description": "how long the command may run, 30s when omitted"
|
|
786
|
+
}
|
|
787
|
+
},
|
|
788
|
+
"required": [
|
|
789
|
+
"command"
|
|
790
|
+
],
|
|
791
|
+
"additionalProperties": false,
|
|
792
|
+
"description": "secret provider, a command that prints the secret to stdout"
|
|
793
|
+
}
|
|
794
|
+
},
|
|
795
|
+
"secretAccounts": {
|
|
796
|
+
"type": "object",
|
|
797
|
+
"additionalProperties": {
|
|
798
|
+
"type": "object",
|
|
799
|
+
"properties": {
|
|
800
|
+
"default": {
|
|
801
|
+
"type": "boolean",
|
|
802
|
+
"description": "the account for secrets and tasks that name none"
|
|
803
|
+
},
|
|
804
|
+
"providers": {
|
|
805
|
+
"type": "object",
|
|
806
|
+
"additionalProperties": {
|
|
807
|
+
"type": "object",
|
|
808
|
+
"properties": {
|
|
809
|
+
"env": {
|
|
810
|
+
"type": "object",
|
|
811
|
+
"additionalProperties": {
|
|
812
|
+
"type": "string"
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
},
|
|
816
|
+
"required": [
|
|
817
|
+
"env"
|
|
818
|
+
],
|
|
819
|
+
"additionalProperties": false
|
|
820
|
+
},
|
|
821
|
+
"description": "per provider, the environment that makes its command act as this account"
|
|
822
|
+
}
|
|
823
|
+
},
|
|
824
|
+
"required": [
|
|
825
|
+
"providers"
|
|
826
|
+
],
|
|
827
|
+
"additionalProperties": false,
|
|
828
|
+
"description": "service account the secret providers are read as"
|
|
829
|
+
}
|
|
830
|
+
},
|
|
751
831
|
"labels": {
|
|
752
832
|
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/labels"
|
|
753
833
|
}
|
package/dist/cli.js
CHANGED
|
@@ -21,6 +21,7 @@ const work_graph_serializer_1 = require("./planner/work-graph-serializer");
|
|
|
21
21
|
const dry_run_1 = require("./executer/dry-run");
|
|
22
22
|
const cache_sync_1 = require("./cache/cache-sync");
|
|
23
23
|
const cache_inventory_1 = require("./cache/cache-inventory");
|
|
24
|
+
const prefetch_secrets_1 = require("./secrets/prefetch-secrets");
|
|
24
25
|
const isCliTask = (val) => val.type === 'task';
|
|
25
26
|
exports.isCliTask = isCliTask;
|
|
26
27
|
const isCliService = (val) => val.type === 'service';
|
|
@@ -30,6 +31,11 @@ class Cli {
|
|
|
30
31
|
this.workTree = workTree;
|
|
31
32
|
this.environment = environment;
|
|
32
33
|
}
|
|
34
|
+
// Starts the run's secrets: the values that make up a task id are fetched
|
|
35
|
+
// before anything asks for an id, see prefetchSecrets.
|
|
36
|
+
async prepareSecrets(newRun = false) {
|
|
37
|
+
await (0, prefetch_secrets_1.prefetchSecrets)(this.workTree, this.environment, newRun);
|
|
38
|
+
}
|
|
33
39
|
setup(type, options) {
|
|
34
40
|
const logMode = options?.logMode ?? (ci_1.isCI ? 'live' : 'interactive');
|
|
35
41
|
const workTree = (0, reset_work_tree_1.resetWorkTree)(this.workTree, type);
|
|
@@ -43,6 +49,9 @@ class Cli {
|
|
|
43
49
|
return {
|
|
44
50
|
state: processWorkTree,
|
|
45
51
|
start: async () => {
|
|
52
|
+
if (type !== 'down') {
|
|
53
|
+
await this.prepareSecrets(true);
|
|
54
|
+
}
|
|
46
55
|
(0, check_for_loop_1.checkForLoop)(workTree);
|
|
47
56
|
const capacity = (0, plan_work_tasks_1.hasError)(workTree) ? null : await (0, resource_budget_1.getResourceCapacity)(workTree, this.environment);
|
|
48
57
|
if (capacity && type !== 'down' && (options?.resourceCheck ?? true)) {
|
|
@@ -100,15 +109,19 @@ class Cli {
|
|
|
100
109
|
return await run.start();
|
|
101
110
|
}
|
|
102
111
|
async clean(options) {
|
|
112
|
+
await this.prepareSecrets();
|
|
103
113
|
await (0, event_cache_1.cleanCache)(this.workTree, this.environment, options);
|
|
104
114
|
}
|
|
105
115
|
async restore(path) {
|
|
116
|
+
await this.prepareSecrets();
|
|
106
117
|
await (0, event_cache_1.restoreCache)(this.environment, path, this.workTree);
|
|
107
118
|
}
|
|
108
119
|
async package(options) {
|
|
120
|
+
await this.prepareSecrets();
|
|
109
121
|
await (0, package_1.packageWorkTree)(this.workTree, this.environment, options);
|
|
110
122
|
}
|
|
111
123
|
async store(path) {
|
|
124
|
+
await this.prepareSecrets();
|
|
112
125
|
await (0, event_cache_1.storeCache)(this.environment, path, this.workTree);
|
|
113
126
|
}
|
|
114
127
|
services() {
|
|
@@ -132,6 +145,7 @@ class Cli {
|
|
|
132
145
|
return [...(0, plan_work_tasks_1.iterateWorkTasks)(this.workTree)].map((task) => (0, describe_task_1.describeTask)(task, this.environment.cwd));
|
|
133
146
|
}
|
|
134
147
|
async explain(options) {
|
|
148
|
+
await this.prepareSecrets();
|
|
135
149
|
return (0, explain_1.explainWorkTree)(this.workTree, options?.cacheDefault ?? 'checksum', this.environment);
|
|
136
150
|
}
|
|
137
151
|
// Serialize the in-scope work graph for rendering (mermaid/dot). Pure read of
|
|
@@ -142,25 +156,30 @@ class Cli {
|
|
|
142
156
|
// Ordered execution plan with predicted cache decisions, reusing the explain
|
|
143
157
|
// engine — executes nothing, starts nothing, performs no cache push/pull.
|
|
144
158
|
async dryRun(options) {
|
|
159
|
+
await this.prepareSecrets();
|
|
145
160
|
return (0, dry_run_1.planDryRun)(this.workTree, options?.cacheDefault ?? 'checksum', this.environment);
|
|
146
161
|
}
|
|
147
162
|
// Move cache entries between the tasks' own caches and a named remote without
|
|
148
163
|
// executing anything (`cache pull` / `cache push`).
|
|
149
164
|
async syncCache(options) {
|
|
165
|
+
await this.prepareSecrets();
|
|
150
166
|
return (0, cache_sync_1.syncCache)(this.workTree, options, this.environment);
|
|
151
167
|
}
|
|
152
168
|
// Entries of a named cache (default: the local `default` cache).
|
|
153
169
|
async listCache(cacheName) {
|
|
170
|
+
await this.prepareSecrets();
|
|
154
171
|
return (0, cache_inventory_1.listCache)(this.workTree, cacheName, this.environment);
|
|
155
172
|
}
|
|
156
173
|
retentionPolicy(cacheName, overrides) {
|
|
157
174
|
return (0, cache_inventory_1.retentionPolicyOf)(this.workTree, cacheName, overrides);
|
|
158
175
|
}
|
|
159
176
|
async pruneCache(cacheName, policy, dryRun = false) {
|
|
177
|
+
await this.prepareSecrets();
|
|
160
178
|
return (0, cache_inventory_1.pruneCache)(this.workTree, cacheName, policy, { dryRun }, this.environment);
|
|
161
179
|
}
|
|
162
180
|
// Apply declared retention to the local caches this build file uses.
|
|
163
181
|
async autoPrune() {
|
|
182
|
+
await this.prepareSecrets();
|
|
164
183
|
return (0, cache_inventory_1.autoPrune)(this.workTree, this.environment);
|
|
165
184
|
}
|
|
166
185
|
task(name) {
|
|
@@ -5,14 +5,17 @@ const work_item_status_1 = require("../planner/work-item-status");
|
|
|
5
5
|
const get_file_context_1 = require("../file/get-file-context");
|
|
6
6
|
const log_1 = require("../log");
|
|
7
7
|
const empty_writable_1 = require("../utils/empty-writable");
|
|
8
|
+
const redact_1 = require("../utils/redact");
|
|
8
9
|
function environmentMock(cwd) {
|
|
10
|
+
const secrets = (0, redact_1.createSecretRegistry)();
|
|
9
11
|
return {
|
|
10
12
|
cwd,
|
|
11
13
|
file: (0, get_file_context_1.getFileContext)(cwd),
|
|
12
14
|
console: (0, log_1.consoleContext)((0, empty_writable_1.emptyWritable)()),
|
|
13
15
|
abortCtrl: new AbortController(),
|
|
14
16
|
processEnvs: {},
|
|
15
|
-
status: (0, work_item_status_1.statusConsole)((0, empty_writable_1.emptyWritable)()),
|
|
17
|
+
status: (0, work_item_status_1.statusConsole)((0, empty_writable_1.emptyWritable)(), secrets),
|
|
18
|
+
secrets,
|
|
16
19
|
stdout: (0, empty_writable_1.emptyWritable)(),
|
|
17
20
|
stderr: (0, empty_writable_1.emptyWritable)(),
|
|
18
21
|
stdoutColumns: 80,
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
4
4
|
const log_1 = require("./log");
|
|
5
5
|
const get_file_context_1 = require("./file/get-file-context");
|
|
6
6
|
const work_item_status_1 = require("./planner/work-item-status");
|
|
7
|
+
const redact_1 = require("./utils/redact");
|
|
7
8
|
const empty_writable_1 = require("./utils/empty-writable");
|
|
8
9
|
const run_program_1 = require("./run-program");
|
|
9
10
|
const abort_on_signals_1 = require("./utils/abort-on-signals");
|
|
@@ -19,13 +20,15 @@ process.on('beforeExit', () => {
|
|
|
19
20
|
process.exitCode = 1;
|
|
20
21
|
}
|
|
21
22
|
});
|
|
23
|
+
const secrets = (0, redact_1.createSecretRegistry)();
|
|
22
24
|
(0, run_program_1.runProgram)({
|
|
23
25
|
cwd: process.cwd(),
|
|
24
26
|
abortCtrl,
|
|
25
27
|
processEnvs: process.env,
|
|
26
28
|
file: (0, get_file_context_1.getFileContext)(process.cwd()),
|
|
27
29
|
console: (0, log_1.consoleContext)(process.stdout),
|
|
28
|
-
status: (0, work_item_status_1.statusConsole)((0, empty_writable_1.emptyWritable)()),
|
|
30
|
+
status: (0, work_item_status_1.statusConsole)((0, empty_writable_1.emptyWritable)(), secrets),
|
|
31
|
+
secrets,
|
|
29
32
|
stdout: process.stdout,
|
|
30
33
|
stderr: process.stderr,
|
|
31
34
|
stdoutColumns: process.stdout.columns,
|
|
@@ -26,7 +26,9 @@ function getServiceDefinitionHash(item) {
|
|
|
26
26
|
src: service.src.map((s) => s.absolutePath),
|
|
27
27
|
healthcheck: service.healthcheck?.cmd.cmd ?? null,
|
|
28
28
|
// where secrets go, never their values: a rotated value needs a `down`
|
|
29
|
-
secrets: service.secrets.map((s) =>
|
|
29
|
+
secrets: service.secrets.map((s) => s.source.type === 'provider'
|
|
30
|
+
? `provider:${(0, work_secret_1.getSecretName)(s)}:${(0, work_secret_1.describeSecretSource)(s)}`
|
|
31
|
+
: `${s.source.type}:${(0, work_secret_1.getSecretName)(s)}`),
|
|
30
32
|
// the init task's id covers its image, commands, envs and mounts: a changed
|
|
31
33
|
// init recreates the service, so it runs again
|
|
32
34
|
init: service.init ? service.init.task.id() : null,
|
|
@@ -114,7 +114,7 @@ function parseService(workTree, service, environment, context) {
|
|
|
114
114
|
caching,
|
|
115
115
|
// set once the service is planned, see appendServiceInit
|
|
116
116
|
init: null,
|
|
117
|
-
secrets: (0, work_secret_1.parseWorkSecrets)(service.cwd, context.projectRoot, service.schema.secrets, envs, environment),
|
|
117
|
+
secrets: (0, work_secret_1.parseWorkSecrets)(service.cwd, context.projectRoot, service.schema.secrets, envs, environment, context.secrets, service.schema.account),
|
|
118
118
|
resources: (0, work_resources_1.parseWorkResources)(service.schema.resources),
|
|
119
119
|
};
|
|
120
120
|
}
|
|
@@ -70,7 +70,7 @@ function parseTask(cwd, task, environment, context) {
|
|
|
70
70
|
shell: task.schema.shell ? (0, template_value_1.templateValue)(task.schema.shell, envs) : '/bin/sh',
|
|
71
71
|
continuous: task.schema.continuous ?? false,
|
|
72
72
|
timeout: task.schema.timeout ? (0, units_1.parseDuration)(task.schema.timeout) : null,
|
|
73
|
-
secrets: (0, work_secret_1.parseWorkSecrets)(cwd, context.projectRoot, task.schema.secrets, envs, environment),
|
|
73
|
+
secrets: (0, work_secret_1.parseWorkSecrets)(cwd, context.projectRoot, task.schema.secrets, envs, environment, context.secrets, task.schema.account),
|
|
74
74
|
resources: (0, work_resources_1.parseWorkResources)(task.schema.resources),
|
|
75
75
|
};
|
|
76
76
|
if ((0, build_file_task_schema_1.isBuildFileContainerTaskSchema)(task.schema)) {
|
|
@@ -15,12 +15,13 @@ function logContext(type, item) {
|
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
17
|
exports.logContext = logContext;
|
|
18
|
-
|
|
18
|
+
// `secrets` is the registry of the run (its Environment) whose output this console shows.
|
|
19
|
+
function statusConsole(writable, secrets = (0, redact_1.createSecretRegistry)()) {
|
|
19
20
|
const statusBuffer = new buffer_context_1.BufferContext(StatusBufferMax);
|
|
20
21
|
const consoleBuffer = new buffer_context_1.BufferContext(ConsoleBufferMax);
|
|
21
22
|
const emit = (0, emitter_1.emitter)();
|
|
22
23
|
function addMessage(context, unredacted) {
|
|
23
|
-
const message = { ...unredacted, message:
|
|
24
|
+
const message = { ...unredacted, message: secrets.redact(unredacted.message) };
|
|
24
25
|
emit.emit(message);
|
|
25
26
|
writable.write(JSON.stringify({ context, message }) + '\n');
|
|
26
27
|
if (message.type === 'console') {
|
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.getSecretDigest = exports.resolveSecretValue = exports.parseWorkSecrets = exports.getPortableSecretName = exports.getSecretName = void 0;
|
|
3
|
+
exports.getSecretDigest = exports.resolveSecretValue = exports.parseWorkSecrets = exports.getPortableSecretName = exports.getSecretName = exports.describeSecretSource = void 0;
|
|
4
4
|
const crypto_1 = require("crypto");
|
|
5
5
|
const os_1 = require("os");
|
|
6
6
|
const fs_1 = require("fs");
|
|
7
7
|
const normalize_path_1 = require("./utils/normalize-path");
|
|
8
8
|
const template_value_1 = require("./utils/template-value");
|
|
9
9
|
const portable_path_1 = require("./utils/portable-path");
|
|
10
|
-
const
|
|
10
|
+
const abort_1 = require("../executer/abort");
|
|
11
|
+
const provider_values_1 = require("../secrets/provider-values");
|
|
12
|
+
// Where the value comes from, for messages and for what makes a service
|
|
13
|
+
// different: never the value.
|
|
14
|
+
function describeSecretSource(secret) {
|
|
15
|
+
const source = secret.source;
|
|
16
|
+
if (source.type === 'provider') {
|
|
17
|
+
return `${source.provider}:${source.ref} as ${source.account}`;
|
|
18
|
+
}
|
|
19
|
+
return source.type === 'env' ? `env:${source.name}` : `file:${source.path}`;
|
|
20
|
+
}
|
|
21
|
+
exports.describeSecretSource = describeSecretSource;
|
|
11
22
|
function getSecretName(secret) {
|
|
12
23
|
return secret.target.type === 'env' ? secret.target.name : secret.target.path;
|
|
13
24
|
}
|
|
@@ -18,15 +29,16 @@ function getPortableSecretName(projectRoot, secret) {
|
|
|
18
29
|
return secret.target.type === 'env' ? secret.target.name : (0, portable_path_1.portablePath)(projectRoot, secret.target.path);
|
|
19
30
|
}
|
|
20
31
|
exports.getPortableSecretName = getPortableSecretName;
|
|
21
|
-
function parseWorkSecrets(cwd, projectRoot, secrets, envs, environment
|
|
32
|
+
function parseWorkSecrets(cwd, projectRoot, secrets, envs, environment, catalog,
|
|
33
|
+
// the service account of the task or service the secrets belong to
|
|
34
|
+
account) {
|
|
22
35
|
return (secrets ?? []).map((secret) => {
|
|
23
36
|
const ref = secret.from.substring(secret.from.indexOf(':') + 1);
|
|
24
|
-
const source = secret.from.startsWith('env:')
|
|
25
|
-
? { type: 'env', name: ref }
|
|
26
|
-
: { type: 'file', path: (0, normalize_path_1.normalizePath)(cwd, (0, os_1.homedir)(), (0, template_value_1.templateValue)(ref, envs)) };
|
|
27
37
|
const target = secret.env
|
|
28
38
|
? { type: 'env', name: secret.env }
|
|
29
39
|
: { type: 'file', path: (0, normalize_path_1.normalizePath)(cwd, cwd, (0, template_value_1.templateValue)(secret.path ?? '', envs)) };
|
|
40
|
+
const name = target.type === 'env' ? target.name : secret.path;
|
|
41
|
+
const source = parseSecretSource(secret, ref, cwd, envs, catalog, account, `secret ${name}`);
|
|
30
42
|
const workSecret = {
|
|
31
43
|
source,
|
|
32
44
|
target,
|
|
@@ -37,20 +49,65 @@ function parseWorkSecrets(cwd, projectRoot, secrets, envs, environment) {
|
|
|
37
49
|
});
|
|
38
50
|
}
|
|
39
51
|
exports.parseWorkSecrets = parseWorkSecrets;
|
|
52
|
+
function parseSecretSource(secret, ref, cwd, envs, catalog, taskAccount, label) {
|
|
53
|
+
const kind = secret.from.substring(0, secret.from.indexOf(':'));
|
|
54
|
+
if (kind === 'env') {
|
|
55
|
+
return { type: 'env', name: ref };
|
|
56
|
+
}
|
|
57
|
+
if (kind === 'file') {
|
|
58
|
+
return { type: 'file', path: (0, normalize_path_1.normalizePath)(cwd, (0, os_1.homedir)(), (0, template_value_1.templateValue)(ref, envs)) };
|
|
59
|
+
}
|
|
60
|
+
const provider = catalog.providers[kind];
|
|
61
|
+
if (!provider) {
|
|
62
|
+
const declared = Object.keys(catalog.providers);
|
|
63
|
+
throw new Error(`${label}: unknown secret provider ${kind}, ${declared.length > 0 ? `declared: ${declared.join(', ')}` : 'none is declared'}`);
|
|
64
|
+
}
|
|
65
|
+
const providerRef = (0, template_value_1.templateValue)(ref, envs);
|
|
66
|
+
if (providerRef.length === 0 || providerRef.startsWith('-')) {
|
|
67
|
+
throw new Error(`${label}: a reference for ${kind} must not be empty or start with -`);
|
|
68
|
+
}
|
|
69
|
+
const accountName = secret.account ?? taskAccount ?? Object.values(catalog.accounts).find((a) => a.default)?.name;
|
|
70
|
+
if (!accountName) {
|
|
71
|
+
throw new Error(`${label}: ${kind}:${providerRef} needs an account, and none is named or marked default: true`);
|
|
72
|
+
}
|
|
73
|
+
const account = catalog.accounts[accountName];
|
|
74
|
+
if (!account) {
|
|
75
|
+
throw new Error(`${label}: unknown secret account ${accountName}, declared: ${Object.keys(catalog.accounts).join(', ') || 'none'}`);
|
|
76
|
+
}
|
|
77
|
+
const credentials = account.providers[kind];
|
|
78
|
+
if (!credentials) {
|
|
79
|
+
throw new Error(`${label}: account ${accountName} has no credentials for provider ${kind}`);
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
type: 'provider',
|
|
83
|
+
provider: kind,
|
|
84
|
+
ref: providerRef,
|
|
85
|
+
account: accountName,
|
|
86
|
+
binding: { definition: provider, account: { name: accountName, env: credentials.env } },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
40
89
|
function readSecretValueSync(secret, environment) {
|
|
41
90
|
if (secret.source.type === 'env') {
|
|
42
91
|
const value = environment.processEnvs[secret.source.name];
|
|
43
92
|
if (value === undefined || value === null) {
|
|
44
93
|
throw new Error(`secret ${getSecretName(secret)}: environment variable ${secret.source.name} is not set`);
|
|
45
94
|
}
|
|
46
|
-
|
|
95
|
+
environment.secrets.register(value);
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
if (secret.source.type === 'provider') {
|
|
99
|
+
const value = (0, provider_values_1.peekProviderValue)(secret.source.binding, secret.source.ref, environment);
|
|
100
|
+
if (value === undefined) {
|
|
101
|
+
throw new Error(`secret ${getSecretName(secret)}: ${describeSecretSource(secret)} was not fetched before its task id was computed`);
|
|
102
|
+
}
|
|
103
|
+
environment.secrets.register(value);
|
|
47
104
|
return value;
|
|
48
105
|
}
|
|
49
106
|
if (!(0, fs_1.existsSync)(secret.source.path)) {
|
|
50
107
|
throw new Error(`secret ${getSecretName(secret)}: file ${secret.source.path} does not exist`);
|
|
51
108
|
}
|
|
52
109
|
const value = (0, fs_1.readFileSync)(secret.source.path, 'utf8');
|
|
53
|
-
|
|
110
|
+
environment.secrets.register(value);
|
|
54
111
|
return value;
|
|
55
112
|
}
|
|
56
113
|
// The value, read when the task or service starts (or when a cache-affecting
|
|
@@ -61,14 +118,17 @@ async function resolveSecretValue(secret, environment) {
|
|
|
61
118
|
if (value === undefined || value === null) {
|
|
62
119
|
throw new Error(`secret ${getSecretName(secret)}: environment variable ${secret.source.name} is not set`);
|
|
63
120
|
}
|
|
64
|
-
|
|
121
|
+
environment.secrets.register(value);
|
|
65
122
|
return value;
|
|
66
123
|
}
|
|
124
|
+
if (secret.source.type === 'provider') {
|
|
125
|
+
return resolveProviderSecret(secret, secret.source, environment);
|
|
126
|
+
}
|
|
67
127
|
if (!(await environment.file.exists(secret.source.path))) {
|
|
68
128
|
throw new Error(`secret ${getSecretName(secret)}: file ${secret.source.path} does not exist`);
|
|
69
129
|
}
|
|
70
130
|
const value = await environment.file.read(secret.source.path);
|
|
71
|
-
|
|
131
|
+
environment.secrets.register(value);
|
|
72
132
|
return value;
|
|
73
133
|
}
|
|
74
134
|
exports.resolveSecretValue = resolveSecretValue;
|
|
@@ -80,4 +140,21 @@ function getSecretDigest(projectRoot, cwd, secret, value) {
|
|
|
80
140
|
return `sha256:${(0, crypto_1.createHash)('sha256').update(`${salt}\0${value}`).digest('hex')}`;
|
|
81
141
|
}
|
|
82
142
|
exports.getSecretDigest = getSecretDigest;
|
|
143
|
+
async function resolveProviderSecret(secret, source, environment) {
|
|
144
|
+
let value;
|
|
145
|
+
try {
|
|
146
|
+
value = await (0, provider_values_1.fetchProviderValue)(source.binding, source.ref, environment);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (error instanceof abort_1.AbortError) {
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
throw new Error(`secret ${getSecretName(secret)} (${describeSecretSource(secret)}): ${error instanceof Error ? error.message : error}`);
|
|
153
|
+
}
|
|
154
|
+
if (value.length === 0) {
|
|
155
|
+
throw new Error(`secret ${getSecretName(secret)} (${describeSecretSource(secret)}): the provider returned no value`);
|
|
156
|
+
}
|
|
157
|
+
environment.secrets.register(value);
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
83
160
|
//# sourceMappingURL=work-secret.js.map
|
|
@@ -34,6 +34,8 @@ exports.buildFileBaseTaskSchema = (0, zod_1.object)({
|
|
|
34
34
|
// runs longer. Does not affect the cache key (ADR-0002).
|
|
35
35
|
timeout: duration_schema_1.durationSchema.optional(),
|
|
36
36
|
secrets: (0, zod_1.array)(build_file_secret_schema_1.buildFileSecretSchema).optional(),
|
|
37
|
+
// the service account its provider secrets are read as, unless a secret names one
|
|
38
|
+
account: (0, zod_1.string)().optional(),
|
|
37
39
|
// CPU and memory limits of the container; a local task is not limited
|
|
38
40
|
resources: resources_schema_1.resourcesSchema.optional(),
|
|
39
41
|
});
|
|
@@ -36,6 +36,8 @@ exports.buildFileContainerServiceSchema = (0, zod_1.object)({
|
|
|
36
36
|
// passed; the service counts as ready for dependents only after it succeeded
|
|
37
37
|
init: (0, zod_1.string)().describe('task run once the service is healthy, before dependents start').optional(),
|
|
38
38
|
secrets: (0, zod_1.array)(build_file_secret_schema_1.buildFileSecretSchema).optional(),
|
|
39
|
+
// the service account its provider secrets are read as, unless a secret names one
|
|
40
|
+
account: (0, zod_1.string)().optional(),
|
|
39
41
|
resources: resources_schema_1.resourcesSchema.optional(),
|
|
40
42
|
})
|
|
41
43
|
.strict()
|
|
@@ -9,6 +9,7 @@ const build_file_task_schema_1 = require("./build-file-task-schema");
|
|
|
9
9
|
const labels_schema_1 = require("./labels-schema");
|
|
10
10
|
const cache_schema_1 = require("./cache-schema");
|
|
11
11
|
const build_file_include_schema_1 = require("./build-file-include-schema");
|
|
12
|
+
const build_file_secret_provider_schema_1 = require("./build-file-secret-provider-schema");
|
|
12
13
|
exports.buildFileSchema = (0, zod_1.object)({
|
|
13
14
|
envs: envs_schema_1.envsSchema.optional(),
|
|
14
15
|
tasks: (0, zod_1.record)(build_file_task_schema_1.buildFileTaskSchema).optional(),
|
|
@@ -17,6 +18,8 @@ exports.buildFileSchema = (0, zod_1.object)({
|
|
|
17
18
|
includes: (0, zod_1.record)(build_file_include_schema_1.buildFileIncludeSchema).optional(),
|
|
18
19
|
environments: (0, zod_1.record)(build_file_environment_schema_1.buildFileEnvironmentSchema).optional(),
|
|
19
20
|
caches: (0, zod_1.record)(cache_schema_1.buildFileCacheSchema).optional(),
|
|
21
|
+
secretProviders: build_file_secret_provider_schema_1.buildFileSecretProvidersSchema.optional(),
|
|
22
|
+
secretAccounts: build_file_secret_provider_schema_1.buildFileSecretAccountsSchema.optional(),
|
|
20
23
|
labels: labels_schema_1.labelsSchema.optional(),
|
|
21
24
|
})
|
|
22
25
|
.strict()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildFileSecretAccountsSchema = exports.buildFileSecretProvidersSchema = exports.buildFileSecretAccountSchema = exports.buildFileSecretProviderSchema = exports.RESERVED_SECRET_PROVIDERS = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const duration_schema_1 = require("./duration-schema");
|
|
6
|
+
// `env` and `file` are the built-in sources of `from`, so a provider cannot take
|
|
7
|
+
// either name.
|
|
8
|
+
exports.RESERVED_SECRET_PROVIDERS = ['env', 'file'];
|
|
9
|
+
const nameSchema = (0, zod_1.string)()
|
|
10
|
+
.regex(/^[A-Za-z][A-Za-z0-9_-]*$/, 'a name starts with a letter and holds letters, digits, - and _')
|
|
11
|
+
.refine((name) => !exports.RESERVED_SECRET_PROVIDERS.includes(name), 'env and file are built-in secret sources');
|
|
12
|
+
// How to fetch a secret: a command that prints the value to stdout (ADR 0008).
|
|
13
|
+
// The command is an argv list, never run through a shell. `{{ref}}` is replaced
|
|
14
|
+
// by the text after `provider:` in a secret's `from`. It holds no credential:
|
|
15
|
+
// those come from the account the secret is read as.
|
|
16
|
+
exports.buildFileSecretProviderSchema = (0, zod_1.object)({
|
|
17
|
+
command: (0, zod_1.array)((0, zod_1.string)())
|
|
18
|
+
.min(1)
|
|
19
|
+
.describe('command and arguments, no shell; {{ref}} is replaced by the secret reference'),
|
|
20
|
+
env: (0, zod_1.record)((0, zod_1.string)()).optional().describe('static environment of the command, next to PATH and the account env'),
|
|
21
|
+
timeout: duration_schema_1.durationSchema.optional().describe('how long the command may run, 30s when omitted'),
|
|
22
|
+
})
|
|
23
|
+
.strict()
|
|
24
|
+
.describe('secret provider, a command that prints the secret to stdout');
|
|
25
|
+
// As whom a provider is read: the environment its command runs with. Values
|
|
26
|
+
// are `${HOST_VARIABLE}` references to a credential the machine running
|
|
27
|
+
// hammerkit holds, so a build file never carries one.
|
|
28
|
+
exports.buildFileSecretAccountSchema = (0, zod_1.object)({
|
|
29
|
+
default: (0, zod_1.boolean)().optional().describe('the account for secrets and tasks that name none'),
|
|
30
|
+
providers: (0, zod_1.record)(nameSchema, (0, zod_1.object)({ env: (0, zod_1.record)((0, zod_1.string)()) }).strict()).describe('per provider, the environment that makes its command act as this account'),
|
|
31
|
+
})
|
|
32
|
+
.strict()
|
|
33
|
+
.describe('service account the secret providers are read as');
|
|
34
|
+
exports.buildFileSecretProvidersSchema = (0, zod_1.record)(nameSchema, exports.buildFileSecretProviderSchema);
|
|
35
|
+
exports.buildFileSecretAccountsSchema = (0, zod_1.record)(nameSchema, exports.buildFileSecretAccountSchema);
|
|
36
|
+
//# sourceMappingURL=build-file-secret-provider-schema.js.map
|
|
@@ -3,11 +3,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.buildFileSecretSchema = void 0;
|
|
4
4
|
const zod_1 = require("zod");
|
|
5
5
|
// A credential handed to a task or service without putting it in the build
|
|
6
|
-
// file, the cache or the logs: `from: env:NAME
|
|
6
|
+
// file, the cache or the logs: `from: env:NAME`, `from: file:path` or
|
|
7
|
+
// `from: <provider>:<ref>` (a secret provider, read as an account), injected
|
|
7
8
|
// as an env variable (`env`) or a read-only file (`path`). `cache: true` makes
|
|
8
9
|
// a salted digest of the value part of the task's cache key (ADR 0002).
|
|
9
10
|
exports.buildFileSecretSchema = (0, zod_1.object)({
|
|
10
|
-
from: (0, zod_1.string)().regex(/^
|
|
11
|
+
from: (0, zod_1.string)().regex(/^[A-Za-z][A-Za-z0-9_-]*:.+$/, 'from must be env:NAME, file:path or <provider>:<ref>'),
|
|
12
|
+
// the service account a provider secret is read as, else the task's or service's, else the default
|
|
13
|
+
account: (0, zod_1.string)().optional(),
|
|
11
14
|
env: (0, zod_1.string)()
|
|
12
15
|
.regex(/^[A-Za-z_][A-Za-z0-9_]*$/)
|
|
13
16
|
.optional(),
|
|
@@ -18,5 +21,5 @@ exports.buildFileSecretSchema = (0, zod_1.object)({
|
|
|
18
21
|
.refine((secret) => (secret.env === undefined) !== (secret.path === undefined), {
|
|
19
22
|
message: 'a secret needs exactly one of env or path',
|
|
20
23
|
})
|
|
21
|
-
.describe('secret from env:NAME
|
|
24
|
+
.describe('secret from env:NAME, file:path or <provider>:<ref>, injected as env or a read-only file');
|
|
22
25
|
//# sourceMappingURL=build-file-secret-schema.js.map
|
|
@@ -9,6 +9,7 @@ const label_values_1 = require("../executer/label-values");
|
|
|
9
9
|
const merge_environment_variables_1 = require("../environment/merge-environment-variables");
|
|
10
10
|
const resolve_cache_1 = require("../cache/resolve-cache");
|
|
11
11
|
const project_root_1 = require("../planner/project-root");
|
|
12
|
+
const secret_catalog_1 = require("../secrets/secret-catalog");
|
|
12
13
|
function combinePath(namePrefix, name) {
|
|
13
14
|
return namePrefix.length > 0 ? `${namePrefix}:${name}` : name;
|
|
14
15
|
}
|
|
@@ -21,6 +22,7 @@ async function parseReferences(ctx, mainScope, environment) {
|
|
|
21
22
|
envFiles: {},
|
|
22
23
|
environments: {},
|
|
23
24
|
caches: {},
|
|
25
|
+
secrets: (0, secret_catalog_1.emptySecretCatalog)(),
|
|
24
26
|
projectRoot: await (0, project_root_1.findProjectRoot)(mainScope.cwd, environment),
|
|
25
27
|
};
|
|
26
28
|
for (const file of Object.values(ctx.files)) {
|
|
@@ -90,6 +92,7 @@ async function parseReferences(ctx, mainScope, environment) {
|
|
|
90
92
|
resolveInit(reference, service);
|
|
91
93
|
}
|
|
92
94
|
reference.caches = (0, resolve_cache_1.withBuiltinCaches)(declaredCaches);
|
|
95
|
+
reference.secrets = (0, secret_catalog_1.collectSecretCatalog)(Object.values(ctx.files), environment, reference);
|
|
93
96
|
return reference;
|
|
94
97
|
}
|
|
95
98
|
exports.parseReferences = parseReferences;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createCommandSecretProvider = void 0;
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
const abort_1 = require("../executer/abort");
|
|
6
|
+
const STDERR_LIMIT = 500;
|
|
7
|
+
// Runs the provider's command and takes its stdout, verbatim, as the value: a
|
|
8
|
+
// key file needs its trailing newline. No shell, so a reference is one argument
|
|
9
|
+
// and never syntax. The command sees PATH, the provider's own `env` and the
|
|
10
|
+
// account's `env` and nothing else of this process: a personal login (`gcloud
|
|
11
|
+
// auth login`, the 1Password app) is never picked up, and a missing credential
|
|
12
|
+
// fails instead of falling back to one.
|
|
13
|
+
function createCommandSecretProvider(binding) {
|
|
14
|
+
const { definition, account } = binding;
|
|
15
|
+
return {
|
|
16
|
+
async resolve(ref, environment) {
|
|
17
|
+
const [command, ...args] = definition.command.map((part) => expand(part, environment, `provider ${definition.name}`).replace(/\{\{ref\}\}/g, () => ref));
|
|
18
|
+
const env = {
|
|
19
|
+
...minimalEnvironment(environment),
|
|
20
|
+
...expandAll(definition.env, environment, `provider ${definition.name}`),
|
|
21
|
+
...expandAll(account.env, environment, `account ${account.name}, provider ${definition.name}`),
|
|
22
|
+
};
|
|
23
|
+
return run(command, args, env, definition.timeoutMs, environment);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
exports.createCommandSecretProvider = createCommandSecretProvider;
|
|
28
|
+
function minimalEnvironment(environment) {
|
|
29
|
+
const names = process.platform === 'win32' ? ['PATH', 'Path', 'PATHEXT', 'SystemRoot'] : ['PATH'];
|
|
30
|
+
const result = {};
|
|
31
|
+
for (const name of names) {
|
|
32
|
+
const value = environment.processEnvs[name];
|
|
33
|
+
if (value !== undefined && value !== null) {
|
|
34
|
+
result[name] = value;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
function expandAll(values, environment, owner) {
|
|
40
|
+
return Object.fromEntries(Object.entries(values).map(([key, value]) => [key, expand(value, environment, owner)]));
|
|
41
|
+
}
|
|
42
|
+
// `${NAME}` from the process environment; one that is not set stops the secret
|
|
43
|
+
// before the command runs.
|
|
44
|
+
function expand(value, environment, owner) {
|
|
45
|
+
return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => {
|
|
46
|
+
const resolved = environment.processEnvs[name];
|
|
47
|
+
if (resolved === undefined || resolved === null || resolved === '') {
|
|
48
|
+
throw new Error(`${owner}: ${name} is not set`);
|
|
49
|
+
}
|
|
50
|
+
return resolved;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function run(command, args, env, timeoutMs, environment) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const signal = environment.abortCtrl.signal;
|
|
56
|
+
if (signal.aborted) {
|
|
57
|
+
reject(new abort_1.AbortError());
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const child = (0, child_process_1.spawn)(command, args, { env, shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
61
|
+
const stdout = [];
|
|
62
|
+
const stderr = [];
|
|
63
|
+
let failure = null;
|
|
64
|
+
const finish = (error, value) => {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
signal.removeEventListener('abort', onAbort);
|
|
67
|
+
if (error) {
|
|
68
|
+
reject(error);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
resolve(value ?? '');
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const stop = (error) => {
|
|
75
|
+
failure = failure ?? error;
|
|
76
|
+
child.kill('SIGKILL');
|
|
77
|
+
};
|
|
78
|
+
const onAbort = () => stop(new abort_1.AbortError());
|
|
79
|
+
const timer = setTimeout(() => stop(new Error(`${command} did not finish within ${timeoutMs}ms`)), timeoutMs);
|
|
80
|
+
signal.addEventListener('abort', onAbort);
|
|
81
|
+
child.stdout.on('data', (chunk) => stdout.push(chunk));
|
|
82
|
+
child.stderr.on('data', (chunk) => stderr.push(chunk));
|
|
83
|
+
child.on('error', (error) => {
|
|
84
|
+
finish(error.code === 'ENOENT' ? new Error(`${command} was not found on PATH`) : error);
|
|
85
|
+
});
|
|
86
|
+
child.on('close', (code) => {
|
|
87
|
+
if (failure) {
|
|
88
|
+
finish(failure);
|
|
89
|
+
}
|
|
90
|
+
else if (code !== 0) {
|
|
91
|
+
finish(new Error(`${command} exited with code ${code}${describeStderr(stderr, environment)}`));
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
finish(null, Buffer.concat(stdout).toString('utf8'));
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
// What the CLI said, as the first lines of a failure; its own output may hold a
|
|
100
|
+
// value it was asked for, so it is masked like all output.
|
|
101
|
+
function describeStderr(chunks, environment) {
|
|
102
|
+
const text = environment.secrets.redact(Buffer.concat(chunks).toString('utf8').trim());
|
|
103
|
+
if (text.length === 0) {
|
|
104
|
+
return '';
|
|
105
|
+
}
|
|
106
|
+
return `: ${text.length > STDERR_LIMIT ? `${text.substring(0, STDERR_LIMIT)}…` : text}`;
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=command-secret-provider.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.prefetchSecrets = void 0;
|
|
4
|
+
const plan_work_tasks_1 = require("../planner/utils/plan-work-tasks");
|
|
5
|
+
const work_secret_1 = require("../planner/work-secret");
|
|
6
|
+
const provider_values_1 = require("./provider-values");
|
|
7
|
+
// Fetches the values a task id needs; a run drops the ones of the previous run
|
|
8
|
+
// first (`newRun`), any other command reuses what the run already fetched. A task id is computed synchronously and holds the
|
|
9
|
+
// digest of each `cache: true` secret, so those values must be in memory before
|
|
10
|
+
// anything asks for an id. Secrets that don't affect the id are read when their
|
|
11
|
+
// task starts, and a plan without provider secrets calls no provider.
|
|
12
|
+
async function prefetchSecrets(workTree, environment, newRun = false) {
|
|
13
|
+
if (newRun) {
|
|
14
|
+
(0, provider_values_1.beginSecretRun)(environment);
|
|
15
|
+
}
|
|
16
|
+
const fetches = [];
|
|
17
|
+
for (const task of (0, plan_work_tasks_1.iterateWorkTasks)(workTree)) {
|
|
18
|
+
for (const secret of task.data.secrets) {
|
|
19
|
+
if (secret.cache && secret.source.type === 'provider') {
|
|
20
|
+
fetches.push((0, work_secret_1.resolveSecretValue)(secret, environment));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
await Promise.all(fetches);
|
|
25
|
+
}
|
|
26
|
+
exports.prefetchSecrets = prefetchSecrets;
|
|
27
|
+
//# sourceMappingURL=prefetch-secrets.js.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.peekProviderValue = exports.fetchProviderValue = exports.beginSecretRun = void 0;
|
|
4
|
+
const secret_provider_1 = require("./secret-provider");
|
|
5
|
+
// The values fetched in the current run, by provider, account and reference.
|
|
6
|
+
// Concurrent requests share one fetch: a secret manager is rate limited (a
|
|
7
|
+
// 1Password service account per token) and several tasks read the same value.
|
|
8
|
+
// Held in memory only, never written anywhere, and dropped when the next run
|
|
9
|
+
// begins so a rotated value is picked up by it.
|
|
10
|
+
const runs = new WeakMap();
|
|
11
|
+
function beginSecretRun(environment) {
|
|
12
|
+
runs.set(environment, new Map());
|
|
13
|
+
}
|
|
14
|
+
exports.beginSecretRun = beginSecretRun;
|
|
15
|
+
function keyOf(binding, ref) {
|
|
16
|
+
return [binding.definition.name, binding.account.name, ref].join('\0');
|
|
17
|
+
}
|
|
18
|
+
function fetchProviderValue(binding, ref, environment) {
|
|
19
|
+
let values = runs.get(environment);
|
|
20
|
+
if (!values) {
|
|
21
|
+
values = new Map();
|
|
22
|
+
runs.set(environment, values);
|
|
23
|
+
}
|
|
24
|
+
const key = keyOf(binding, ref);
|
|
25
|
+
let fetched = values.get(key);
|
|
26
|
+
if (!fetched) {
|
|
27
|
+
environment.status
|
|
28
|
+
.context({ type: 'cli', name: 'hammerkit' })
|
|
29
|
+
.write('debug', `secret ${ref} via ${binding.definition.name}/${binding.account.name}`);
|
|
30
|
+
const entry = { promise: (0, secret_provider_1.createSecretProvider)(binding).resolve(ref, environment) };
|
|
31
|
+
entry.promise = entry.promise.then((value) => {
|
|
32
|
+
entry.value = value;
|
|
33
|
+
return value;
|
|
34
|
+
});
|
|
35
|
+
fetched = entry;
|
|
36
|
+
values.set(key, fetched);
|
|
37
|
+
}
|
|
38
|
+
return fetched.promise;
|
|
39
|
+
}
|
|
40
|
+
exports.fetchProviderValue = fetchProviderValue;
|
|
41
|
+
// The value of a fetch that has finished in this run, for the sync part of
|
|
42
|
+
// planning (a task id); undefined when it was not fetched, see prefetchSecrets.
|
|
43
|
+
function peekProviderValue(binding, ref, environment) {
|
|
44
|
+
return runs.get(environment)?.get(keyOf(binding, ref))?.value;
|
|
45
|
+
}
|
|
46
|
+
exports.peekProviderValue = peekProviderValue;
|
|
47
|
+
//# sourceMappingURL=provider-values.js.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.collectSecretCatalog = exports.emptySecretCatalog = void 0;
|
|
4
|
+
const merge_environment_variables_1 = require("../environment/merge-environment-variables");
|
|
5
|
+
const replace_env_variables_1 = require("../environment/replace-env-variables");
|
|
6
|
+
const units_1 = require("../utils/units");
|
|
7
|
+
const DEFAULT_TIMEOUT = '30s';
|
|
8
|
+
function emptySecretCatalog() {
|
|
9
|
+
return { providers: {}, accounts: {} };
|
|
10
|
+
}
|
|
11
|
+
exports.emptySecretCatalog = emptySecretCatalog;
|
|
12
|
+
const FULL_COMMIT = /^[0-9a-f]{40}$/i;
|
|
13
|
+
// Providers and accounts are global names, like caches, collected from every
|
|
14
|
+
// loaded build file. A name declared twice is an error rather than a silent
|
|
15
|
+
// override: which definition runs with a service account's credential must not
|
|
16
|
+
// depend on include order.
|
|
17
|
+
function collectSecretCatalog(files, environment, context) {
|
|
18
|
+
const catalog = emptySecretCatalog();
|
|
19
|
+
for (const file of files) {
|
|
20
|
+
const providers = Object.entries(file.schema.secretProviders ?? {});
|
|
21
|
+
const accounts = Object.entries(file.schema.secretAccounts ?? {});
|
|
22
|
+
if (providers.length === 0 && accounts.length === 0) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
requirePinnedSource(file);
|
|
26
|
+
const envs = (0, replace_env_variables_1.buildEnvironmentVariables)((0, merge_environment_variables_1.mergeEnvironmentVariables)(file.schema.envs, null), environment, context).variables;
|
|
27
|
+
for (const [name, provider] of providers) {
|
|
28
|
+
if (catalog.providers[name]) {
|
|
29
|
+
throw new Error(`secret provider ${name} is declared in ${catalog.providers[name].declaredIn} and in ${file.fileName}`);
|
|
30
|
+
}
|
|
31
|
+
catalog.providers[name] = {
|
|
32
|
+
name,
|
|
33
|
+
type: 'command',
|
|
34
|
+
command: provider.command.map((part) => applyEnvs(part, envs)),
|
|
35
|
+
env: provider.env ?? {},
|
|
36
|
+
timeoutMs: (0, units_1.parseDuration)(provider.timeout ?? DEFAULT_TIMEOUT),
|
|
37
|
+
declaredIn: file.fileName,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
for (const [name, account] of accounts) {
|
|
41
|
+
if (catalog.accounts[name]) {
|
|
42
|
+
throw new Error(`secret account ${name} is declared in ${catalog.accounts[name].declaredIn} and in ${file.fileName}`);
|
|
43
|
+
}
|
|
44
|
+
catalog.accounts[name] = {
|
|
45
|
+
name,
|
|
46
|
+
default: account.default ?? false,
|
|
47
|
+
providers: account.providers,
|
|
48
|
+
declaredIn: file.fileName,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const defaults = Object.values(catalog.accounts).filter((account) => account.default);
|
|
53
|
+
if (defaults.length > 1) {
|
|
54
|
+
throw new Error(`more than one default secret account: ${defaults.map((a) => a.name).join(', ')}`);
|
|
55
|
+
}
|
|
56
|
+
for (const account of Object.values(catalog.accounts)) {
|
|
57
|
+
for (const provider of Object.keys(account.providers)) {
|
|
58
|
+
if (!catalog.providers[provider]) {
|
|
59
|
+
throw new Error(`secret account ${account.name} (${account.declaredIn}) names provider ${provider}, which is not declared`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return catalog;
|
|
64
|
+
}
|
|
65
|
+
exports.collectSecretCatalog = collectSecretCatalog;
|
|
66
|
+
// A tag or branch can be moved, and a moved file runs with the credentials of a
|
|
67
|
+
// service account: providers and accounts from a git include are pinned.
|
|
68
|
+
function requirePinnedSource(file) {
|
|
69
|
+
if (file.remote && !FULL_COMMIT.test(file.remote.ref ?? '')) {
|
|
70
|
+
throw new Error(`${file.fileName} declares secret providers or accounts, so its git include ${file.remote.git} needs ref to be a full commit SHA`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// `${NAME}` that the build file's own `envs` define; any other reference stays
|
|
74
|
+
// for the host environment.
|
|
75
|
+
function applyEnvs(value, envs) {
|
|
76
|
+
return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, name) => name in envs && !envs[name].includes('$') ? envs[name] : match);
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=secret-catalog.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createSecretProvider = exports.registerSecretProvider = void 0;
|
|
4
|
+
const command_secret_provider_1 = require("./command-secret-provider");
|
|
5
|
+
const factories = new Map();
|
|
6
|
+
function registerSecretProvider(type, factory) {
|
|
7
|
+
factories.set(type, factory);
|
|
8
|
+
}
|
|
9
|
+
exports.registerSecretProvider = registerSecretProvider;
|
|
10
|
+
function createSecretProvider(binding) {
|
|
11
|
+
const factory = factories.get(binding.definition.type);
|
|
12
|
+
if (!factory) {
|
|
13
|
+
throw new Error(`unknown secret provider type: ${binding.definition.type}`);
|
|
14
|
+
}
|
|
15
|
+
return factory(binding);
|
|
16
|
+
}
|
|
17
|
+
exports.createSecretProvider = createSecretProvider;
|
|
18
|
+
registerSecretProvider('command', command_secret_provider_1.createCommandSecretProvider);
|
|
19
|
+
//# sourceMappingURL=secret-provider.js.map
|
package/dist/utils/redact.js
CHANGED
|
@@ -1,29 +1,85 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
4
|
-
// Secret values
|
|
5
|
-
// output it streams from a task or service.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
exports.getEncodedForms = exports.createSecretRegistry = void 0;
|
|
4
|
+
// Secret values a run has read, masked in everything it logs, including the
|
|
5
|
+
// output it streams from a task or service. The registry belongs to one run
|
|
6
|
+
// (its Environment): a process that serves several runs must not mask one
|
|
7
|
+
// run's values in another's output, and must not keep them after the run.
|
|
8
|
+
//
|
|
9
|
+
// Logs are per line, so each line of a multi-line value (a key file) is masked
|
|
10
|
+
// on its own. Values shorter than MIN_LENGTH are left alone: masking them would
|
|
11
|
+
// mangle unrelated output. The usual encodings of a value (base64, hex, URL,
|
|
12
|
+
// JSON) are masked too, since a task or tool often prints those instead; an
|
|
13
|
+
// encoding must be MIN_ENCODED_LENGTH long to be matched, for the same reason.
|
|
8
14
|
const MIN_LENGTH = 4;
|
|
15
|
+
const MIN_ENCODED_LENGTH = 8;
|
|
9
16
|
const MASK = '***';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
function createSecretRegistry() {
|
|
18
|
+
const values = new Set();
|
|
19
|
+
// longest first, so a value containing another is masked as a whole
|
|
20
|
+
let sorted = null;
|
|
21
|
+
function add(candidate, minLength) {
|
|
22
|
+
if (candidate.length >= minLength && !values.has(candidate)) {
|
|
23
|
+
values.add(candidate);
|
|
24
|
+
sorted = null;
|
|
16
25
|
}
|
|
17
26
|
}
|
|
27
|
+
return {
|
|
28
|
+
register(value) {
|
|
29
|
+
// the whole value, then each line of it; encodings are taken of each as it was written
|
|
30
|
+
for (const base of new Set([value, ...value.split(/\r?\n/)])) {
|
|
31
|
+
if (base.trim().length < MIN_LENGTH) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
add(base.trim(), MIN_LENGTH);
|
|
35
|
+
for (const form of getEncodedForms(base)) {
|
|
36
|
+
add(form.value, form.minLength);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
redact(message) {
|
|
41
|
+
if (values.size === 0) {
|
|
42
|
+
return message;
|
|
43
|
+
}
|
|
44
|
+
sorted = sorted ?? [...values].sort((a, b) => b.length - a.length);
|
|
45
|
+
return sorted.reduce((result, value) => result.split(value).join(MASK), message);
|
|
46
|
+
},
|
|
47
|
+
};
|
|
18
48
|
}
|
|
19
|
-
exports.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
49
|
+
exports.createSecretRegistry = createSecretRegistry;
|
|
50
|
+
// The encodings of a value that are likely to show up in output. Base64 is
|
|
51
|
+
// matched wherever it starts inside a longer base64 string: the value begins
|
|
52
|
+
// at byte offset 0, 1 or 2 of a 3-byte group, and the characters that mix in
|
|
53
|
+
// bytes before or after it are dropped, since they depend on unknown data.
|
|
54
|
+
function getEncodedForms(value) {
|
|
55
|
+
const bytes = Buffer.from(value, 'utf8');
|
|
56
|
+
const forms = [];
|
|
57
|
+
// the value on its own, as a task or tool usually prints it
|
|
58
|
+
const whole = bytes.toString('base64');
|
|
59
|
+
forms.push({ value: whole, minLength: MIN_ENCODED_LENGTH });
|
|
60
|
+
forms.push({ value: whole.replace(/\+/g, '-').replace(/\//g, '_'), minLength: MIN_ENCODED_LENGTH });
|
|
61
|
+
for (let offset = 0; offset < 3; offset++) {
|
|
62
|
+
const encoded = Buffer.concat([Buffer.alloc(offset), bytes])
|
|
63
|
+
.toString('base64')
|
|
64
|
+
.replace(/=+$/, '');
|
|
65
|
+
const leading = [0, 2, 3][offset];
|
|
66
|
+
const trailing = (bytes.length + offset) % 3 === 0 ? 0 : 1;
|
|
67
|
+
const form = encoded.substring(leading, encoded.length - trailing);
|
|
68
|
+
forms.push({ value: form, minLength: MIN_ENCODED_LENGTH });
|
|
69
|
+
forms.push({ value: form.replace(/\+/g, '-').replace(/\//g, '_'), minLength: MIN_ENCODED_LENGTH });
|
|
23
70
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
71
|
+
const hex = bytes.toString('hex');
|
|
72
|
+
forms.push({ value: hex, minLength: MIN_ENCODED_LENGTH });
|
|
73
|
+
forms.push({ value: hex.toUpperCase(), minLength: MIN_ENCODED_LENGTH });
|
|
74
|
+
const url = encodeURIComponent(value);
|
|
75
|
+
if (url !== value) {
|
|
76
|
+
forms.push({ value: url, minLength: MIN_LENGTH });
|
|
77
|
+
}
|
|
78
|
+
const json = JSON.stringify(value).slice(1, -1);
|
|
79
|
+
if (json !== value) {
|
|
80
|
+
forms.push({ value: json, minLength: MIN_LENGTH });
|
|
81
|
+
}
|
|
82
|
+
return forms;
|
|
27
83
|
}
|
|
28
|
-
exports.
|
|
84
|
+
exports.getEncodedForms = getEncodedForms;
|
|
29
85
|
//# sourceMappingURL=redact.js.map
|
package/package.json
CHANGED