hammerkit 1.22.0 → 1.24.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.
Files changed (54) hide show
  1. package/build.schema.json +92 -2
  2. package/dist/cli.js +25 -0
  3. package/dist/executer/environment-mock.js +4 -1
  4. package/dist/executer/execute-command.js +7 -1
  5. package/dist/index.js +4 -1
  6. package/dist/planner/service-definition.js +3 -1
  7. package/dist/planner/utils/append-work-service.js +1 -1
  8. package/dist/planner/utils/append-work-task.js +2 -1
  9. package/dist/planner/work-item-status.js +3 -2
  10. package/dist/planner/work-secret.js +87 -10
  11. package/dist/program.js +4 -0
  12. package/dist/remote/bundle.js +59 -0
  13. package/dist/remote/collect-bundle.js +41 -0
  14. package/dist/remote/credentials.js +93 -0
  15. package/dist/remote/device-login.js +177 -0
  16. package/dist/remote/host-config.js +170 -0
  17. package/dist/remote/location.js +46 -0
  18. package/dist/remote/remote-commands.js +154 -0
  19. package/dist/remote/remote-work-commands.js +152 -0
  20. package/dist/remote/server-client.js +88 -0
  21. package/dist/remote/source-manifest.js +79 -0
  22. package/dist/remote/text-diff.js +89 -0
  23. package/dist/schema/build-file-base-task-schema.js +6 -0
  24. package/dist/schema/build-file-container-service-schema.js +2 -0
  25. package/dist/schema/build-file-schema.js +6 -0
  26. package/dist/schema/build-file-secret-provider-schema.js +36 -0
  27. package/dist/schema/build-file-secret-schema.js +6 -3
  28. package/dist/schema/reference-parser.js +4 -0
  29. package/dist/schema/runner-schema.js +13 -0
  30. package/dist/secrets/command-secret-provider.js +108 -0
  31. package/dist/secrets/prefetch-secrets.js +27 -0
  32. package/dist/secrets/provider-values.js +47 -0
  33. package/dist/secrets/secret-catalog.js +78 -0
  34. package/dist/secrets/secret-provider.js +19 -0
  35. package/dist/server/approvals-api.js +99 -0
  36. package/dist/server/definitions-api.js +119 -0
  37. package/dist/server/definitions.js +248 -0
  38. package/dist/server/discovery.js +9 -0
  39. package/dist/server/executor.js +238 -0
  40. package/dist/server/git-checkout.js +63 -0
  41. package/dist/server/http.js +39 -0
  42. package/dist/server/oauth.js +226 -0
  43. package/dist/server/policy.js +90 -0
  44. package/dist/server/proposals-api.js +123 -0
  45. package/dist/server/proposals.js +126 -0
  46. package/dist/server/run-request.js +67 -0
  47. package/dist/server/runs.js +279 -0
  48. package/dist/server/server-command.js +43 -0
  49. package/dist/server/server-config.js +233 -0
  50. package/dist/server/server.js +338 -0
  51. package/dist/server/sources-api.js +106 -0
  52. package/dist/server/sources.js +258 -0
  53. package/dist/utils/redact.js +75 -19
  54. package/package.json +1 -1
