hammerkit 1.23.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 +10 -0
- package/dist/cli.js +6 -0
- package/dist/executer/execute-command.js +7 -1
- package/dist/planner/utils/append-work-task.js +1 -0
- 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 +4 -0
- package/dist/schema/build-file-schema.js +3 -0
- package/dist/schema/reference-parser.js +1 -0
- package/dist/schema/runner-schema.js +13 -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/package.json +1 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.handleProposals = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const bundle_1 = require("../remote/bundle");
|
|
6
|
+
const definitions_1 = require("./definitions");
|
|
7
|
+
const http_1 = require("./http");
|
|
8
|
+
const oauth_1 = require("./oauth");
|
|
9
|
+
const policy_1 = require("./policy");
|
|
10
|
+
const server_config_1 = require("./server-config");
|
|
11
|
+
const decisionSchema = zod_1.z.object({ reason: zod_1.z.string().max(500).optional() }).strict();
|
|
12
|
+
const same = (a, b) => a.iss === b.iss && a.sub === b.sub;
|
|
13
|
+
// What the caller is shown of a proposal: what it is, who proposed it and the
|
|
14
|
+
// version it would replace, so the files can be compared.
|
|
15
|
+
function toView(store, proposal, principal, grant) {
|
|
16
|
+
return {
|
|
17
|
+
...proposal,
|
|
18
|
+
base: store.find(proposal.name, 'latest')?.hash ?? null,
|
|
19
|
+
mine: same(proposal.proposedBy, principal),
|
|
20
|
+
// approving needs the permission to publish the name, and somebody else's proposal
|
|
21
|
+
canDecide: proposal.state === 'waiting' && (0, policy_1.mayPublish)(grant, proposal.name) && !same(proposal.proposedBy, principal),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* New versions of build files that wait for an approval. Whoever may only
|
|
26
|
+
* propose a version (`propose` on an entitlement) puts one here; someone who may
|
|
27
|
+
* `publish` that name approves it, and it is published then.
|
|
28
|
+
*
|
|
29
|
+
* GET /v1/proposals the ones the caller proposed or may decide
|
|
30
|
+
* GET /v1/proposals/<name>/<hash> one of them
|
|
31
|
+
* GET /v1/proposals/<name>/<hash>/files its files, for who decides
|
|
32
|
+
* POST /v1/proposals/<name>/<hash>/approve publish it (body: optional {reason})
|
|
33
|
+
* POST /v1/proposals/<name>/<hash>/reject drop it
|
|
34
|
+
*/
|
|
35
|
+
async function handleProposals(config, store, proposals, log, principal, req, res, url) {
|
|
36
|
+
const match = /^\/v1\/proposals(?:\/([^/]+)\/([^/]+)(?:\/(files|approve|reject))?)?$/.exec(url.pathname);
|
|
37
|
+
if (!match) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
const [, name, hash, action] = match;
|
|
41
|
+
const grant = (0, policy_1.getGrant)(config.entitlements, principal);
|
|
42
|
+
// what the caller neither proposed nor may decide does not exist for them
|
|
43
|
+
const visible = (proposal) => same(proposal.proposedBy, principal) || (0, policy_1.mayPublish)(grant, proposal.name);
|
|
44
|
+
if (name === undefined) {
|
|
45
|
+
if (req.method !== 'GET') {
|
|
46
|
+
(0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
(0, http_1.send)(res, 200, {
|
|
50
|
+
proposals: proposals
|
|
51
|
+
.all()
|
|
52
|
+
.filter(visible)
|
|
53
|
+
.map((proposal) => toView(store, proposal, principal, grant)),
|
|
54
|
+
});
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
if (!server_config_1.definitionName.safeParse(name).success || !bundle_1.HASH_PATTERN.test(hash)) {
|
|
58
|
+
(0, http_1.send)(res, 404, { error: 'not found' });
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
const proposal = proposals.get(name, hash);
|
|
62
|
+
if (!action) {
|
|
63
|
+
if (req.method !== 'GET') {
|
|
64
|
+
(0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
proposal && visible(proposal)
|
|
68
|
+
? (0, http_1.send)(res, 200, toView(store, proposal, principal, grant))
|
|
69
|
+
: (0, http_1.send)(res, 404, { error: 'not found' });
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
if (action === 'files') {
|
|
73
|
+
if (req.method !== 'GET') {
|
|
74
|
+
(0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
// the files are for who decides, and for who wrote them
|
|
78
|
+
const bundle = proposal && visible(proposal) ? proposals.bundle(name, hash) : undefined;
|
|
79
|
+
bundle ? (0, http_1.send)(res, 200, bundle) : (0, http_1.send)(res, 404, { error: 'not found' });
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
if (req.method !== 'POST') {
|
|
83
|
+
(0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'POST' });
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
const body = decisionSchema.safeParse(req.headers['content-type'] ? await (0, http_1.readJson)(req) : {});
|
|
87
|
+
if (!body.success) {
|
|
88
|
+
const issue = body.error.issues[0];
|
|
89
|
+
(0, http_1.send)(res, 400, { error: `${issue.path.join('.') || 'request'}: ${issue.message}` });
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
const outcome = action === 'approve' ? 'approved' : 'rejected';
|
|
93
|
+
try {
|
|
94
|
+
const result = await proposals.decide(principal, name, hash, outcome, (n) => (0, policy_1.mayPublish)(grant, n), async (bundle) => {
|
|
95
|
+
const proposed = proposals.get(name, hash);
|
|
96
|
+
await store.publish(name, bundle, proposed?.proposedBy ?? principal, principal);
|
|
97
|
+
}, body.data.reason);
|
|
98
|
+
log(`${action} proposal=${name}@${hash} iss=${principal.iss} sub=${principal.sub} result=${result.status}${body.data.reason ? ` reason=${(0, oauth_1.quote)(body.data.reason)}` : ''}`);
|
|
99
|
+
switch (result.status) {
|
|
100
|
+
case 'ok':
|
|
101
|
+
(0, http_1.send)(res, 200, toView(store, result.proposal, principal, grant));
|
|
102
|
+
break;
|
|
103
|
+
case 'own-proposal':
|
|
104
|
+
(0, http_1.send)(res, 403, { error: 'a version is approved by somebody else than who proposed it' });
|
|
105
|
+
break;
|
|
106
|
+
case 'not-waiting':
|
|
107
|
+
(0, http_1.send)(res, 409, { error: `the proposal is ${result.state}, it does not wait for an approval` });
|
|
108
|
+
break;
|
|
109
|
+
default:
|
|
110
|
+
(0, http_1.send)(res, 404, { error: 'not found' });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
if (e instanceof definitions_1.DefinitionError) {
|
|
115
|
+
(0, http_1.send)(res, 400, { error: `the build file does not load: ${e.message}` });
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
throw e;
|
|
119
|
+
}
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
exports.handleProposals = handleProposals;
|
|
123
|
+
//# sourceMappingURL=proposals-api.js.map
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ProposalRegistry = exports.ProposalError = void 0;
|
|
4
|
+
class ProposalError extends Error {
|
|
5
|
+
}
|
|
6
|
+
exports.ProposalError = ProposalError;
|
|
7
|
+
// The proposals of the server, in memory: a restart forgets what waits, and
|
|
8
|
+
// whoever proposed it proposes it again. Nothing in here is runnable.
|
|
9
|
+
class ProposalRegistry {
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
this.entries = new Map();
|
|
12
|
+
this.closed = false;
|
|
13
|
+
this.maxWaiting = options.maxWaiting ?? 100;
|
|
14
|
+
this.timeout = options.timeout ?? 60 * 60 * 1000;
|
|
15
|
+
this.keep = options.keep ?? 100;
|
|
16
|
+
this.now = options.now ?? Date.now;
|
|
17
|
+
}
|
|
18
|
+
// Proposing what already waits is not an error and changes nothing.
|
|
19
|
+
propose(version, bundle, proposer) {
|
|
20
|
+
if (this.closed) {
|
|
21
|
+
throw new ProposalError('the server is shutting down');
|
|
22
|
+
}
|
|
23
|
+
const key = this.key(version.name, version.hash);
|
|
24
|
+
const existing = this.entries.get(key);
|
|
25
|
+
if (existing && existing.proposal.state === 'waiting') {
|
|
26
|
+
return { proposal: { ...existing.proposal }, created: false };
|
|
27
|
+
}
|
|
28
|
+
if (this.waiting().length >= this.maxWaiting) {
|
|
29
|
+
throw new ProposalError('too many build file versions are waiting for an approval');
|
|
30
|
+
}
|
|
31
|
+
const proposal = {
|
|
32
|
+
name: version.name,
|
|
33
|
+
hash: version.hash,
|
|
34
|
+
entry: version.entry,
|
|
35
|
+
tasks: version.tasks,
|
|
36
|
+
services: version.services,
|
|
37
|
+
proposedBy: { iss: proposer.iss, sub: proposer.sub },
|
|
38
|
+
createdAt: new Date(this.now()).toISOString(),
|
|
39
|
+
expiresAt: new Date(this.now() + this.timeout).toISOString(),
|
|
40
|
+
state: 'waiting',
|
|
41
|
+
};
|
|
42
|
+
const entry = { proposal, bundle };
|
|
43
|
+
entry.timer = setTimeout(() => this.settle(entry, 'expired'), this.timeout);
|
|
44
|
+
entry.timer.unref();
|
|
45
|
+
this.entries.delete(key);
|
|
46
|
+
this.entries.set(key, entry);
|
|
47
|
+
this.evict();
|
|
48
|
+
return { proposal: { ...proposal }, created: true };
|
|
49
|
+
}
|
|
50
|
+
waiting() {
|
|
51
|
+
return [...this.entries.values()].filter((e) => e.proposal.state === 'waiting').map((e) => ({ ...e.proposal }));
|
|
52
|
+
}
|
|
53
|
+
// Everything kept, newest first.
|
|
54
|
+
all() {
|
|
55
|
+
return [...this.entries.values()].map((e) => ({ ...e.proposal })).reverse();
|
|
56
|
+
}
|
|
57
|
+
get(name, hash) {
|
|
58
|
+
const entry = this.entries.get(this.key(name, hash));
|
|
59
|
+
return entry ? { ...entry.proposal } : undefined;
|
|
60
|
+
}
|
|
61
|
+
// The files of one that still waits, for whoever decides on it.
|
|
62
|
+
bundle(name, hash) {
|
|
63
|
+
return this.entries.get(this.key(name, hash))?.bundle;
|
|
64
|
+
}
|
|
65
|
+
// An approval publishes the files first (`publish`), and only then is the
|
|
66
|
+
// proposal approved, so it never claims a version that is not there. Nobody
|
|
67
|
+
// decides on their own proposal.
|
|
68
|
+
async decide(decider, name, hash, outcome, mayDecide, publish, reason) {
|
|
69
|
+
const entry = this.entries.get(this.key(name, hash));
|
|
70
|
+
if (!entry || !mayDecide(name)) {
|
|
71
|
+
return { status: 'not-found' };
|
|
72
|
+
}
|
|
73
|
+
if (entry.proposal.proposedBy.iss === decider.iss && entry.proposal.proposedBy.sub === decider.sub) {
|
|
74
|
+
return { status: 'own-proposal' };
|
|
75
|
+
}
|
|
76
|
+
if (entry.proposal.state !== 'waiting' || !entry.bundle) {
|
|
77
|
+
return { status: 'not-waiting', state: entry.proposal.state };
|
|
78
|
+
}
|
|
79
|
+
if (outcome === 'approved') {
|
|
80
|
+
await publish(entry.bundle);
|
|
81
|
+
if (entry.proposal.state !== 'waiting') {
|
|
82
|
+
return { status: 'not-waiting', state: entry.proposal.state };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
this.settle(entry, outcome, { iss: decider.iss, sub: decider.sub }, reason);
|
|
86
|
+
return { status: 'ok', proposal: { ...entry.proposal } };
|
|
87
|
+
}
|
|
88
|
+
close() {
|
|
89
|
+
this.closed = true;
|
|
90
|
+
for (const entry of this.entries.values()) {
|
|
91
|
+
if (entry.timer) {
|
|
92
|
+
clearTimeout(entry.timer);
|
|
93
|
+
entry.timer = undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
settle(entry, state, by, reason) {
|
|
98
|
+
if (entry.proposal.state !== 'waiting') {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (entry.timer) {
|
|
102
|
+
clearTimeout(entry.timer);
|
|
103
|
+
entry.timer = undefined;
|
|
104
|
+
}
|
|
105
|
+
entry.proposal.state = state;
|
|
106
|
+
entry.proposal.decidedAt = new Date(this.now()).toISOString();
|
|
107
|
+
if (by) {
|
|
108
|
+
entry.proposal.decidedBy = by;
|
|
109
|
+
}
|
|
110
|
+
if (reason) {
|
|
111
|
+
entry.proposal.reason = reason;
|
|
112
|
+
}
|
|
113
|
+
entry.bundle = undefined;
|
|
114
|
+
}
|
|
115
|
+
evict() {
|
|
116
|
+
const decided = [...this.entries.entries()].filter(([, e]) => e.proposal.state !== 'waiting');
|
|
117
|
+
for (const [key] of decided.slice(0, Math.max(0, decided.length - this.keep))) {
|
|
118
|
+
this.entries.delete(key);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
key(name, hash) {
|
|
122
|
+
return `${name}@${hash}`;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
exports.ProposalRegistry = ProposalRegistry;
|
|
126
|
+
//# sourceMappingURL=proposals.js.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runRequestSchema = exports.runOptionsSchema = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const units_1 = require("../utils/units");
|
|
6
|
+
const bundle_1 = require("../remote/bundle");
|
|
7
|
+
const server_config_1 = require("./server-config");
|
|
8
|
+
const name = zod_1.z.string().min(1).max(200);
|
|
9
|
+
// What `POST /v1/runs` takes. Who is asking, which accounts they may use and
|
|
10
|
+
// where code may come from is decided from the token, never from this body.
|
|
11
|
+
const duration = zod_1.z.string().refine((value) => {
|
|
12
|
+
try {
|
|
13
|
+
(0, units_1.parseDuration)(value);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}, 'expected a duration like 30s, 5m or 1h30m');
|
|
20
|
+
// The settings of `hammerkit run` that make sense for a run on a server. What
|
|
21
|
+
// the server limits itself (workers, resources) and what only a terminal
|
|
22
|
+
// needs (watch, log mode) are left out.
|
|
23
|
+
exports.runOptionsSchema = zod_1.z
|
|
24
|
+
.object({
|
|
25
|
+
// as --env
|
|
26
|
+
env: name.optional(),
|
|
27
|
+
// as --filter and --exclude: labels
|
|
28
|
+
filter: zod_1.z.array(name).max(20).optional(),
|
|
29
|
+
exclude: zod_1.z.array(name).max(20).optional(),
|
|
30
|
+
// as --cache
|
|
31
|
+
cache: zod_1.z.enum(['checksum', 'modify-date', 'none']).optional(),
|
|
32
|
+
// as --cache-read-only
|
|
33
|
+
cacheReadOnly: zod_1.z.boolean().optional(),
|
|
34
|
+
// as --timeout
|
|
35
|
+
timeout: duration.optional(),
|
|
36
|
+
// as --no-skip-deps when false
|
|
37
|
+
skipDeps: zod_1.z.boolean().optional(),
|
|
38
|
+
})
|
|
39
|
+
.strict();
|
|
40
|
+
exports.runRequestSchema = zod_1.z
|
|
41
|
+
.object({
|
|
42
|
+
// the published build file to run, and the version of it: a hash, or the
|
|
43
|
+
// latest when left out. The server answers with the hash it chose.
|
|
44
|
+
definition: zod_1.z
|
|
45
|
+
.object({
|
|
46
|
+
name: server_config_1.definitionName,
|
|
47
|
+
hash: zod_1.z.union([zod_1.z.literal('latest'), zod_1.z.string().regex(bundle_1.HASH_PATTERN)]).default('latest'),
|
|
48
|
+
})
|
|
49
|
+
.strict(),
|
|
50
|
+
// tasks by their name in the build file, as on the command line; labels in
|
|
51
|
+
// the options can select tasks instead
|
|
52
|
+
tasks: zod_1.z.array(name).max(100).default([]),
|
|
53
|
+
// the service account to use; the caller's default when left out
|
|
54
|
+
account: name.optional(),
|
|
55
|
+
source: zod_1.z.discriminatedUnion('type', [
|
|
56
|
+
zod_1.z.object({ type: zod_1.z.literal('ref'), ref: name }).strict(),
|
|
57
|
+
// the name of a tree announced with POST /v1/sources
|
|
58
|
+
zod_1.z.object({ type: zod_1.z.literal('upload'), snapshot: zod_1.z.string().regex(bundle_1.HASH_PATTERN) }).strict(),
|
|
59
|
+
]),
|
|
60
|
+
options: exports.runOptionsSchema.default({}),
|
|
61
|
+
})
|
|
62
|
+
.strict()
|
|
63
|
+
.refine((request) => request.tasks.length > 0 || (request.options.filter ?? []).length > 0, {
|
|
64
|
+
message: 'name at least one task, or filter by labels',
|
|
65
|
+
path: ['tasks'],
|
|
66
|
+
});
|
|
67
|
+
//# sourceMappingURL=run-request.js.map
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.unavailableExecutor = exports.RunRegistry = exports.RUN_ID_PATTERN = exports.RunError = exports.isFinal = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const FINAL_STATES = ['succeeded', 'failed', 'cancelled', 'rejected'];
|
|
6
|
+
function isFinal(state) {
|
|
7
|
+
return FINAL_STATES.includes(state);
|
|
8
|
+
}
|
|
9
|
+
exports.isFinal = isFinal;
|
|
10
|
+
class RunError extends Error {
|
|
11
|
+
}
|
|
12
|
+
exports.RunError = RunError;
|
|
13
|
+
exports.RUN_ID_PATTERN = /^run_[A-Za-z0-9_-]{16}$/;
|
|
14
|
+
// Where runs are kept while the server is up. Everything is scoped to the
|
|
15
|
+
// caller that submitted the run: the same id asked for by someone else is
|
|
16
|
+
// "not found", so ids can't be used to find out which runs exist.
|
|
17
|
+
class RunRegistry {
|
|
18
|
+
constructor(options) {
|
|
19
|
+
this.options = options;
|
|
20
|
+
this.entries = new Map();
|
|
21
|
+
this.queue = [];
|
|
22
|
+
this.running = 0;
|
|
23
|
+
this.closed = false;
|
|
24
|
+
this.maxConcurrent = options.maxConcurrent ?? 4;
|
|
25
|
+
this.keep = options.keep ?? 200;
|
|
26
|
+
this.maxEvents = options.maxEvents ?? 5000;
|
|
27
|
+
this.maxWaiting = options.maxWaiting ?? 100;
|
|
28
|
+
this.approvalTimeout = options.approvalTimeout ?? 60 * 60 * 1000;
|
|
29
|
+
this.now = options.now ?? Date.now;
|
|
30
|
+
}
|
|
31
|
+
// A run that needs an approval waits for it, the others are queued at once.
|
|
32
|
+
submit(owner, spec, needsApproval = false) {
|
|
33
|
+
if (this.closed) {
|
|
34
|
+
throw new RunError('the server is shutting down');
|
|
35
|
+
}
|
|
36
|
+
const waiting = needsApproval;
|
|
37
|
+
if (waiting && this.waiting().length >= this.maxWaiting) {
|
|
38
|
+
throw new RunError('too many runs are waiting for an approval');
|
|
39
|
+
}
|
|
40
|
+
const run = {
|
|
41
|
+
...spec,
|
|
42
|
+
id: `run_${(0, crypto_1.randomBytes)(12).toString('base64url')}`,
|
|
43
|
+
owner: { iss: owner.iss, sub: owner.sub },
|
|
44
|
+
state: waiting ? 'awaiting-approval' : 'queued',
|
|
45
|
+
createdAt: this.timestamp(),
|
|
46
|
+
};
|
|
47
|
+
const entry = { run, events: [], listeners: new Set(), abort: new AbortController(), truncated: false };
|
|
48
|
+
this.entries.set(run.id, entry);
|
|
49
|
+
this.record(entry, { type: 'state', state: run.state });
|
|
50
|
+
if (waiting) {
|
|
51
|
+
run.approval = {
|
|
52
|
+
expiresAt: new Date(this.now() + this.approvalTimeout).toISOString(),
|
|
53
|
+
};
|
|
54
|
+
entry.timer = setTimeout(() => this.expire(entry), this.approvalTimeout);
|
|
55
|
+
entry.timer.unref();
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
this.queue.push(entry);
|
|
59
|
+
}
|
|
60
|
+
this.evict();
|
|
61
|
+
// what the caller is told is the run as accepted, even when it starts at once
|
|
62
|
+
const accepted = this.copy(run);
|
|
63
|
+
this.dispatch();
|
|
64
|
+
return accepted;
|
|
65
|
+
}
|
|
66
|
+
get(owner, id) {
|
|
67
|
+
const entry = this.find(owner, id);
|
|
68
|
+
return entry ? this.copy(entry.run) : undefined;
|
|
69
|
+
}
|
|
70
|
+
// newest first
|
|
71
|
+
list(owner) {
|
|
72
|
+
return [...this.entries.values()]
|
|
73
|
+
.filter((entry) => this.owns(owner, entry))
|
|
74
|
+
.map((entry) => this.copy(entry.run))
|
|
75
|
+
.reverse();
|
|
76
|
+
}
|
|
77
|
+
// A queued run is cancelled at once; a running one when its executor stops.
|
|
78
|
+
cancel(owner, id) {
|
|
79
|
+
const entry = this.find(owner, id);
|
|
80
|
+
if (!entry) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
if (entry.run.state === 'queued') {
|
|
84
|
+
this.queue.splice(this.queue.indexOf(entry), 1);
|
|
85
|
+
this.finish(entry, 'cancelled');
|
|
86
|
+
}
|
|
87
|
+
else if (entry.run.state === 'awaiting-approval') {
|
|
88
|
+
this.finish(entry, 'cancelled');
|
|
89
|
+
}
|
|
90
|
+
else if (entry.run.state === 'running') {
|
|
91
|
+
entry.abort.abort();
|
|
92
|
+
}
|
|
93
|
+
return this.copy(entry.run);
|
|
94
|
+
}
|
|
95
|
+
// The runs that wait for an approval, of anybody, oldest first. Who may see
|
|
96
|
+
// them is for the caller to decide.
|
|
97
|
+
waiting() {
|
|
98
|
+
return [...this.entries.values()]
|
|
99
|
+
.filter((entry) => entry.run.state === 'awaiting-approval')
|
|
100
|
+
.map((entry) => this.copy(entry.run));
|
|
101
|
+
}
|
|
102
|
+
// One run that waits (or waited), if `canDecide` says the decider is admin of
|
|
103
|
+
// its account.
|
|
104
|
+
getForApproval(id, canDecide) {
|
|
105
|
+
const entry = this.entries.get(id);
|
|
106
|
+
if (!entry || !this.mayDecide(entry, canDecide)) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
return this.copy(entry.run);
|
|
110
|
+
}
|
|
111
|
+
// An approval queues the run, a rejection ends it. Nobody decides on their own run.
|
|
112
|
+
decide(decider, id, outcome, canDecide, reason) {
|
|
113
|
+
const entry = this.entries.get(id);
|
|
114
|
+
if (!entry || !this.mayDecide(entry, canDecide)) {
|
|
115
|
+
return { status: 'not-found' };
|
|
116
|
+
}
|
|
117
|
+
if (this.owns(decider, entry)) {
|
|
118
|
+
return { status: 'own-run' };
|
|
119
|
+
}
|
|
120
|
+
if (entry.run.state !== 'awaiting-approval' || !entry.run.approval) {
|
|
121
|
+
return { status: 'not-waiting', state: entry.run.state };
|
|
122
|
+
}
|
|
123
|
+
const by = { iss: decider.iss, sub: decider.sub };
|
|
124
|
+
this.record(entry, { type: 'approval', outcome, by, ...(reason ? { reason } : {}) });
|
|
125
|
+
if (outcome === 'rejected') {
|
|
126
|
+
this.finish(entry, 'rejected', `rejected by ${by.sub}${reason ? `: ${reason}` : ''}`);
|
|
127
|
+
return { status: 'ok', run: this.copy(entry.run) };
|
|
128
|
+
}
|
|
129
|
+
entry.run.approval.approvedBy = by;
|
|
130
|
+
entry.run.approval.approvedAt = this.timestamp();
|
|
131
|
+
this.clearTimer(entry);
|
|
132
|
+
entry.run.state = 'queued';
|
|
133
|
+
this.record(entry, { type: 'state', state: 'queued' });
|
|
134
|
+
this.queue.push(entry);
|
|
135
|
+
const approved = this.copy(entry.run);
|
|
136
|
+
this.dispatch();
|
|
137
|
+
return { status: 'ok', run: approved };
|
|
138
|
+
}
|
|
139
|
+
// The events after `after` (0 for all), and a way to follow new ones. The
|
|
140
|
+
// listener is not called for a run that is already over.
|
|
141
|
+
watch(owner, id, after, listener) {
|
|
142
|
+
const entry = this.find(owner, id);
|
|
143
|
+
if (!entry) {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
const events = entry.events.filter((event) => event.seq > after);
|
|
147
|
+
const finished = FINAL_STATES.includes(entry.run.state);
|
|
148
|
+
if (!finished) {
|
|
149
|
+
entry.listeners.add(listener);
|
|
150
|
+
}
|
|
151
|
+
return { events, finished, stop: () => entry.listeners.delete(listener) };
|
|
152
|
+
}
|
|
153
|
+
// Stops accepting runs and aborts the ones in flight.
|
|
154
|
+
async close() {
|
|
155
|
+
this.closed = true;
|
|
156
|
+
for (const entry of this.queue.splice(0)) {
|
|
157
|
+
this.finish(entry, 'cancelled');
|
|
158
|
+
}
|
|
159
|
+
for (const entry of this.entries.values()) {
|
|
160
|
+
if (entry.run.state === 'awaiting-approval') {
|
|
161
|
+
this.finish(entry, 'cancelled');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
for (const entry of this.entries.values()) {
|
|
165
|
+
if (entry.run.state === 'running') {
|
|
166
|
+
entry.abort.abort();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
while (this.running > 0) {
|
|
170
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
find(owner, id) {
|
|
174
|
+
const entry = this.entries.get(id);
|
|
175
|
+
return entry && this.owns(owner, entry) ? entry : undefined;
|
|
176
|
+
}
|
|
177
|
+
mayDecide(entry, canDecide) {
|
|
178
|
+
return entry.run.approval !== undefined && canDecide(entry.run.account);
|
|
179
|
+
}
|
|
180
|
+
expire(entry) {
|
|
181
|
+
if (entry.run.state === 'awaiting-approval') {
|
|
182
|
+
this.finish(entry, 'rejected', 'nobody approved it in time');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
clearTimer(entry) {
|
|
186
|
+
if (entry.timer) {
|
|
187
|
+
clearTimeout(entry.timer);
|
|
188
|
+
entry.timer = undefined;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
owns(owner, entry) {
|
|
192
|
+
return entry.run.owner.iss === owner.iss && entry.run.owner.sub === owner.sub;
|
|
193
|
+
}
|
|
194
|
+
copy(run) {
|
|
195
|
+
return {
|
|
196
|
+
...run,
|
|
197
|
+
tasks: [...run.tasks],
|
|
198
|
+
definition: { ...run.definition },
|
|
199
|
+
source: { ...run.source },
|
|
200
|
+
options: JSON.parse(JSON.stringify(run.options)),
|
|
201
|
+
owner: { ...run.owner },
|
|
202
|
+
...(run.approval ? { approval: JSON.parse(JSON.stringify(run.approval)) } : {}),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
timestamp() {
|
|
206
|
+
return new Date(this.now()).toISOString();
|
|
207
|
+
}
|
|
208
|
+
dispatch() {
|
|
209
|
+
while (this.running < this.maxConcurrent && this.queue.length > 0) {
|
|
210
|
+
const entry = this.queue.shift();
|
|
211
|
+
this.running++;
|
|
212
|
+
this.execute(entry).finally(() => {
|
|
213
|
+
this.running--;
|
|
214
|
+
this.dispatch();
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
async execute(entry) {
|
|
219
|
+
entry.run.state = 'running';
|
|
220
|
+
entry.run.startedAt = this.timestamp();
|
|
221
|
+
this.record(entry, { type: 'state', state: 'running' });
|
|
222
|
+
try {
|
|
223
|
+
await this.options.executor.execute(this.copy(entry.run), {
|
|
224
|
+
emit: (event) => this.record(entry, event),
|
|
225
|
+
signal: entry.abort.signal,
|
|
226
|
+
});
|
|
227
|
+
this.finish(entry, entry.abort.signal.aborted ? 'cancelled' : 'succeeded');
|
|
228
|
+
}
|
|
229
|
+
catch (e) {
|
|
230
|
+
this.finish(entry, entry.abort.signal.aborted ? 'cancelled' : 'failed', entry.abort.signal.aborted ? undefined : e instanceof Error ? e.message : String(e));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
finish(entry, state, error) {
|
|
234
|
+
this.clearTimer(entry);
|
|
235
|
+
entry.run.state = state;
|
|
236
|
+
entry.run.finishedAt = this.timestamp();
|
|
237
|
+
if (error) {
|
|
238
|
+
entry.run.error = error;
|
|
239
|
+
}
|
|
240
|
+
this.record(entry, { type: 'state', state, ...(error ? { error } : {}) }, true);
|
|
241
|
+
entry.listeners.clear();
|
|
242
|
+
this.evict();
|
|
243
|
+
}
|
|
244
|
+
record(entry, event, force = false) {
|
|
245
|
+
// an executor that keeps talking can't fill the memory of the server; the
|
|
246
|
+
// state changes, which are few, are always kept
|
|
247
|
+
if (!force && event.type !== 'state' && entry.events.length >= this.maxEvents) {
|
|
248
|
+
if (!entry.truncated) {
|
|
249
|
+
entry.truncated = true;
|
|
250
|
+
this.push(entry, { type: 'truncated' });
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
this.push(entry, event);
|
|
255
|
+
}
|
|
256
|
+
push(entry, event) {
|
|
257
|
+
const stored = { ...event, seq: entry.events.length + 1, time: this.timestamp() };
|
|
258
|
+
entry.events.push(stored);
|
|
259
|
+
for (const listener of entry.listeners) {
|
|
260
|
+
listener(stored);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Only finished runs are dropped, oldest first.
|
|
264
|
+
evict() {
|
|
265
|
+
const finished = [...this.entries.values()].filter((entry) => FINAL_STATES.includes(entry.run.state));
|
|
266
|
+
for (const entry of finished.slice(0, Math.max(0, finished.length - this.keep))) {
|
|
267
|
+
this.entries.delete(entry.run.id);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
exports.RunRegistry = RunRegistry;
|
|
272
|
+
// Used until a backend is wired in: runs are accepted, recorded and failed with
|
|
273
|
+
// a reason, so the API can be used and tested without pretending to execute.
|
|
274
|
+
exports.unavailableExecutor = {
|
|
275
|
+
async execute() {
|
|
276
|
+
throw new Error('this server has no backend that can execute runs yet');
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
//# sourceMappingURL=runs.js.map
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
await new Promise((resolve) => {
|
|
26
|
+
if (environment.abortCtrl.signal.aborted) {
|
|
27
|
+
resolve();
|
|
28
|
+
}
|
|
29
|
+
environment.abortCtrl.signal.addEventListener('abort', () => resolve(), { once: true });
|
|
30
|
+
});
|
|
31
|
+
await server.close();
|
|
32
|
+
environment.stdout.write('hammerkit server stopped\n');
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
if (e instanceof commander_1.CommanderError) {
|
|
36
|
+
throw e;
|
|
37
|
+
}
|
|
38
|
+
program.error(`Server was not successful: ${(0, log_1.getErrorMessage)(e)}`, { exitCode: 1 });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
exports.registerServerCommand = registerServerCommand;
|
|
43
|
+
//# sourceMappingURL=server-command.js.map
|