hammerkit 1.23.0 → 1.25.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 (52) hide show
  1. package/build.schema.json +10 -0
  2. package/dist/cli.js +6 -0
  3. package/dist/executer/execute-command.js +7 -1
  4. package/dist/optimizer/get-cache-directory.js +5 -0
  5. package/dist/planner/utils/append-work-task.js +1 -0
  6. package/dist/planner/work-secret.js +6 -2
  7. package/dist/program.js +6 -0
  8. package/dist/remote/bundle.js +59 -0
  9. package/dist/remote/collect-bundle.js +41 -0
  10. package/dist/remote/credentials.js +93 -0
  11. package/dist/remote/device-login.js +177 -0
  12. package/dist/remote/host-config.js +170 -0
  13. package/dist/remote/location.js +46 -0
  14. package/dist/remote/remote-commands.js +158 -0
  15. package/dist/remote/remote-work-commands.js +224 -0
  16. package/dist/remote/server-client.js +88 -0
  17. package/dist/remote/source-manifest.js +79 -0
  18. package/dist/remote/text-diff.js +89 -0
  19. package/dist/schema/build-file-base-task-schema.js +4 -0
  20. package/dist/schema/build-file-schema.js +3 -0
  21. package/dist/schema/reference-parser.js +9 -2
  22. package/dist/schema/runner-schema.js +13 -0
  23. package/dist/secrets/provider-values.js +4 -0
  24. package/dist/server/accounts-api.js +42 -0
  25. package/dist/server/approvals-api.js +104 -0
  26. package/dist/server/definitions-api.js +163 -0
  27. package/dist/server/definitions.js +160 -0
  28. package/dist/server/discovery.js +9 -0
  29. package/dist/server/executor.js +237 -0
  30. package/dist/server/git-checkout.js +115 -0
  31. package/dist/server/http.js +39 -0
  32. package/dist/server/oauth.js +226 -0
  33. package/dist/server/plan.js +296 -0
  34. package/dist/server/policy.js +138 -0
  35. package/dist/server/proposals-api.js +145 -0
  36. package/dist/server/proposals.js +173 -0
  37. package/dist/server/repo-access.js +127 -0
  38. package/dist/server/repo.js +94 -0
  39. package/dist/server/run-environment.js +20 -0
  40. package/dist/server/run-plan.js +44 -0
  41. package/dist/server/run-request.js +85 -0
  42. package/dist/server/run-store.js +61 -0
  43. package/dist/server/run-worker-command.js +17 -0
  44. package/dist/server/run-worker-main.js +7 -0
  45. package/dist/server/run-worker.js +179 -0
  46. package/dist/server/runs.js +432 -0
  47. package/dist/server/server-command.js +61 -0
  48. package/dist/server/server-config.js +394 -0
  49. package/dist/server/server.js +434 -0
  50. package/dist/server/sources-api.js +106 -0
  51. package/dist/server/sources.js +258 -0
  52. package/package.json +1 -1