@@ -0,0 +1,258 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SourceStore = exports.subjectKey = exports.DEFAULT_SOURCE_LIMITS = exports.SourceError = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const fs_1 = require("fs");
6
+ const promises_1 = require("fs/promises");
7
+ const path_1 = require("path");
8
+ const crypto_2 = require("crypto");
9
+ const bundle_1 = require("../remote/bundle");
10
+ const source_manifest_1 = require("../remote/source-manifest");
11
+ class SourceError extends Error {
12
+ constructor(status, message) {
13
+ super(message);
14
+ this.status = status;
15
+ }
16
+ }
17
+ exports.SourceError = SourceError;
18
+ exports.DEFAULT_SOURCE_LIMITS = { subjectBytes: 4 * 1024 * 1024 * 1024 };
19
+ // The directory name of a caller: the issuer and subject, hashed.
20
+ function subjectKey(principal) {
21
+ return (0, crypto_1.createHash)('sha256')
22
+ .update(JSON.stringify([principal.iss, principal.sub]))
23
+ .digest('hex')
24
+ .slice(0, 32);
25
+ }
26
+ exports.subjectKey = subjectKey;
27
+ /**
28
+ * The project files that runs on this server work on, kept per caller:
29
+ *
30
+ * <directory>/<caller>/blobs/<sha-256> content, once per digest
31
+ * <directory>/<caller>/manifests/<hash>.json the trees that were announced
32
+ *
33
+ * Content is shared between the manifests of one caller and never between
34
+ * callers: a server that answers "I have this file" to anybody would tell them
35
+ * what other people work on.
36
+ */
37
+ class SourceStore {
38
+ constructor(directory, limits = exports.DEFAULT_SOURCE_LIMITS, now = Date.now) {
39
+ this.directory = directory;
40
+ this.limits = limits;
41
+ this.now = now;
42
+ this.subjects = new Map();
43
+ }
44
+ // Announces a tree. Answers what has to be sent to complete it.
45
+ async announce(principal, manifest) {
46
+ const denied = manifest.files
47
+ .map((file) => ({ path: file.path, reason: (0, source_manifest_1.deniedSourcePath)(file.path) }))
48
+ .filter((entry) => entry.reason !== null);
49
+ if (denied.length > 0) {
50
+ const shown = denied
51
+ .slice(0, 5)
52
+ .map((entry) => `${entry.path} (${entry.reason})`)
53
+ .join(', ');
54
+ throw new SourceError(400, `${denied.length} file${denied.length === 1 ? '' : 's'} may not be sent: ${shown}${denied.length > 5 ? ', ...' : ''}`);
55
+ }
56
+ const key = subjectKey(principal);
57
+ const subject = await this.subject(key);
58
+ const snapshot = (0, source_manifest_1.hashSource)(manifest);
59
+ const existing = subject.manifests.get(snapshot);
60
+ if (existing) {
61
+ return { status: await this.statusOf(key, snapshot, existing), created: false };
62
+ }
63
+ // one digest has one size
64
+ const sizes = new Map();
65
+ for (const file of manifest.files) {
66
+ const known = sizes.get(file.sha256) ?? subject.wanted.get(file.sha256);
67
+ if (known !== undefined && known !== file.size) {
68
+ throw new SourceError(400, `${file.path}: ${file.sha256} is announced with two sizes`);
69
+ }
70
+ sizes.set(file.sha256, file.size);
71
+ }
72
+ const stored = { manifest, createdAt: new Date(this.now()).toISOString() };
73
+ await (0, promises_1.mkdir)((0, path_1.join)(this.directory, key, 'manifests'), { recursive: true });
74
+ await this.write((0, path_1.join)(this.directory, key, 'manifests', `${snapshot.slice('sha256:'.length)}.json`), stored);
75
+ subject.manifests.set(snapshot, stored);
76
+ for (const [digest, size] of sizes) {
77
+ subject.wanted.set(digest, size);
78
+ }
79
+ return { status: await this.statusOf(key, snapshot, stored), created: true };
80
+ }
81
+ // Keeps the content of one file, once the digest of what arrives is the one
82
+ // that was announced. Only content of an announced tree is taken.
83
+ async put(principal, digest, body) {
84
+ if (!source_manifest_1.DIGEST_PATTERN.test(digest)) {
85
+ throw new SourceError(404, 'not found');
86
+ }
87
+ const key = subjectKey(principal);
88
+ const subject = await this.subject(key);
89
+ const size = subject.wanted.get(digest);
90
+ if (size === undefined) {
91
+ throw new SourceError(400, 'this file is not part of a tree you announced');
92
+ }
93
+ const target = this.blob(key, digest);
94
+ if (await exists(target)) {
95
+ body.resume();
96
+ return 'exists';
97
+ }
98
+ if (subject.stored + size > this.limits.subjectBytes) {
99
+ body.resume();
100
+ throw new SourceError(413, `the server keeps at most ${this.limits.subjectBytes} bytes of files for you`);
101
+ }
102
+ await (0, promises_1.mkdir)((0, path_1.join)(this.directory, key, 'blobs'), { recursive: true });
103
+ const temporary = `${target}.${(0, crypto_2.randomBytes)(8).toString('hex')}.tmp`;
104
+ const hash = (0, crypto_1.createHash)('sha256');
105
+ let received = 0;
106
+ const out = (0, fs_1.createWriteStream)(temporary, { flags: 'wx' });
107
+ try {
108
+ for await (const chunk of body) {
109
+ received += chunk.length;
110
+ if (received > size) {
111
+ throw new SourceError(400, `the file is larger than the ${size} bytes that were announced`);
112
+ }
113
+ hash.update(chunk);
114
+ if (!out.write(chunk)) {
115
+ await new Promise((resolve) => out.once('drain', () => resolve()));
116
+ }
117
+ }
118
+ await new Promise((resolve, reject) => {
119
+ out.once('error', reject);
120
+ out.end(resolve);
121
+ });
122
+ if (received !== size) {
123
+ throw new SourceError(400, `the file has ${received} bytes, ${size} were announced`);
124
+ }
125
+ if (hash.digest('hex') !== digest) {
126
+ throw new SourceError(400, 'the content does not have the digest it was sent for');
127
+ }
128
+ await (0, promises_1.rename)(temporary, target);
129
+ }
130
+ catch (e) {
131
+ out.destroy();
132
+ await (0, promises_1.rm)(temporary, { force: true });
133
+ throw e;
134
+ }
135
+ subject.stored += size;
136
+ return 'created';
137
+ }
138
+ async get(principal, snapshot) {
139
+ if (!bundle_1.HASH_PATTERN.test(snapshot)) {
140
+ return undefined;
141
+ }
142
+ const key = subjectKey(principal);
143
+ const subject = await this.subject(key);
144
+ const stored = subject.manifests.get(snapshot);
145
+ return stored ? this.statusOf(key, snapshot, stored) : undefined;
146
+ }
147
+ async list(principal) {
148
+ const subject = await this.subject(subjectKey(principal));
149
+ const snapshots = [...subject.manifests.entries()]
150
+ .map(([snapshot, stored]) => ({
151
+ snapshot,
152
+ files: stored.manifest.files.length,
153
+ bytes: stored.manifest.files.reduce((sum, file) => sum + file.size, 0),
154
+ createdAt: stored.createdAt,
155
+ }))
156
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
157
+ return { snapshots, bytes: subject.stored, limit: this.limits.subjectBytes };
158
+ }
159
+ // Forgets a tree and the content nothing else of the caller's needs.
160
+ async remove(principal, snapshot) {
161
+ if (!bundle_1.HASH_PATTERN.test(snapshot)) {
162
+ return false;
163
+ }
164
+ const key = subjectKey(principal);
165
+ const subject = await this.subject(key);
166
+ if (!subject.manifests.delete(snapshot)) {
167
+ return false;
168
+ }
169
+ await (0, promises_1.rm)((0, path_1.join)(this.directory, key, 'manifests', `${snapshot.slice('sha256:'.length)}.json`), { force: true });
170
+ const kept = new Map();
171
+ for (const stored of subject.manifests.values()) {
172
+ for (const file of stored.manifest.files) {
173
+ kept.set(file.sha256, file.size);
174
+ }
175
+ }
176
+ for (const [digest, size] of subject.wanted) {
177
+ if (!kept.has(digest)) {
178
+ subject.wanted.delete(digest);
179
+ if (await exists(this.blob(key, digest))) {
180
+ await (0, promises_1.rm)(this.blob(key, digest), { force: true });
181
+ subject.stored -= size;
182
+ }
183
+ }
184
+ }
185
+ return true;
186
+ }
187
+ async statusOf(key, snapshot, stored) {
188
+ const missing = [];
189
+ for (const digest of new Set(stored.manifest.files.map((file) => file.sha256))) {
190
+ if (!(await exists(this.blob(key, digest)))) {
191
+ missing.push(digest);
192
+ }
193
+ }
194
+ return {
195
+ snapshot,
196
+ files: stored.manifest.files.length,
197
+ bytes: stored.manifest.files.reduce((sum, file) => sum + file.size, 0),
198
+ createdAt: stored.createdAt,
199
+ missing,
200
+ };
201
+ }
202
+ blob(key, digest) {
203
+ return (0, path_1.join)(this.directory, key, 'blobs', digest);
204
+ }
205
+ async write(file, value) {
206
+ const temporary = `${file}.${(0, crypto_2.randomBytes)(8).toString('hex')}.tmp`;
207
+ await (0, promises_1.writeFile)(temporary, JSON.stringify(value), { flag: 'wx' });
208
+ await (0, promises_1.rename)(temporary, file);
209
+ }
210
+ // Reads what is kept for one caller the first time they are seen.
211
+ subject(key) {
212
+ let loaded = this.subjects.get(key);
213
+ if (!loaded) {
214
+ loaded = this.load(key);
215
+ this.subjects.set(key, loaded);
216
+ }
217
+ return loaded;
218
+ }
219
+ async load(key) {
220
+ const subject = { wanted: new Map(), stored: 0, manifests: new Map() };
221
+ const manifests = (0, path_1.join)(this.directory, key, 'manifests');
222
+ let names = [];
223
+ try {
224
+ names = await (0, promises_1.readdir)(manifests);
225
+ }
226
+ catch {
227
+ return subject;
228
+ }
229
+ for (const name of names.filter((n) => n.endsWith('.json'))) {
230
+ const stored = JSON.parse(await (0, promises_1.readFile)((0, path_1.join)(manifests, name), 'utf8'));
231
+ subject.manifests.set((0, source_manifest_1.hashSource)(stored.manifest), stored);
232
+ for (const file of stored.manifest.files) {
233
+ subject.wanted.set(file.sha256, file.size);
234
+ }
235
+ }
236
+ for (const [digest, size] of subject.wanted) {
237
+ try {
238
+ await (0, promises_1.stat)(this.blob(key, digest));
239
+ subject.stored += size;
240
+ }
241
+ catch {
242
+ // announced, not sent yet
243
+ }
244
+ }
245
+ return subject;
246
+ }
247
+ }
248
+ exports.SourceStore = SourceStore;
249
+ async function exists(file) {
250
+ try {
251
+ await (0, promises_1.access)(file);
252
+ return true;
253
+ }
254
+ catch {
255
+ return false;
256
+ }
257
+ }
258
+ //# sourceMappingURL=sources.js.map
@@ -1,29 +1,85 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.redact = exports.registerSecretValue = void 0;
4
- // Secret values hammerkit has read, masked in everything it logs, including the
5
- // output it streams from a task or service. Logs are per line, so each line of
6
- // a multi-line value (a key file) is masked on its own. Values shorter than
7
- // MIN_LENGTH are left alone: masking them would mangle unrelated output.
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
- const values = new Set();
11
- function registerSecretValue(value) {
12
- for (const candidate of [value, ...value.split(/\r?\n/)]) {
13
- const trimmed = candidate.trim();
14
- if (trimmed.length >= MIN_LENGTH) {
15
- values.add(trimmed);
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.registerSecretValue = registerSecretValue;
20
- function redact(message) {
21
- if (values.size === 0) {
22
- return message;
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
- // longest first, so a value containing another is masked as a whole
25
- const sorted = [...values].sort((a, b) => b.length - a.length);
26
- return sorted.reduce((result, value) => result.split(value).join(MASK), message);
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.redact = redact;
84
+ exports.getEncodedForms = getEncodedForms;
29
85
  //# sourceMappingURL=redact.js.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hammerkit",
3
3
  "description": "Containerized build tool with incremental caching that runs the same build file locally, in Docker and on Kubernetes.",
4
- "version": "1.22.0",
4
+ "version": "1.24.0",
5
5
  "keywords": [
6
6
  "build",
7
7
  "build-tool",