hammerkit 1.24.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.
@@ -8,8 +8,14 @@ const server_config_1 = require("./server-config");
8
8
  const discovery_1 = require("./discovery");
9
9
  const oauth_1 = require("./oauth");
10
10
  const policy_1 = require("./policy");
11
+ const run_store_1 = require("./run-store");
11
12
  const runs_1 = require("./runs");
13
+ const git_checkout_1 = require("./git-checkout");
14
+ const repo_access_1 = require("./repo-access");
12
15
  const executor_1 = require("./executor");
16
+ const run_plan_1 = require("./run-plan");
17
+ const run_environment_1 = require("./run-environment");
18
+ const accounts_api_1 = require("./accounts-api");
13
19
  const run_request_1 = require("./run-request");
14
20
  const http_2 = require("./http");
15
21
  const definitions_1 = require("./definitions");
@@ -43,7 +49,7 @@ exports.getDiscoveryDocument = getDiscoveryDocument;
43
49
  const HEARTBEAT_MS = 15000;
44
50
  // Server-sent events: `id` is the sequence number, so a client that lost the
45
51
  // connection continues with Last-Event-ID (or ?after=) and misses nothing.
46
- function streamEvents(registry, principal, id, req, res, url) {
52
+ function streamEvents(registry, config, principal, id, req, res, url) {
47
53
  const after = Number(req.headers['last-event-id'] ?? url.searchParams.get('after') ?? 0);
48
54
  const write = (event) => res.write(`id: ${event.seq}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
49
55
  const watch = registry.watch(principal, id, Number.isFinite(after) && after > 0 ? after : 0, (event) => {
@@ -51,7 +57,7 @@ function streamEvents(registry, principal, id, req, res, url) {
51
57
  if (event.type === 'state' && (0, runs_1.isFinal)(event.state)) {
52
58
  end();
53
59
  }
54
- });
60
+ }, accountsOf(config, principal));
55
61
  if (!watch) {
56
62
  (0, http_2.send)(res, 404, { error: 'not found' });
57
63
  return;
@@ -73,7 +79,7 @@ function streamEvents(registry, principal, id, req, res, url) {
73
79
  watch.stop();
74
80
  });
75
81
  }
76
- async function handleRuns(config, registry, definitions, sources, log, principal, req, res, url) {
82
+ async function handleRuns(config, registry, definitions, sources, planning, repos, log, principal, req, res, url) {
77
83
  const match = /^\/v1\/runs(?:\/([^/]+)(?:\/(cancel|events))?)?$/.exec(url.pathname);
78
84
  if (!match) {
79
85
  return false;
@@ -85,7 +91,7 @@ async function handleRuns(config, registry, definitions, sources, log, principal
85
91
  (0, http_2.send)(res, 200, { runs: registry.list(principal).map(toRunResponse) });
86
92
  }
87
93
  else if (method === 'POST') {
88
- await submitRun(config, registry, definitions, sources, log, principal, req, res);
94
+ await submitRun(config, registry, definitions, sources, planning, repos, log, principal, req, res);
89
95
  }
90
96
  else {
91
97
  (0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET, POST' });
@@ -100,7 +106,7 @@ async function handleRuns(config, registry, definitions, sources, log, principal
100
106
  (0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'POST' });
101
107
  return true;
102
108
  }
103
- const run = registry.cancel(principal, id);
109
+ const run = registry.cancel(principal, id, accountsOf(config, principal));
104
110
  log(`cancel iss=${principal.iss} sub=${principal.sub} run=${id} found=${Boolean(run)}`);
105
111
  run ? (0, http_2.send)(res, 200, toRunResponse(run)) : (0, http_2.send)(res, 404, { error: 'not found' });
106
112
  }
@@ -108,20 +114,25 @@ async function handleRuns(config, registry, definitions, sources, log, principal
108
114
  (0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
109
115
  }
110
116
  else if (action === 'events') {
111
- streamEvents(registry, principal, id, req, res, url);
117
+ streamEvents(registry, config, principal, id, req, res, url);
112
118
  }
113
119
  else {
114
- const run = registry.get(principal, id);
120
+ const run = registry.get(principal, id, accountsOf(config, principal));
115
121
  run ? (0, http_2.send)(res, 200, toRunResponse(run)) : (0, http_2.send)(res, 404, { error: 'not found' });
116
122
  }
117
123
  return true;
118
124
  }
125
+ // The accounts the caller uses: who approves a run on them follows and stops it.
126
+ function accountsOf(config, principal) {
127
+ const grant = (0, policy_1.getGrant)(config.entitlements, principal);
128
+ return (account) => (0, policy_1.canUse)(grant, account);
129
+ }
119
130
  function toRunResponse(run) {
120
131
  const { owner, ...visible } = run;
121
132
  void owner;
122
133
  return visible;
123
134
  }
124
- async function submitRun(config, registry, definitions, sources, log, principal, req, res) {
135
+ async function submitRun(config, registry, definitions, sources, planning, repos, log, principal, req, res) {
125
136
  const parsed = run_request_1.runRequestSchema.safeParse(await (0, http_2.readJson)(req));
126
137
  if (!parsed.success) {
127
138
  const issue = parsed.error.issues[0];
@@ -130,21 +141,27 @@ async function submitRun(config, registry, definitions, sources, log, principal,
130
141
  }
131
142
  const request = parsed.data;
132
143
  const grant = (0, policy_1.getGrant)(config.entitlements, principal);
133
- const account = request.account ?? grant.default;
134
144
  // what the caller may not do is said plainly: they already know who they are
135
- if (account !== null && !grant.accounts.includes(account) && !grant.request.includes(account)) {
136
- log(`refuse run iss=${principal.iss} sub=${principal.sub}: service account ${(0, oauth_1.quote)(account)} is not granted`);
137
- (0, http_2.send)(res, 403, { error: `you may not use the service account ${account}` });
145
+ if (!(0, policy_1.mayRun)(grant)) {
146
+ log(`refuse run iss=${principal.iss} sub=${principal.sub}: no entitlement`);
147
+ (0, http_2.send)(res, 403, { error: 'you are not entitled to run anything on this server' });
138
148
  return;
139
149
  }
140
- if (!grant.source.includes(request.source.type)) {
141
- log(`refuse run iss=${principal.iss} sub=${principal.sub}: source ${request.source.type} is not granted`);
142
- (0, http_2.send)(res, 403, { error: `runs from ${request.source.type} are not allowed for you` });
150
+ const version = definitions.find(request.definition.name, request.definition.hash);
151
+ if (!version) {
152
+ (0, http_2.send)(res, 400, { error: `there is no build file ${request.definition.name} at ${request.definition.hash}` });
143
153
  return;
144
154
  }
145
- if (!(0, policy_1.maySeeDefinitions)(grant)) {
146
- log(`refuse run iss=${principal.iss} sub=${principal.sub}: no entitlement`);
147
- (0, http_2.send)(res, 403, { error: 'you are not entitled to run anything on this server' });
155
+ if (version.yanked) {
156
+ (0, http_2.send)(res, 409, {
157
+ error: `${version.name} at ${version.hash} was yanked${version.yanked.reason ? `: ${version.yanked.reason}` : ''}`,
158
+ });
159
+ return;
160
+ }
161
+ const known = [...version.tasks, ...version.services];
162
+ const unknown = request.tasks.filter((task) => !known.includes(task));
163
+ if (unknown.length > 0) {
164
+ (0, http_2.send)(res, 400, { error: `${version.name} has no task ${unknown.map(oauth_1.quote).join(', ')}` });
148
165
  return;
149
166
  }
150
167
  if (request.source.type === 'upload') {
@@ -163,33 +180,72 @@ async function submitRun(config, registry, definitions, sources, log, principal,
163
180
  return;
164
181
  }
165
182
  }
166
- const version = definitions.find(request.definition.name, request.definition.hash);
167
- if (!version) {
168
- (0, http_2.send)(res, 400, { error: `there is no build file ${request.definition.name} at ${request.definition.hash}` });
183
+ // the accounts of a run are read from the files it is pinned to, with the engine
184
+ const bundle = version.plan ? await definitions.files(version) : undefined;
185
+ if (!version.plan || !bundle) {
186
+ (0, http_2.send)(res, 409, {
187
+ error: `${version.name} at ${version.hash} was published before accounts were recorded, publish it again`,
188
+ });
169
189
  return;
170
190
  }
171
- const known = [...version.tasks, ...version.services];
172
- const unknown = request.tasks.filter((task) => !known.includes(task));
173
- if (unknown.length > 0) {
174
- (0, http_2.send)(res, 400, { error: `${version.name} has no task ${unknown.map(oauth_1.quote).join(', ')}` });
191
+ let planned;
192
+ try {
193
+ planned = await (0, run_plan_1.planRun)(bundle, request, planning.environment, planning.policy);
194
+ }
195
+ catch (e) {
196
+ (0, http_2.send)(res, 400, { error: `the run does not load: ${e instanceof Error ? e.message : String(e)}` });
197
+ return;
198
+ }
199
+ const decision = (0, policy_1.decideRun)({
200
+ grant,
201
+ accounts: planned.accounts,
202
+ catalog: config.accounts,
203
+ source: request.source.type === 'ref'
204
+ ? { type: 'ref', repo: request.source.repo, ref: request.source.ref }
205
+ : { type: 'upload' },
206
+ approvable: (account) => config.entitlements.some((entitlement) => entitlement.accounts[account] === 'use'),
207
+ readable: (repo) => repos.readable(repo),
208
+ });
209
+ if (decision.type === 'refuse') {
210
+ log(`refuse run iss=${principal.iss} sub=${principal.sub}: ${decision.error}`);
211
+ (0, http_2.send)(res, decision.status, { error: decision.error });
175
212
  return;
176
213
  }
177
- // asking for an account is not using it: an admin of it has to approve
178
- const needsApproval = account !== null && !grant.accounts.includes(account);
214
+ const approvals = decision.type === 'approval' ? decision.accounts : [];
215
+ // the ref is pinned now: the approver sees the commit, and the run executes that one
216
+ let source;
217
+ if (request.source.type === 'ref') {
218
+ try {
219
+ const commit = await repos.resolve(request.source.repo, request.source.ref);
220
+ source = { type: 'ref', repo: request.source.repo, ref: request.source.ref, commit };
221
+ }
222
+ catch (e) {
223
+ const message = e instanceof Error ? e.message : String(e);
224
+ log(`refuse run iss=${principal.iss} sub=${principal.sub}: ${message}`);
225
+ (0, http_2.send)(res, e instanceof git_checkout_1.RefError ? 400 : 502, {
226
+ error: e instanceof git_checkout_1.RefError ? message : `could not resolve ${request.source.ref}: ${message}`,
227
+ });
228
+ return;
229
+ }
230
+ }
231
+ else {
232
+ source = request.source;
233
+ }
179
234
  try {
180
235
  const run = registry.submit(principal, {
181
236
  definition: { name: version.name, hash: version.hash },
182
237
  tasks: request.tasks,
183
- account,
184
- source: request.source,
238
+ accounts: planned.accounts,
239
+ plan: planned.plan,
240
+ source,
185
241
  options: request.options,
186
- }, needsApproval);
187
- log(`run ${run.id} iss=${principal.iss} sub=${principal.sub} account=${account ?? '-'} source=${run.source.type} tasks=${run.tasks.length}${needsApproval ? ' approval=required' : ''}`);
242
+ }, approvals);
243
+ log(`run ${run.id} iss=${principal.iss} sub=${principal.sub} accounts=[${planned.accounts.join(',')}] source=${run.source.type} tasks=${run.tasks.length}${approvals.length > 0 ? ` approval=[${approvals.join(',')}]` : ''}`);
188
244
  (0, http_2.send)(res, 202, toRunResponse(run));
189
245
  }
190
246
  catch (e) {
191
247
  if (e instanceof runs_1.RunError) {
192
- (0, http_2.send)(res, 503, { error: e.message });
248
+ (0, http_2.send)(res, e.status, { error: e.message });
193
249
  return;
194
250
  }
195
251
  throw e;
@@ -215,7 +271,7 @@ async function authenticate(verifier, log, req, res) {
215
271
  return null;
216
272
  }
217
273
  }
218
- async function handle(config, registry, definitions, proposals, sources, verifier, log, req, res) {
274
+ async function handle(config, registry, definitions, proposals, sources, planning, repos, verifier, log, req, res) {
219
275
  const url = new URL(req.url ?? '/', 'http://localhost');
220
276
  const path = url.pathname;
221
277
  if (path === discovery_1.DISCOVERY_PATH || path === '/healthz') {
@@ -230,18 +286,7 @@ async function handle(config, registry, definitions, proposals, sources, verifie
230
286
  if (!principal) {
231
287
  return;
232
288
  }
233
- if (path === '/v1/me' && req.method === 'GET') {
234
- const grant = (0, policy_1.getGrant)(config.entitlements, principal);
235
- log(`grant iss=${principal.iss} sub=${principal.sub} accounts=[${grant.accounts.join(',')}] entitlements=[${grant.matched.join(',')}]`);
236
- (0, http_2.send)(res, 200, {
237
- iss: principal.iss,
238
- sub: principal.sub,
239
- accounts: grant.accounts,
240
- default: grant.default,
241
- request: grant.request,
242
- publish: grant.publish,
243
- propose: grant.propose,
244
- });
289
+ if (await (0, accounts_api_1.handleAccounts)(config, log, principal, req, res, url)) {
245
290
  return;
246
291
  }
247
292
  if (await (0, definitions_api_1.handleDefinitions)(config, definitions, proposals, log, principal, req, res, url)) {
@@ -250,7 +295,7 @@ async function handle(config, registry, definitions, proposals, sources, verifie
250
295
  if (await (0, sources_api_1.handleSources)(config, sources, log, principal, req, res, url)) {
251
296
  return;
252
297
  }
253
- if (await handleRuns(config, registry, definitions, sources, log, principal, req, res, url)) {
298
+ if (await handleRuns(config, registry, definitions, sources, planning, repos, log, principal, req, res, url)) {
254
299
  return;
255
300
  }
256
301
  if (await (0, approvals_api_1.handleApprovals)(config, registry, log, principal, req, res, url)) {
@@ -262,33 +307,64 @@ async function handle(config, registry, definitions, proposals, sources, verifie
262
307
  (0, http_2.send)(res, 404, { error: 'not found' });
263
308
  }
264
309
  async function startServer(config, environment, options = {}) {
310
+ // tasks find their tools through PATH, a server that hides it would fail every run
311
+ if (!config.runs.env.allow.some((name) => name.toUpperCase() === 'PATH') && !('PATH' in config.runs.env.set)) {
312
+ throw new Error('runs.env.allow has no PATH, tasks could not find their tools: add PATH (and on Windows SystemRoot, ComSpec, PATHEXT and TEMP)');
313
+ }
265
314
  const verifier = new oauth_1.TokenVerifier(config.issuers, options);
266
315
  const write = options.log ?? (() => undefined);
267
316
  // request urls and subjects come from callers: no control characters, so no forged log lines
268
317
  const log = (line) => write(line.replace(/\p{Cc}/gu, '?').slice(0, 1000));
269
- const definitions = new definitions_1.DefinitionStore((0, path_1.join)(config.storage.directory ?? (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'server'), 'definitions'), environment);
318
+ if (config.runtimes.includes('local')) {
319
+ log('warning: the local runtime is allowed, local tasks run as the user of this server and read all it can read; ' +
320
+ 'an account is then an approval class, not a credential boundary');
321
+ }
322
+ // build files are read, planned and run as the server's runs see the world: the
323
+ // operator's accounts and the allowlisted environment, nothing of the server's own
324
+ const planning = {
325
+ environment: {
326
+ ...environment,
327
+ processEnvs: (0, run_environment_1.runEnvironment)(config, environment.processEnvs),
328
+ hosted: { catalog: (0, server_config_1.getSecretCatalog)(config) },
329
+ },
330
+ policy: { envs: config.runs.env.allow, reserved: (0, server_config_1.getSourceAccounts)(config), runtimes: config.runtimes },
331
+ };
332
+ const storage = config.storage.directory ?? (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'server');
333
+ const definitions = new definitions_1.DefinitionStore((0, path_1.join)(storage, 'definitions'), planning.environment, {
334
+ policy: planning.policy,
335
+ });
270
336
  await definitions.load();
337
+ const repos = new repo_access_1.RepoAccess(config, planning.environment, options.repos);
271
338
  const executor = options.executor ??
272
339
  new executor_1.HostExecutor({
273
- config,
274
340
  definitions,
275
- environment,
276
- directory: (0, path_1.join)(config.storage.directory ?? (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'server'), 'runs'),
341
+ planning,
342
+ repos,
343
+ directory: (0, path_1.join)(storage, 'runs'),
344
+ cache: (0, path_1.join)(storage, 'cache'),
345
+ worker: options.worker,
277
346
  });
278
- const sources = new sources_1.SourceStore((0, path_1.join)(config.storage.directory ?? (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'server'), 'sources'));
347
+ const sources = new sources_1.SourceStore((0, path_1.join)(storage, 'sources'));
279
348
  const registry = new runs_1.RunRegistry({
280
349
  executor,
281
350
  maxConcurrent: config.runs.maxConcurrent,
282
351
  keep: config.runs.keep,
283
352
  maxWaiting: config.approvals.maxWaiting,
284
353
  approvalTimeout: (0, units_1.parseDuration)(config.approvals.timeout),
354
+ limits: config.runs.limits,
355
+ maxDuration: (0, units_1.parseDuration)(config.runs.maxDuration),
356
+ maxLogBytes: config.runs.maxLogBytes,
357
+ store: new run_store_1.FileRunStore((0, path_1.join)(storage, 'records')),
358
+ log,
285
359
  });
360
+ await registry.restore();
286
361
  const proposals = new proposals_1.ProposalRegistry({
287
362
  maxWaiting: config.approvals.maxWaiting,
288
363
  timeout: (0, units_1.parseDuration)(config.approvals.timeout),
364
+ cooldown: (0, units_1.parseDuration)(config.approvals.cooldown),
289
365
  });
290
366
  const listener = (req, res) => {
291
- handle(config, registry, definitions, proposals, sources, verifier, log, req, res).catch((e) => {
367
+ handle(config, registry, definitions, proposals, sources, planning, repos, verifier, log, req, res).catch((e) => {
292
368
  if (e instanceof http_2.HttpError) {
293
369
  (0, http_2.send)(res, e.status, { error: e.message });
294
370
  return;
@@ -324,6 +400,26 @@ async function startServer(config, environment, options = {}) {
324
400
  const host = address.family === 'IPv6' ? `[${address.address}]` : address.address;
325
401
  return {
326
402
  url: `${config.tls ? 'https' : 'http'}://${host}:${address.port}`,
403
+ reload: (next) => {
404
+ const readers = (0, server_config_1.getSourceAccounts)(config);
405
+ next.entitlements.forEach((entitlement, index) => {
406
+ const iss = (entitlement.subject ?? entitlement.group ?? entitlement.claims)?.iss;
407
+ if (iss && !config.issuers.some((issuer) => issuer.issuer === iss)) {
408
+ throw new Error(`entitlements.${index}: issuer ${iss} is not accepted by the running server`);
409
+ }
410
+ for (const account of Object.keys(entitlement.accounts)) {
411
+ if (!config.accounts[account] || readers.includes(account)) {
412
+ throw new Error(`entitlements.${index}: the running server has no account ${account} to give`);
413
+ }
414
+ }
415
+ });
416
+ const restart = Object.keys(next).filter((key) => key !== 'entitlements' &&
417
+ JSON.stringify(next[key]) !== JSON.stringify(config[key]));
418
+ // every request reads the entitlements of this object
419
+ config.entitlements = next.entitlements;
420
+ log(`reloaded ${next.entitlements.length} entitlements${restart.length > 0 ? `, restart for: ${restart.join(', ')}` : ''}`);
421
+ return { restart };
422
+ },
327
423
  close: async () => {
328
424
  proposals.close();
329
425
  await registry.close();
@@ -99,7 +99,7 @@ function notFound(res) {
99
99
  // Sending files is only for callers who may run something from an upload.
100
100
  function mustMayUpload(config, principal) {
101
101
  const grant = (0, policy_1.getGrant)(config.entitlements, principal);
102
- if (!(0, policy_1.maySeeDefinitions)(grant) || !grant.source.includes('upload')) {
102
+ if (!(0, policy_1.mayRun)(grant)) {
103
103
  throw new sources_1.SourceError(403, 'uploads are not allowed for you');
104
104
  }
105
105
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hammerkit",
3
3
  "description": "Containerized build tool with incremental caching that runs the same build file locally, in Docker and on Kubernetes.",
4
- "version": "1.24.0",
4
+ "version": "1.25.0",
5
5
  "keywords": [
6
6
  "build",
7
7
  "build-tool",