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,226 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TokenVerifier = exports.quote = exports.TokenError = void 0;
4
+ const crypto_1 = require("crypto");
5
+ // The reason is for the operator's log, never for the caller.
6
+ class TokenError extends Error {
7
+ }
8
+ exports.TokenError = TokenError;
9
+ // Values from a token are written to the operator's log before the signature
10
+ // is checked, so they are quoted and cut: they can't start a log line of their own.
11
+ function quote(value) {
12
+ return JSON.stringify(String(value).slice(0, 80));
13
+ }
14
+ exports.quote = quote;
15
+ // Asymmetric algorithms only: there is no shared secret, and `none` and HS*
16
+ // are never accepted.
17
+ const ALGORITHMS = {
18
+ RS256: { hash: 'sha256', kty: 'RSA' },
19
+ RS384: { hash: 'sha384', kty: 'RSA' },
20
+ RS512: { hash: 'sha512', kty: 'RSA' },
21
+ PS256: { hash: 'sha256', kty: 'RSA', pss: true },
22
+ ES256: { hash: 'sha256', kty: 'EC', crv: 'P-256' },
23
+ ES384: { hash: 'sha384', kty: 'EC', crv: 'P-384' },
24
+ ES512: { hash: 'sha512', kty: 'EC', crv: 'P-521' },
25
+ };
26
+ const CLOCK_SKEW_SECONDS = 60;
27
+ const KEYS_TTL_MS = 10 * 60 * 1000;
28
+ // an unknown kid refreshes the keys, but not more often than this
29
+ const REFRESH_COOLDOWN_MS = 60 * 1000;
30
+ const FETCH_TIMEOUT_MS = 5000;
31
+ // after a failed load the issuer is left alone for this long, instead of being hit by every request
32
+ const FAILURE_BACKOFF_MS = 10 * 1000;
33
+ // keys that could not be refreshed are still used for this long past their TTL, so a short IdP outage does not lock everyone out
34
+ const STALE_KEYS_MS = 60 * 60 * 1000;
35
+ class TokenVerifier {
36
+ constructor(issuers, options = {}) {
37
+ this.issuers = issuers;
38
+ this.keys = new Map();
39
+ this.loading = new Map();
40
+ this.failures = new Map();
41
+ const fetcher = options.fetch ?? fetch;
42
+ this.now = options.now ?? Date.now;
43
+ this.fetchJson = async (url) => {
44
+ const response = await fetcher(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
45
+ if (!response.ok) {
46
+ throw new TokenError(`${url} answered ${response.status}`);
47
+ }
48
+ return response.json();
49
+ };
50
+ }
51
+ async verify(token) {
52
+ const parts = token.split('.');
53
+ if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
54
+ throw new TokenError('not a signed JWT');
55
+ }
56
+ const header = decodeObject(parts[0], 'header');
57
+ const payload = decodeObject(parts[1], 'payload');
58
+ const algorithm = typeof header.alg === 'string' ? ALGORITHMS[header.alg] : undefined;
59
+ if (!algorithm) {
60
+ throw new TokenError(`algorithm ${quote(header.alg)} is not accepted`);
61
+ }
62
+ const issuer = this.issuers.find((i) => i.issuer === payload.iss);
63
+ if (!issuer) {
64
+ throw new TokenError(`issuer ${quote(payload.iss)} is not accepted`);
65
+ }
66
+ const jwk = await this.findKey(issuer, algorithm, typeof header.kid === 'string' ? header.kid : undefined);
67
+ const signature = Buffer.from(parts[2], 'base64url');
68
+ const data = Buffer.from(`${parts[0]}.${parts[1]}`);
69
+ const key = (0, crypto_1.createPublicKey)({ key: jwk, format: 'jwk' });
70
+ if (!verifySignature(algorithm, data, key, signature)) {
71
+ throw new TokenError('signature does not match');
72
+ }
73
+ return this.readClaims(issuer, payload);
74
+ }
75
+ readClaims(issuer, payload) {
76
+ const now = this.now() / 1000;
77
+ if (typeof payload.exp !== 'number') {
78
+ throw new TokenError('token has no expiry');
79
+ }
80
+ if (payload.exp + CLOCK_SKEW_SECONDS < now) {
81
+ throw new TokenError('token expired');
82
+ }
83
+ if (typeof payload.nbf === 'number' && payload.nbf - CLOCK_SKEW_SECONDS > now) {
84
+ throw new TokenError('token not valid yet');
85
+ }
86
+ const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
87
+ if (!audiences.includes(issuer.audience)) {
88
+ throw new TokenError(`token is not meant for ${issuer.audience}`);
89
+ }
90
+ if (typeof payload.sub !== 'string' || payload.sub.length === 0) {
91
+ throw new TokenError('token has no subject');
92
+ }
93
+ const groups = payload[issuer.groupsClaim];
94
+ const claims = {};
95
+ for (const [name, value] of Object.entries(payload)) {
96
+ if (typeof value === 'string') {
97
+ claims[name] = value;
98
+ }
99
+ }
100
+ return {
101
+ iss: issuer.issuer,
102
+ sub: payload.sub,
103
+ groups: Array.isArray(groups)
104
+ ? groups.filter((g) => typeof g === 'string')
105
+ : typeof groups === 'string'
106
+ ? [groups]
107
+ : [],
108
+ claims,
109
+ };
110
+ }
111
+ async findKey(issuer, algorithm, kid) {
112
+ const known = this.keys.get(issuer.issuer);
113
+ const stale = !known || this.now() - known.loadedAt > KEYS_TTL_MS;
114
+ let loaded;
115
+ if (stale) {
116
+ try {
117
+ loaded = await this.loadKeys(issuer);
118
+ }
119
+ catch (e) {
120
+ if (!known || this.now() - known.loadedAt > KEYS_TTL_MS + STALE_KEYS_MS) {
121
+ throw e;
122
+ }
123
+ loaded = known;
124
+ }
125
+ }
126
+ else {
127
+ loaded = known;
128
+ }
129
+ let jwk = pickKey(loaded.keys, algorithm, kid);
130
+ if (!jwk && !stale && this.now() - loaded.loadedAt > REFRESH_COOLDOWN_MS) {
131
+ // the issuer may have rotated its keys since they were cached
132
+ try {
133
+ loaded = await this.loadKeys(issuer);
134
+ jwk = pickKey(loaded.keys, algorithm, kid);
135
+ }
136
+ catch {
137
+ // keep the cached keys: the token simply has no key we know
138
+ }
139
+ }
140
+ if (!jwk) {
141
+ throw new TokenError(`issuer has no ${algorithm.kty} key${kid ? ` ${quote(kid)}` : ''}`);
142
+ }
143
+ return jwk;
144
+ }
145
+ // One load per issuer at a time, and none right after a failed one.
146
+ loadKeys(issuer) {
147
+ const running = this.loading.get(issuer.issuer);
148
+ if (running) {
149
+ return running;
150
+ }
151
+ const failure = this.failures.get(issuer.issuer);
152
+ if (failure && this.now() - failure.at < FAILURE_BACKOFF_MS) {
153
+ return Promise.reject(failure.error);
154
+ }
155
+ const load = this.fetchKeys(issuer)
156
+ .then((loaded) => {
157
+ this.failures.delete(issuer.issuer);
158
+ return loaded;
159
+ })
160
+ .catch((error) => {
161
+ this.failures.set(issuer.issuer, { at: this.now(), error });
162
+ throw error;
163
+ })
164
+ .finally(() => this.loading.delete(issuer.issuer));
165
+ this.loading.set(issuer.issuer, load);
166
+ return load;
167
+ }
168
+ async fetchKeys(issuer) {
169
+ const base = issuer.issuer.replace(/\/+$/, '');
170
+ const metadata = (await this.fetchJson(`${base}/.well-known/openid-configuration`));
171
+ if (metadata.issuer !== issuer.issuer) {
172
+ throw new TokenError(`issuer metadata names ${quote(metadata.issuer)}`);
173
+ }
174
+ const jwksUri = typeof metadata.jwks_uri === 'string' ? metadata.jwks_uri : '';
175
+ if (!isSecureUrl(jwksUri)) {
176
+ throw new TokenError(`jwks_uri ${quote(jwksUri)} is not https`);
177
+ }
178
+ const jwks = (await this.fetchJson(jwksUri));
179
+ const loaded = { keys: Array.isArray(jwks.keys) ? jwks.keys : [], loadedAt: this.now() };
180
+ this.keys.set(issuer.issuer, loaded);
181
+ return loaded;
182
+ }
183
+ }
184
+ exports.TokenVerifier = TokenVerifier;
185
+ function pickKey(keys, algorithm, kid) {
186
+ const candidates = keys.filter((jwk) => jwk.kty === algorithm.kty &&
187
+ jwk.use !== 'enc' &&
188
+ (algorithm.crv === undefined || jwk.crv === algorithm.crv));
189
+ if (kid !== undefined) {
190
+ return candidates.find((jwk) => jwk.kid === kid);
191
+ }
192
+ // a token without kid is only unambiguous when the issuer has one such key
193
+ return candidates.length === 1 ? candidates[0] : undefined;
194
+ }
195
+ function verifySignature(algorithm, data, key, signature) {
196
+ if (algorithm.kty === 'EC') {
197
+ return (0, crypto_1.verify)(algorithm.hash, data, { key, dsaEncoding: 'ieee-p1363' }, signature);
198
+ }
199
+ if (algorithm.pss) {
200
+ return (0, crypto_1.verify)(algorithm.hash, data, { key, padding: crypto_1.constants.RSA_PKCS1_PSS_PADDING, saltLength: crypto_1.constants.RSA_PSS_SALTLEN_DIGEST }, signature);
201
+ }
202
+ return (0, crypto_1.verify)(algorithm.hash, data, key, signature);
203
+ }
204
+ function decodeObject(part, what) {
205
+ try {
206
+ const value = JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
207
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
208
+ return value;
209
+ }
210
+ }
211
+ catch {
212
+ // reported below
213
+ }
214
+ throw new TokenError(`token ${what} is not a JSON object`);
215
+ }
216
+ function isSecureUrl(value) {
217
+ try {
218
+ const url = new URL(value);
219
+ return (url.protocol === 'https:' ||
220
+ (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)));
221
+ }
222
+ catch {
223
+ return false;
224
+ }
225
+ }
226
+ //# sourceMappingURL=oauth.js.map
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getGrant = exports.isAdmin = exports.maySeeDefinitions = exports.mayPropose = exports.mayPublish = exports.matches = void 0;
4
+ function matches(entitlement, principal) {
5
+ if (entitlement.subject) {
6
+ return entitlement.subject.iss === principal.iss && entitlement.subject.sub === principal.sub;
7
+ }
8
+ if (entitlement.group) {
9
+ return entitlement.group.iss === principal.iss && principal.groups.includes(entitlement.group.name);
10
+ }
11
+ if (entitlement.claims) {
12
+ return (entitlement.claims.iss === principal.iss &&
13
+ Object.entries(entitlement.claims.match).every(([name, value]) => principal.claims[name] === value));
14
+ }
15
+ return false;
16
+ }
17
+ exports.matches = matches;
18
+ function mayPublish(grant, name) {
19
+ return grant.publish.includes('*') || grant.publish.includes(name);
20
+ }
21
+ exports.mayPublish = mayPublish;
22
+ function mayPropose(grant, name) {
23
+ return grant.propose.includes('*') || grant.propose.includes(name);
24
+ }
25
+ exports.mayPropose = mayPropose;
26
+ // A caller who can neither run nor publish anything sees nothing of the server's
27
+ // build files either. Admins see them, they have to know what they approve.
28
+ function maySeeDefinitions(grant) {
29
+ return grant.accounts.length > 0 || grant.request.length > 0 || grant.publish.length > 0 || grant.propose.length > 0;
30
+ }
31
+ exports.maySeeDefinitions = maySeeDefinitions;
32
+ // Admin of an account decides about the runs on it.
33
+ function isAdmin(grant, account) {
34
+ return account !== null && grant.accounts.includes(account);
35
+ }
36
+ exports.isAdmin = isAdmin;
37
+ // Default deny: a caller without a matching entitlement gets no account.
38
+ function getGrant(entitlements, principal) {
39
+ const matched = [];
40
+ const accounts = [];
41
+ const requested = [];
42
+ const publish = [];
43
+ const propose = [];
44
+ let defaultAccount = null;
45
+ let source = null;
46
+ entitlements.forEach((entitlement, index) => {
47
+ if (!matches(entitlement, principal)) {
48
+ return;
49
+ }
50
+ matched.push(index);
51
+ for (const account of entitlement.accounts) {
52
+ if (!accounts.includes(account)) {
53
+ accounts.push(account);
54
+ }
55
+ }
56
+ for (const account of entitlement.request ?? []) {
57
+ if (!requested.includes(account)) {
58
+ requested.push(account);
59
+ }
60
+ }
61
+ for (const name of entitlement.publish ?? []) {
62
+ if (!publish.includes(name)) {
63
+ publish.push(name);
64
+ }
65
+ }
66
+ for (const name of entitlement.propose ?? []) {
67
+ if (!propose.includes(name)) {
68
+ propose.push(name);
69
+ }
70
+ }
71
+ defaultAccount = defaultAccount ?? entitlement.default ?? null;
72
+ if (entitlement.source) {
73
+ source = source ? source.filter((s) => entitlement.source?.includes(s)) : [...entitlement.source];
74
+ }
75
+ });
76
+ // admin wins over request when two entitlements give both
77
+ const request = requested.filter((account) => !accounts.includes(account));
78
+ const usable = [...accounts, ...request];
79
+ return {
80
+ accounts,
81
+ request,
82
+ publish,
83
+ propose,
84
+ default: defaultAccount ?? (usable.length === 1 ? usable[0] : null),
85
+ source: source ?? ['upload', 'ref'],
86
+ matched,
87
+ };
88
+ }
89
+ exports.getGrant = getGrant;
90
+ //# sourceMappingURL=policy.js.map
@@ -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