@@ -0,0 +1,432 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.unavailableExecutor = exports.RunRegistry = exports.RUN_ID_PATTERN = exports.QuotaError = exports.RunError = exports.isFinal = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const units_1 = require("../utils/units");
6
+ const FINAL_STATES = ['succeeded', 'failed', 'cancelled', 'rejected'];
7
+ function isFinal(state) {
8
+ return FINAL_STATES.includes(state);
9
+ }
10
+ exports.isFinal = isFinal;
11
+ // The server can not take the run now; `status` is what the caller is told
12
+ class RunError extends Error {
13
+ constructor() {
14
+ super(...arguments);
15
+ this.status = 503;
16
+ }
17
+ }
18
+ exports.RunError = RunError;
19
+ // A caller is over one of their limits, others may still submit
20
+ class QuotaError extends RunError {
21
+ constructor() {
22
+ super(...arguments);
23
+ this.status = 429;
24
+ }
25
+ }
26
+ exports.QuotaError = QuotaError;
27
+ exports.RUN_ID_PATTERN = /^run_[A-Za-z0-9_-]{16}$/;
28
+ // Where runs are kept while the server is up. Everything is scoped to the
29
+ // caller that submitted the run: the same id asked for by someone else is
30
+ // "not found", so ids can't be used to find out which runs exist.
31
+ class RunRegistry {
32
+ constructor(options) {
33
+ this.options = options;
34
+ this.entries = new Map();
35
+ this.queue = [];
36
+ this.running = 0;
37
+ this.closed = false;
38
+ this.maxConcurrent = options.maxConcurrent ?? 4;
39
+ this.keep = options.keep ?? 200;
40
+ this.maxEvents = options.maxEvents ?? 5000;
41
+ this.maxWaiting = options.maxWaiting ?? 100;
42
+ this.approvalTimeout = options.approvalTimeout ?? 60 * 60 * 1000;
43
+ this.now = options.now ?? Date.now;
44
+ this.maxLogBytes = options.maxLogBytes ?? Infinity;
45
+ }
46
+ /**
47
+ * Takes back the runs of a previous start. Those that waited for an approval wait
48
+ * again, for what is left of their time; those that were queued or running when the
49
+ * server stopped failed with it, and say so.
50
+ */
51
+ async restore() {
52
+ const store = this.options.store;
53
+ if (!store) {
54
+ return;
55
+ }
56
+ const { records, skipped } = await store.load();
57
+ skipped.forEach((name) => this.options.log?.(`skip run record ${name}: it can not be read`));
58
+ for (const { run, events } of records) {
59
+ const entry = {
60
+ run,
61
+ events,
62
+ listeners: new Set(),
63
+ abort: new AbortController(),
64
+ truncated: events.some((event) => event.type === 'truncated'),
65
+ bytes: 0,
66
+ saving: Promise.resolve(),
67
+ };
68
+ this.entries.set(run.id, entry);
69
+ if (run.state === 'awaiting-approval') {
70
+ const left = Date.parse(run.approvals?.[0]?.expiresAt ?? '') - this.now();
71
+ if (Number.isFinite(left) && left > 0) {
72
+ entry.timer = setTimeout(() => this.expire(entry), left);
73
+ entry.timer.unref();
74
+ }
75
+ else {
76
+ this.finish(entry, 'rejected', 'nobody approved it in time');
77
+ }
78
+ }
79
+ else if (run.state === 'queued' || run.state === 'running') {
80
+ this.finish(entry, 'failed', `the server stopped while the run was ${run.state}, submit it again`);
81
+ }
82
+ }
83
+ this.evict();
84
+ }
85
+ // A run that needs approvals (for the accounts named) waits for them, the others
86
+ // are queued at once.
87
+ submit(owner, spec, approvalAccounts = []) {
88
+ if (this.closed) {
89
+ throw new RunError('the server is shutting down');
90
+ }
91
+ const waiting = approvalAccounts.length > 0;
92
+ if (waiting && this.waiting().length >= this.maxWaiting) {
93
+ throw new RunError('too many runs are waiting for an approval');
94
+ }
95
+ this.checkLimits(owner, waiting);
96
+ const run = {
97
+ ...spec,
98
+ id: `run_${(0, crypto_1.randomBytes)(12).toString('base64url')}`,
99
+ owner: { iss: owner.iss, sub: owner.sub },
100
+ state: waiting ? 'awaiting-approval' : 'queued',
101
+ createdAt: this.timestamp(),
102
+ };
103
+ const entry = {
104
+ run,
105
+ events: [],
106
+ listeners: new Set(),
107
+ abort: new AbortController(),
108
+ truncated: false,
109
+ bytes: 0,
110
+ saving: Promise.resolve(),
111
+ };
112
+ this.entries.set(run.id, entry);
113
+ if (waiting) {
114
+ // before the first event, which is written down with the run
115
+ const expiresAt = new Date(this.now() + this.approvalTimeout).toISOString();
116
+ run.approvals = approvalAccounts.map((account) => ({ account, expiresAt }));
117
+ }
118
+ this.record(entry, { type: 'state', state: run.state });
119
+ if (waiting) {
120
+ entry.timer = setTimeout(() => this.expire(entry), this.approvalTimeout);
121
+ entry.timer.unref();
122
+ }
123
+ else {
124
+ this.queue.push(entry);
125
+ }
126
+ this.evict();
127
+ // what the caller is told is the run as accepted, even when it starts at once
128
+ const accepted = this.copy(run);
129
+ this.dispatch();
130
+ return accepted;
131
+ }
132
+ // `canUse` also lets who uses an account the run needed approved read it: they
133
+ // decided about it, so they may follow it and stop it.
134
+ get(owner, id, canUse) {
135
+ const entry = this.find(owner, id, canUse);
136
+ return entry ? this.copy(entry.run) : undefined;
137
+ }
138
+ // newest first
139
+ list(owner) {
140
+ return [...this.entries.values()]
141
+ .filter((entry) => this.owns(owner, entry))
142
+ .map((entry) => this.copy(entry.run))
143
+ .reverse();
144
+ }
145
+ // A queued run is cancelled at once; a running one when its executor stops.
146
+ cancel(owner, id, canUse) {
147
+ const entry = this.find(owner, id, canUse);
148
+ if (!entry) {
149
+ return undefined;
150
+ }
151
+ if (entry.run.state === 'queued') {
152
+ this.queue.splice(this.queue.indexOf(entry), 1);
153
+ this.finish(entry, 'cancelled');
154
+ }
155
+ else if (entry.run.state === 'awaiting-approval') {
156
+ this.finish(entry, 'cancelled');
157
+ }
158
+ else if (entry.run.state === 'running') {
159
+ entry.abort.abort();
160
+ }
161
+ return this.copy(entry.run);
162
+ }
163
+ // The runs that wait for an approval, of anybody, oldest first. Who may see
164
+ // them is for the caller to decide.
165
+ waiting() {
166
+ return [...this.entries.values()]
167
+ .filter((entry) => entry.run.state === 'awaiting-approval')
168
+ .map((entry) => this.copy(entry.run));
169
+ }
170
+ // One run that waits (or waited), if `canDecide` says the decider uses one of the
171
+ // accounts it still needs approved.
172
+ getForApproval(id, canDecide) {
173
+ const entry = this.entries.get(id);
174
+ if (!entry || !this.mayDecide(entry, canDecide)) {
175
+ return undefined;
176
+ }
177
+ return this.copy(entry.run);
178
+ }
179
+ // An approval covers the accounts the approver uses; the run is queued when all
180
+ // of its accounts are covered. A rejection by anybody who covers one of them ends
181
+ // it. Nobody decides on their own run.
182
+ decide(decider, id, outcome, canDecide, reason) {
183
+ const entry = this.entries.get(id);
184
+ if (!entry || !this.mayDecide(entry, canDecide)) {
185
+ return { status: 'not-found' };
186
+ }
187
+ if (this.owns(decider, entry)) {
188
+ return { status: 'own-run' };
189
+ }
190
+ if (entry.run.state !== 'awaiting-approval' || !entry.run.approvals) {
191
+ return { status: 'not-waiting', state: entry.run.state };
192
+ }
193
+ const by = { iss: decider.iss, sub: decider.sub };
194
+ const covered = entry.run.approvals.filter((approval) => !approval.approvedBy && canDecide(approval.account));
195
+ if (outcome === 'approved' && covered.length === 0) {
196
+ return { status: 'already-approved' };
197
+ }
198
+ const accounts = entry.run.approvals
199
+ .filter((approval) => canDecide(approval.account))
200
+ .map((approval) => approval.account);
201
+ this.record(entry, { type: 'approval', outcome, by, accounts, ...(reason ? { reason } : {}) });
202
+ if (outcome === 'rejected') {
203
+ this.finish(entry, 'rejected', `rejected by ${by.sub}${reason ? `: ${reason}` : ''}`);
204
+ return { status: 'ok', run: this.copy(entry.run) };
205
+ }
206
+ const approvedAt = this.timestamp();
207
+ for (const approval of covered) {
208
+ approval.approvedBy = by;
209
+ approval.approvedAt = approvedAt;
210
+ }
211
+ if (entry.run.approvals.some((approval) => !approval.approvedBy)) {
212
+ // some accounts still wait for somebody who uses them
213
+ return { status: 'ok', run: this.copy(entry.run) };
214
+ }
215
+ this.clearTimer(entry);
216
+ entry.run.state = 'queued';
217
+ this.record(entry, { type: 'state', state: 'queued' });
218
+ this.queue.push(entry);
219
+ const approved = this.copy(entry.run);
220
+ this.dispatch();
221
+ return { status: 'ok', run: approved };
222
+ }
223
+ // The events after `after` (0 for all), and a way to follow new ones. The
224
+ // listener is not called for a run that is already over.
225
+ watch(owner, id, after, listener, canUse) {
226
+ const entry = this.find(owner, id, canUse);
227
+ if (!entry) {
228
+ return undefined;
229
+ }
230
+ const events = entry.events.filter((event) => event.seq > after);
231
+ const finished = FINAL_STATES.includes(entry.run.state);
232
+ if (!finished) {
233
+ entry.listeners.add(listener);
234
+ }
235
+ return { events, finished, stop: () => entry.listeners.delete(listener) };
236
+ }
237
+ // Stops accepting runs and aborts the ones in flight.
238
+ async close() {
239
+ this.closed = true;
240
+ for (const entry of this.queue.splice(0)) {
241
+ this.finish(entry, 'cancelled');
242
+ }
243
+ for (const entry of this.entries.values()) {
244
+ if (entry.run.state === 'awaiting-approval') {
245
+ if (this.options.store) {
246
+ // the record keeps it waiting, the next start takes it back
247
+ this.clearTimer(entry);
248
+ }
249
+ else {
250
+ this.finish(entry, 'cancelled');
251
+ }
252
+ }
253
+ }
254
+ for (const entry of this.entries.values()) {
255
+ if (entry.run.state === 'running') {
256
+ entry.abort.abort();
257
+ }
258
+ }
259
+ while (this.running > 0) {
260
+ await new Promise((resolve) => setTimeout(resolve, 10));
261
+ }
262
+ // the runs that were stopped are written down before the server is gone
263
+ await Promise.all([...this.entries.values()].map((entry) => entry.saving));
264
+ }
265
+ find(owner, id, canUse) {
266
+ const entry = this.entries.get(id);
267
+ return entry && (this.owns(owner, entry) || (canUse && this.mayDecide(entry, canUse))) ? entry : undefined;
268
+ }
269
+ // Whoever uses an account the run needed approved is to see it and decide.
270
+ mayDecide(entry, canDecide) {
271
+ return (entry.run.approvals ?? []).some((approval) => canDecide(approval.account));
272
+ }
273
+ expire(entry) {
274
+ if (entry.run.state === 'awaiting-approval') {
275
+ this.finish(entry, 'rejected', 'nobody approved it in time');
276
+ }
277
+ }
278
+ clearTimer(entry) {
279
+ if (entry.timer) {
280
+ clearTimeout(entry.timer);
281
+ entry.timer = undefined;
282
+ }
283
+ }
284
+ owns(owner, entry) {
285
+ return entry.run.owner.iss === owner.iss && entry.run.owner.sub === owner.sub;
286
+ }
287
+ copy(run) {
288
+ return {
289
+ ...run,
290
+ tasks: [...run.tasks],
291
+ definition: { ...run.definition },
292
+ source: { ...run.source },
293
+ options: JSON.parse(JSON.stringify(run.options)),
294
+ owner: { ...run.owner },
295
+ accounts: [...run.accounts],
296
+ ...(run.approvals ? { approvals: JSON.parse(JSON.stringify(run.approvals)) } : {}),
297
+ };
298
+ }
299
+ timestamp() {
300
+ return new Date(this.now()).toISOString();
301
+ }
302
+ // What a caller has now, against what they may have.
303
+ checkLimits(owner, waiting) {
304
+ const limits = this.options.limits;
305
+ if (!limits) {
306
+ return;
307
+ }
308
+ const mine = [...this.entries.values()].filter((entry) => this.owns(owner, entry));
309
+ const count = (state) => mine.filter((entry) => entry.run.state === state).length;
310
+ if (waiting && count('awaiting-approval') >= limits.waiting) {
311
+ throw new QuotaError(`you have ${limits.waiting} runs waiting for an approval, wait for a decision or cancel one`);
312
+ }
313
+ if (!waiting && count('queued') >= limits.queued) {
314
+ throw new QuotaError(`you have ${limits.queued} runs in the queue, wait for one to start or cancel one`);
315
+ }
316
+ }
317
+ runningOf(owner) {
318
+ return [...this.entries.values()].filter((entry) => entry.run.state === 'running' && this.owns(owner, entry)).length;
319
+ }
320
+ // The first run in the queue whose caller has a place left: one caller with many runs
321
+ // does not keep the others from the machine.
322
+ dispatch() {
323
+ while (this.running < this.maxConcurrent) {
324
+ const index = this.queue.findIndex((entry) => !this.options.limits || this.runningOf(entry.run.owner) < this.options.limits.running);
325
+ if (index < 0) {
326
+ return;
327
+ }
328
+ const [entry] = this.queue.splice(index, 1);
329
+ this.running++;
330
+ this.execute(entry).finally(() => {
331
+ this.running--;
332
+ this.dispatch();
333
+ });
334
+ }
335
+ }
336
+ async execute(entry) {
337
+ entry.run.state = 'running';
338
+ entry.run.startedAt = this.timestamp();
339
+ this.record(entry, { type: 'state', state: 'running' });
340
+ const limit = this.options.maxDuration;
341
+ const timer = limit
342
+ ? setTimeout(() => {
343
+ entry.timedOut = true;
344
+ entry.abort.abort();
345
+ }, limit)
346
+ : undefined;
347
+ timer?.unref();
348
+ try {
349
+ await this.options.executor.execute(this.copy(entry.run), {
350
+ emit: (event) => this.record(entry, event),
351
+ signal: entry.abort.signal,
352
+ });
353
+ this.finish(entry, entry.timedOut ? 'failed' : entry.abort.signal.aborted ? 'cancelled' : 'succeeded', entry.timedOut ? this.tooLong() : undefined);
354
+ }
355
+ catch (e) {
356
+ const cancelled = entry.abort.signal.aborted && !entry.timedOut;
357
+ this.finish(entry, cancelled ? 'cancelled' : 'failed', entry.timedOut ? this.tooLong() : cancelled ? undefined : e instanceof Error ? e.message : String(e));
358
+ }
359
+ finally {
360
+ clearTimeout(timer);
361
+ }
362
+ }
363
+ tooLong() {
364
+ return `the run took longer than ${(0, units_1.formatDuration)(this.options.maxDuration)} and was stopped`;
365
+ }
366
+ finish(entry, state, error) {
367
+ this.clearTimer(entry);
368
+ entry.run.state = state;
369
+ entry.run.finishedAt = this.timestamp();
370
+ if (error) {
371
+ entry.run.error = error;
372
+ }
373
+ this.record(entry, { type: 'state', state, ...(error ? { error } : {}) }, true);
374
+ entry.listeners.clear();
375
+ this.evict();
376
+ }
377
+ record(entry, event, force = false) {
378
+ // an executor that keeps talking can't fill the memory of the server or its disk; the
379
+ // state changes, which are few, are always kept
380
+ if (!force && event.type !== 'state') {
381
+ const size = event.type === 'log' ? Buffer.byteLength(event.text) + 64 : 64;
382
+ if (entry.events.length >= this.maxEvents || entry.bytes + size > this.maxLogBytes) {
383
+ if (!entry.truncated) {
384
+ entry.truncated = true;
385
+ this.push(entry, { type: 'truncated' });
386
+ }
387
+ return;
388
+ }
389
+ entry.bytes += size;
390
+ }
391
+ this.push(entry, event);
392
+ }
393
+ push(entry, event) {
394
+ const stored = { ...event, seq: entry.events.length + 1, time: this.timestamp() };
395
+ entry.events.push(stored);
396
+ for (const listener of entry.listeners) {
397
+ listener(stored);
398
+ }
399
+ // what a restart must not lose: the runs that wait, and how a run ended. A running
400
+ // run is not kept up to date, it fails with the server.
401
+ if (event.type === 'state' || event.type === 'approval') {
402
+ this.persist(entry);
403
+ }
404
+ }
405
+ persist(entry) {
406
+ const store = this.options.store;
407
+ if (!store) {
408
+ return;
409
+ }
410
+ const record = { run: this.copy(entry.run), events: [...entry.events] };
411
+ entry.saving = entry.saving
412
+ .then(() => store.save(record))
413
+ .catch((e) => this.options.log?.(`could not keep run ${record.run.id}: ${e instanceof Error ? e.message : e}`));
414
+ }
415
+ // Only finished runs are dropped, oldest first.
416
+ evict() {
417
+ const finished = [...this.entries.values()].filter((entry) => FINAL_STATES.includes(entry.run.state));
418
+ for (const entry of finished.slice(0, Math.max(0, finished.length - this.keep))) {
419
+ this.entries.delete(entry.run.id);
420
+ void entry.saving.then(() => this.options.store?.remove(entry.run.id)).catch(() => undefined);
421
+ }
422
+ }
423
+ }
424
+ exports.RunRegistry = RunRegistry;
425
+ // Used until a backend is wired in: runs are accepted, recorded and failed with
426
+ // a reason, so the API can be used and tested without pretending to execute.
427
+ exports.unavailableExecutor = {
428
+ async execute() {
429
+ throw new Error('this server has no backend that can execute runs yet');
430
+ },
431
+ };
432
+ //# sourceMappingURL=runs.js.map
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerServerCommand = void 0;
4
+ const commander_1 = require("commander");
5
+ const log_1 = require("../log");
6
+ const server_config_1 = require("./server-config");
7
+ const server_1 = require("./server");
8
+ // Runs this machine as a hammerkit server until interrupted. Like `remote`, it
9
+ // only needs its own config file, not a build file.
10
+ function registerServerCommand(program, environment) {
11
+ program
12
+ .command('server')
13
+ .description('serve this machine to remote runs (experimental)')
14
+ .addOption(new commander_1.Option('-c, --config <file>', 'server config file').env('HAMMERKIT_SERVER_CONFIG'))
15
+ .action(async (options) => {
16
+ try {
17
+ if (!options.config) {
18
+ throw new Error('pass the server config with --config or HAMMERKIT_SERVER_CONFIG');
19
+ }
20
+ const config = await (0, server_config_1.readServerConfig)(environment, options.config);
21
+ const server = await (0, server_1.startServer)(config, environment, {
22
+ log: (line) => environment.stdout.write(`${line}\n`),
23
+ });
24
+ environment.stdout.write(`hammerkit server listening on ${server.url}\n`);
25
+ // SIGHUP reads the entitlements again, so who may do what changes without
26
+ // a restart; the rest of the config needs one
27
+ const reload = async () => {
28
+ try {
29
+ const result = server.reload(await (0, server_config_1.readServerConfig)(environment, options.config));
30
+ if (result.restart.length > 0) {
31
+ environment.stdout.write(`changed but not reloaded, restart for: ${result.restart.join(', ')}\n`);
32
+ }
33
+ }
34
+ catch (e) {
35
+ environment.stdout.write(`entitlements were not reloaded: ${(0, log_1.getErrorMessage)(e)}\n`);
36
+ }
37
+ };
38
+ const onReload = () => void reload();
39
+ if (process.platform !== 'win32') {
40
+ process.on('SIGHUP', onReload);
41
+ }
42
+ await new Promise((resolve) => {
43
+ if (environment.abortCtrl.signal.aborted) {
44
+ resolve();
45
+ }
46
+ environment.abortCtrl.signal.addEventListener('abort', () => resolve(), { once: true });
47
+ });
48
+ process.off('SIGHUP', onReload);
49
+ await server.close();
50
+ environment.stdout.write('hammerkit server stopped\n');
51
+ }
52
+ catch (e) {
53
+ if (e instanceof commander_1.CommanderError) {
54
+ throw e;
55
+ }
56
+ program.error(`Server was not successful: ${(0, log_1.getErrorMessage)(e)}`, { exitCode: 1 });
57
+ }
58
+ });
59
+ }
60
+ exports.registerServerCommand = registerServerCommand;
61
+ //# sourceMappingURL=server-command.js.map