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.
- package/build.schema.json +92 -2
- package/dist/cli.js +25 -0
- package/dist/executer/environment-mock.js +4 -1
- package/dist/executer/execute-command.js +7 -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 +2 -1
- package/dist/planner/work-item-status.js +3 -2
- package/dist/planner/work-secret.js +87 -10
- package/dist/program.js +4 -0
- package/dist/remote/bundle.js +59 -0
- package/dist/remote/collect-bundle.js +41 -0
- package/dist/remote/credentials.js +93 -0
- package/dist/remote/device-login.js +177 -0
- package/dist/remote/host-config.js +170 -0
- package/dist/remote/location.js +46 -0
- package/dist/remote/remote-commands.js +154 -0
- package/dist/remote/remote-work-commands.js +152 -0
- package/dist/remote/server-client.js +88 -0
- package/dist/remote/source-manifest.js +79 -0
- package/dist/remote/text-diff.js +89 -0
- package/dist/schema/build-file-base-task-schema.js +6 -0
- package/dist/schema/build-file-container-service-schema.js +2 -0
- package/dist/schema/build-file-schema.js +6 -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 +4 -0
- package/dist/schema/runner-schema.js +13 -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/server/approvals-api.js +99 -0
- package/dist/server/definitions-api.js +119 -0
- package/dist/server/definitions.js +248 -0
- package/dist/server/discovery.js +9 -0
- package/dist/server/executor.js +238 -0
- package/dist/server/git-checkout.js +63 -0
- package/dist/server/http.js +39 -0
- package/dist/server/oauth.js +226 -0
- package/dist/server/policy.js +90 -0
- package/dist/server/proposals-api.js +123 -0
- package/dist/server/proposals.js +126 -0
- package/dist/server/run-request.js +67 -0
- package/dist/server/runs.js +279 -0
- package/dist/server/server-command.js +43 -0
- package/dist/server/server-config.js +233 -0
- package/dist/server/server.js +338 -0
- package/dist/server/sources-api.js +106 -0
- package/dist/server/sources.js +258 -0
- package/dist/utils/redact.js +75 -19
- 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
|
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