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,88 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.callServer = exports.getMe = exports.getDiscovery = exports.printable = exports.ServerError = void 0;
4
+ const discovery_1 = require("../server/discovery");
5
+ const TIMEOUT_MS = 10000;
6
+ const WRITE_TIMEOUT_MS = 30000;
7
+ class ServerError extends Error {
8
+ }
9
+ exports.ServerError = ServerError;
10
+ // Text from the server or the identity provider is shown to the user: no
11
+ // control characters, so it can't rewrite the terminal.
12
+ function printable(value) {
13
+ return String(value)
14
+ .replace(/\p{Cc}/gu, '?')
15
+ .slice(0, 300);
16
+ }
17
+ exports.printable = printable;
18
+ async function getJson(url, token) {
19
+ let response;
20
+ try {
21
+ response = await fetch(url, {
22
+ headers: token ? { authorization: `Bearer ${token}` } : {},
23
+ signal: AbortSignal.timeout(TIMEOUT_MS),
24
+ });
25
+ }
26
+ catch (e) {
27
+ throw new ServerError(`unable to reach ${url}: ${e instanceof Error ? e.message : String(e)}`);
28
+ }
29
+ if (response.status === 401) {
30
+ throw new ServerError(`${url} did not accept the token`);
31
+ }
32
+ if (!response.ok) {
33
+ throw new ServerError(`${url} answered ${response.status}`);
34
+ }
35
+ return response.json();
36
+ }
37
+ // What the server says it is. The issuer registered with `remote add` has to be
38
+ // one it accepts, otherwise a login would send the user to an identity provider
39
+ // the server does not trust.
40
+ async function getDiscovery(server) {
41
+ const document = (await getJson(`${server.url}${discovery_1.DISCOVERY_PATH}`));
42
+ if (document?.name !== 'hammerkit' || !Array.isArray(document.issuers)) {
43
+ throw new ServerError(`${server.url} is not a hammerkit server`);
44
+ }
45
+ return document;
46
+ }
47
+ exports.getDiscovery = getDiscovery;
48
+ async function getMe(server, token) {
49
+ return (await getJson(`${server.url}/v1/me`, token));
50
+ }
51
+ exports.getMe = getMe;
52
+ // A call that changes something, or whose answer the caller tells apart by status.
53
+ // Anything but 2xx is an error carrying what the server said about it.
54
+ async function callServer(server, token, method, path, body) {
55
+ const url = `${server.url}${path}`;
56
+ let response;
57
+ try {
58
+ response = await fetch(url, {
59
+ method,
60
+ headers: {
61
+ authorization: `Bearer ${token}`,
62
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
63
+ },
64
+ body: body === undefined ? undefined : JSON.stringify(body),
65
+ signal: AbortSignal.timeout(method === 'GET' ? TIMEOUT_MS : WRITE_TIMEOUT_MS),
66
+ });
67
+ }
68
+ catch (e) {
69
+ throw new ServerError(`unable to reach ${url}: ${printable(e instanceof Error ? e.message : e)}`);
70
+ }
71
+ let answer;
72
+ try {
73
+ answer = await response.json();
74
+ }
75
+ catch {
76
+ answer = undefined;
77
+ }
78
+ if (response.status === 401) {
79
+ throw new ServerError(`${server.url} did not accept the token`);
80
+ }
81
+ if (!response.ok) {
82
+ const said = answer && typeof answer.error === 'string' ? `: ${printable(answer.error)}` : '';
83
+ throw new ServerError(`${server.url} answered ${response.status}${said}`);
84
+ }
85
+ return { status: response.status, body: answer };
86
+ }
87
+ exports.callServer = callServer;
88
+ //# sourceMappingURL=server-client.js.map
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.deniedSourcePath = exports.hashSource = exports.sourceManifestSchema = exports.DIGEST_PATTERN = exports.MAX_MANIFEST_BODY_BYTES = exports.MAX_SOURCE_BYTES = exports.MAX_SOURCE_FILE_BYTES = exports.MAX_SOURCE_FILES = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const zod_1 = require("zod");
6
+ const bundle_1 = require("./bundle");
7
+ exports.MAX_SOURCE_FILES = 20000;
8
+ exports.MAX_SOURCE_FILE_BYTES = 32 * 1024 * 1024;
9
+ exports.MAX_SOURCE_BYTES = 1024 * 1024 * 1024;
10
+ // a manifest is about 200 bytes per file
11
+ exports.MAX_MANIFEST_BODY_BYTES = 8 * 1024 * 1024;
12
+ exports.DIGEST_PATTERN = /^[0-9a-f]{64}$/;
13
+ const sourceFileSchema = zod_1.z
14
+ .object({
15
+ path: zod_1.z.string(),
16
+ sha256: zod_1.z.string().regex(exports.DIGEST_PATTERN, 'expected 64 lowercase hex digits'),
17
+ size: zod_1.z.number().int().min(0).max(exports.MAX_SOURCE_FILE_BYTES),
18
+ mode: zod_1.z.enum(['file', 'exec']).default('file'),
19
+ })
20
+ .strict();
21
+ exports.sourceManifestSchema = zod_1.z
22
+ .object({ files: zod_1.z.array(sourceFileSchema).min(1).max(exports.MAX_SOURCE_FILES) })
23
+ .strict()
24
+ .superRefine((manifest, ctx) => {
25
+ const seen = new Set();
26
+ let bytes = 0;
27
+ manifest.files.forEach((file, index) => {
28
+ if (!(0, bundle_1.isBundlePath)(file.path)) {
29
+ ctx.addIssue({
30
+ code: zod_1.z.ZodIssueCode.custom,
31
+ path: ['files', index, 'path'],
32
+ message: `"${file.path}" is not a path inside the project`,
33
+ });
34
+ }
35
+ if (seen.has(file.path)) {
36
+ ctx.addIssue({ code: zod_1.z.ZodIssueCode.custom, path: ['files', index, 'path'], message: `${file.path} twice` });
37
+ }
38
+ seen.add(file.path);
39
+ bytes += file.size;
40
+ });
41
+ if (bytes > exports.MAX_SOURCE_BYTES) {
42
+ ctx.addIssue({ code: zod_1.z.ZodIssueCode.custom, path: ['files'], message: `more than ${exports.MAX_SOURCE_BYTES} bytes` });
43
+ }
44
+ });
45
+ // The name of a tree: the sha-256 of the JSON `[[path, sha-256, mode], ...]` in
46
+ // path order. The same files have the same name wherever it is computed, and the
47
+ // size is not part of it because the content digest already decides it.
48
+ function hashSource(manifest) {
49
+ const files = [...manifest.files]
50
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
51
+ .map((file) => [file.path, file.sha256, file.mode]);
52
+ return `sha256:${(0, crypto_1.createHash)('sha256').update(JSON.stringify(files)).digest('hex')}`;
53
+ }
54
+ exports.hashSource = hashSource;
55
+ const DENIED_DIRECTORIES = ['.git', '.ssh', '.aws', '.gnupg', '.kube'];
56
+ const DENIED_NAMES = [
57
+ /^\.env$/i,
58
+ /^\.env\.(?!example$|sample$|template$|dist$)/i,
59
+ /^\.(npmrc|netrc|pypirc|dockercfg|git-credentials)$/i,
60
+ /^id_(rsa|dsa|ecdsa|ed25519)$/i,
61
+ /\.(pem|p12|pfx)$/i,
62
+ ];
63
+ // Files that hold credentials are not sent to a server even when a `src` pattern
64
+ // reaches them: a secret belongs in a secret provider, and a glob such as `**`
65
+ // must not be what decides that a key leaves the machine.
66
+ function deniedSourcePath(path) {
67
+ const parts = path.split('/');
68
+ const directory = parts.slice(0, -1).find((part) => DENIED_DIRECTORIES.includes(part));
69
+ if (directory !== undefined) {
70
+ return `${directory}/ holds credentials or history`;
71
+ }
72
+ const name = parts[parts.length - 1];
73
+ if (DENIED_NAMES.some((pattern) => pattern.test(name))) {
74
+ return `${name} looks like a credential file`;
75
+ }
76
+ return null;
77
+ }
78
+ exports.deniedSourcePath = deniedSourcePath;
79
+ //# sourceMappingURL=source-manifest.js.map
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.diffBundles = exports.diffLines = exports.visible = void 0;
4
+ // Text from a proposal is shown to somebody who decides on it, so nothing in it may
5
+ // rewrite the terminal or hide what it says: control and format characters (which
6
+ // include the ones that reorder text) are shown as `?`. Long lines are not cut,
7
+ // what is hidden behind a cut is what a reviewer needs to see.
8
+ function visible(line) {
9
+ return line.replace(/\t/g, ' ').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '?');
10
+ }
11
+ exports.visible = visible;
12
+ // Beyond this many comparisons a file is shown whole instead of compared.
13
+ const MAX_CELLS = 4000000;
14
+ // The lines that differ, with `context` lines around each change.
15
+ function diffLines(before, after, context = 2) {
16
+ if ((before.length + 1) * (after.length + 1) > MAX_CELLS) {
17
+ return undefined;
18
+ }
19
+ const n = before.length;
20
+ const m = after.length;
21
+ // lengths of the longest common suffixes
22
+ const table = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
23
+ for (let i = n - 1; i >= 0; i--) {
24
+ for (let j = m - 1; j >= 0; j--) {
25
+ table[i][j] = before[i] === after[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
26
+ }
27
+ }
28
+ const all = [];
29
+ let i = 0;
30
+ let j = 0;
31
+ while (i < n && j < m) {
32
+ if (before[i] === after[j]) {
33
+ all.push({ type: ' ', text: before[i] });
34
+ i++;
35
+ j++;
36
+ }
37
+ else if (table[i + 1][j] >= table[i][j + 1]) {
38
+ all.push({ type: '-', text: before[i++] });
39
+ }
40
+ else {
41
+ all.push({ type: '+', text: after[j++] });
42
+ }
43
+ }
44
+ while (i < n)
45
+ all.push({ type: '-', text: before[i++] });
46
+ while (j < m)
47
+ all.push({ type: '+', text: after[j++] });
48
+ const keep = new Array(all.length).fill(false);
49
+ all.forEach((line, index) => {
50
+ if (line.type !== ' ') {
51
+ for (let k = Math.max(0, index - context); k <= Math.min(all.length - 1, index + context); k++) {
52
+ keep[k] = true;
53
+ }
54
+ }
55
+ });
56
+ return all.filter((_, index) => keep[index]);
57
+ }
58
+ exports.diffLines = diffLines;
59
+ const lines = (text) => (text === '' ? [] : text.replace(/\n$/, '').split('\n'));
60
+ // What a proposal changes against the version it follows, file by file.
61
+ function diffBundles(base, proposed) {
62
+ const out = [];
63
+ const before = base?.files ?? {};
64
+ const paths = [...new Set([...Object.keys(before), ...Object.keys(proposed.files)])].sort();
65
+ for (const path of paths) {
66
+ const old = before[path];
67
+ const next = proposed.files[path];
68
+ if (old === next) {
69
+ continue;
70
+ }
71
+ const status = old === undefined ? 'added' : next === undefined ? 'removed' : 'changed';
72
+ out.push(`--- ${visible(path)} (${status})`);
73
+ const compared = old !== undefined && next !== undefined ? diffLines(lines(old), lines(next)) : undefined;
74
+ if (compared) {
75
+ compared.forEach((line) => out.push(`${line.type} ${visible(line.text)}`));
76
+ }
77
+ else {
78
+ // added, removed or too large to compare: all of the old, then all of the new
79
+ lines(old ?? '').forEach((line) => out.push(`- ${visible(line)}`));
80
+ lines(next ?? '').forEach((line) => out.push(`+ ${visible(line)}`));
81
+ }
82
+ }
83
+ if (base && base.entry !== proposed.entry) {
84
+ out.unshift(`entry file: ${visible(base.entry)} -> ${visible(proposed.entry)}`);
85
+ }
86
+ return out;
87
+ }
88
+ exports.diffBundles = diffBundles;
89
+ //# sourceMappingURL=text-diff.js.map
@@ -11,6 +11,7 @@ const build_file_volume_schema_1 = require("./build-file-volume-schema");
11
11
  const duration_schema_1 = require("./duration-schema");
12
12
  const build_file_secret_schema_1 = require("./build-file-secret-schema");
13
13
  const resources_schema_1 = require("./resources-schema");
14
+ const runner_schema_1 = require("./runner-schema");
14
15
  // what `extend` adds to (the rest, e.g. `image` or `shell`, the task just overrides)
15
16
  exports.RESET_PROPERTIES = ['deps', 'needs', 'src', 'generates', 'cmds', 'mounts', 'envs', 'labels'];
16
17
  exports.buildFileBaseTaskSchema = (0, zod_1.object)({
@@ -38,5 +39,8 @@ exports.buildFileBaseTaskSchema = (0, zod_1.object)({
38
39
  account: (0, zod_1.string)().optional(),
39
40
  // CPU and memory limits of the container; a local task is not limited
40
41
  resources: resources_schema_1.resourcesSchema.optional(),
42
+ // Name of the server (see `hammerkit remote`) the task has to run on; defaults
43
+ // to the build file's `runner`. Never a local fallback when it is unknown.
44
+ runner: runner_schema_1.runnerSchema.optional(),
41
45
  });
42
46
  //# sourceMappingURL=build-file-base-task-schema.js.map
@@ -10,6 +10,7 @@ const labels_schema_1 = require("./labels-schema");
10
10
  const cache_schema_1 = require("./cache-schema");
11
11
  const build_file_include_schema_1 = require("./build-file-include-schema");
12
12
  const build_file_secret_provider_schema_1 = require("./build-file-secret-provider-schema");
13
+ const runner_schema_1 = require("./runner-schema");
13
14
  exports.buildFileSchema = (0, zod_1.object)({
14
15
  envs: envs_schema_1.envsSchema.optional(),
15
16
  tasks: (0, zod_1.record)(build_file_task_schema_1.buildFileTaskSchema).optional(),
@@ -21,6 +22,8 @@ exports.buildFileSchema = (0, zod_1.object)({
21
22
  secretProviders: build_file_secret_provider_schema_1.buildFileSecretProvidersSchema.optional(),
22
23
  secretAccounts: build_file_secret_provider_schema_1.buildFileSecretAccountsSchema.optional(),
23
24
  labels: labels_schema_1.labelsSchema.optional(),
25
+ // default `runner` of the tasks of this file
26
+ runner: runner_schema_1.runnerSchema.optional(),
24
27
  })
25
28
  .strict()
26
29
  .describe('Build file with support for containerization\nhttps://hammerkit.dev/docs/build-file');
@@ -26,8 +26,12 @@ async function parseReferences(ctx, mainScope, environment) {
26
26
  projectRoot: await (0, project_root_1.findProjectRoot)(mainScope.cwd, environment),
27
27
  };
28
28
  for (const file of Object.values(ctx.files)) {
29
+ if (environment.hosted && (file.schema.secretProviders || file.schema.secretAccounts)) {
30
+ throw new Error(`${file.fileName} declares secret providers or accounts, which a server defines in its own config`);
31
+ }
29
32
  if (!reference.envFiles[file.cwd]) {
30
- reference.envFiles[file.cwd] = await (0, read_env_file_1.readEnvFile)(file.cwd, environment);
33
+ // a server does not read the `.env` of a checkout: it is code of the project
34
+ reference.envFiles[file.cwd] = environment.hosted ? {} : await (0, read_env_file_1.readEnvFile)(file.cwd, environment);
31
35
  }
32
36
  const buildFileEnvs = file.schema.envs;
33
37
  if (file.schema.tasks) {
@@ -92,7 +96,9 @@ async function parseReferences(ctx, mainScope, environment) {
92
96
  resolveInit(reference, service);
93
97
  }
94
98
  reference.caches = (0, resolve_cache_1.withBuiltinCaches)(declaredCaches);
95
- reference.secrets = (0, secret_catalog_1.collectSecretCatalog)(Object.values(ctx.files), environment, reference);
99
+ reference.secrets = environment.hosted
100
+ ? { ...environment.hosted.catalog, explicitAccounts: true }
101
+ : (0, secret_catalog_1.collectSecretCatalog)(Object.values(ctx.files), environment, reference);
96
102
  return reference;
97
103
  }
98
104
  exports.parseReferences = parseReferences;
@@ -121,6 +127,7 @@ function applySchemaExtension(reference, task) {
121
127
  task.schema.shell = task.schema.shell ?? extend.schema.shell;
122
128
  task.schema.cache = task.schema.cache ?? extend.schema.cache;
123
129
  task.schema.timeout = task.schema.timeout ?? extend.schema.timeout;
130
+ task.schema.runner = task.schema.runner ?? extend.schema.runner;
124
131
  if (!reset.has('cmds'))
125
132
  task.schema.cmds = extendArray(task.schema.cmds, extend.schema.cmds);
126
133
  if (!reset.has('src'))
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runnerSchema = void 0;
4
+ const zod_1 = require("zod");
5
+ const host_config_1 = require("../remote/host-config");
6
+ // A server registered with `hammerkit remote add`; resolved on the machine that
7
+ // runs the build, so the build file carries no URL.
8
+ exports.runnerSchema = (0, zod_1.string)()
9
+ .min(1)
10
+ .refine((name) => !host_config_1.RESERVED_SERVER_NAMES.includes(name), {
11
+ message: 'local and auto are no servers, leave runner out to run where --on or the host config says',
12
+ });
13
+ //# sourceMappingURL=runner-schema.js.map
@@ -16,6 +16,10 @@ function keyOf(binding, ref) {
16
16
  return [binding.definition.name, binding.account.name, ref].join('\0');
17
17
  }
18
18
  function fetchProviderValue(binding, ref, environment) {
19
+ const allowed = environment.hosted?.accounts;
20
+ if (allowed && !allowed.includes(binding.account.name)) {
21
+ return Promise.reject(new Error(`the run may not read secrets as ${binding.account.name}`));
22
+ }
19
23
  let values = runs.get(environment);
20
24
  if (!values) {
21
25
  values = new Map();
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleAccounts = void 0;
4
+ const http_1 = require("./http");
5
+ const policy_1 = require("./policy");
6
+ /**
7
+ * Who the caller is and what they may do:
8
+ *
9
+ * GET /v1/me identity, accounts with their level, repositories and the builds level
10
+ * GET /v1/accounts the accounts the caller has a level on, with what the operator says about them
11
+ */
12
+ async function handleAccounts(config, log, principal, req, res, url) {
13
+ if (url.pathname !== '/v1/me' && url.pathname !== '/v1/accounts') {
14
+ return false;
15
+ }
16
+ if (req.method !== 'GET') {
17
+ (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
18
+ return true;
19
+ }
20
+ const grant = (0, policy_1.getGrant)(config.entitlements, principal);
21
+ const accounts = Object.entries(grant.accounts)
22
+ .sort(([a], [b]) => (a < b ? -1 : 1))
23
+ .map(([name, level]) => ({ name, level }));
24
+ if (url.pathname === '/v1/me') {
25
+ log(`grant iss=${principal.iss} sub=${principal.sub} accounts=[${accounts
26
+ .map((account) => `${account.name}:${account.level}`)
27
+ .join(',')}] entitlements=[${grant.matched.join(',')}]`);
28
+ (0, http_1.send)(res, 200, { iss: principal.iss, sub: principal.sub, accounts, repos: grant.repos, builds: grant.builds });
29
+ return true;
30
+ }
31
+ (0, http_1.send)(res, 200, {
32
+ accounts: accounts.map(({ name, level }) => ({
33
+ name,
34
+ ...(config.accounts[name]?.description ? { description: config.accounts[name].description } : {}),
35
+ level,
36
+ sources: config.accounts[name]?.source ?? ['upload', 'ref'],
37
+ })),
38
+ });
39
+ return true;
40
+ }
41
+ exports.handleAccounts = handleAccounts;
42
+ //# sourceMappingURL=accounts-api.js.map
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleApprovals = void 0;
4
+ const zod_1 = require("zod");
5
+ const http_1 = require("./http");
6
+ const oauth_1 = require("./oauth");
7
+ const policy_1 = require("./policy");
8
+ const runs_1 = require("./runs");
9
+ const decisionSchema = zod_1.z.object({ reason: zod_1.z.string().max(500).optional() }).strict();
10
+ // What an approver is shown of a run: everything that decides whether to let it
11
+ // go ahead, and who asked.
12
+ function toApprovalView(run) {
13
+ return {
14
+ id: run.id,
15
+ state: run.state,
16
+ requestedBy: run.owner,
17
+ createdAt: run.createdAt,
18
+ definition: run.definition,
19
+ tasks: run.tasks,
20
+ accounts: run.accounts,
21
+ plan: run.plan,
22
+ source: run.source,
23
+ options: run.options,
24
+ approvals: run.approvals,
25
+ };
26
+ }
27
+ /**
28
+ * The runs waiting for an approval, which somebody who uses the accounts they
29
+ * need gives (one person is enough when they use all of them):
30
+ *
31
+ * GET /v1/approvals the runs the caller may approve
32
+ * GET /v1/approvals/<id> one of them
33
+ * POST /v1/approvals/<id>/approve let it run (body: optional {reason})
34
+ * POST /v1/approvals/<id>/reject end it
35
+ *
36
+ * What the caller may not approve does not exist for them; their own run is the
37
+ * exception, they are told why it is not theirs to decide.
38
+ */
39
+ async function handleApprovals(config, registry, log, principal, req, res, url) {
40
+ const match = /^\/v1\/approvals(?:\/([^/]+)(?:\/(approve|reject))?)?$/.exec(url.pathname);
41
+ if (!match) {
42
+ return false;
43
+ }
44
+ const [, id, action] = match;
45
+ const grant = (0, policy_1.getGrant)(config.entitlements, principal);
46
+ const canDecide = (account) => (0, policy_1.canUse)(grant, account);
47
+ if (!id) {
48
+ if (req.method !== 'GET') {
49
+ (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
50
+ return true;
51
+ }
52
+ const open = registry
53
+ .waiting()
54
+ .filter((run) => !(run.owner.iss === principal.iss && run.owner.sub === principal.sub) &&
55
+ (run.approvals ?? []).some((approval) => !approval.approvedBy && canDecide(approval.account)));
56
+ (0, http_1.send)(res, 200, { approvals: open.map(toApprovalView) });
57
+ return true;
58
+ }
59
+ if (!runs_1.RUN_ID_PATTERN.test(id)) {
60
+ (0, http_1.send)(res, 404, { error: 'not found' });
61
+ return true;
62
+ }
63
+ if (!action) {
64
+ if (req.method !== 'GET') {
65
+ (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
66
+ return true;
67
+ }
68
+ const run = registry.getForApproval(id, canDecide);
69
+ run ? (0, http_1.send)(res, 200, toApprovalView(run)) : (0, http_1.send)(res, 404, { error: 'not found' });
70
+ return true;
71
+ }
72
+ if (req.method !== 'POST') {
73
+ (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'POST' });
74
+ return true;
75
+ }
76
+ const body = decisionSchema.safeParse(req.headers['content-type'] ? await (0, http_1.readJson)(req) : {});
77
+ if (!body.success) {
78
+ const issue = body.error.issues[0];
79
+ (0, http_1.send)(res, 400, { error: `${issue.path.join('.') || 'request'}: ${issue.message}` });
80
+ return true;
81
+ }
82
+ const outcome = action === 'approve' ? 'approved' : 'rejected';
83
+ const result = registry.decide(principal, id, outcome, canDecide, body.data.reason);
84
+ log(`${action} run=${id} iss=${principal.iss} sub=${principal.sub} result=${result.status}${body.data.reason ? ` reason=${(0, oauth_1.quote)(body.data.reason)}` : ''}`);
85
+ switch (result.status) {
86
+ case 'ok':
87
+ (0, http_1.send)(res, 200, toApprovalView(result.run));
88
+ break;
89
+ case 'own-run':
90
+ (0, http_1.send)(res, 403, { error: 'a run is approved by somebody else than who asked for it' });
91
+ break;
92
+ case 'not-waiting':
93
+ (0, http_1.send)(res, 409, { error: `the run is ${result.state}, it does not wait for an approval` });
94
+ break;
95
+ case 'already-approved':
96
+ (0, http_1.send)(res, 409, { error: 'you approved all you can, the run waits for somebody who uses the other accounts' });
97
+ break;
98
+ default:
99
+ (0, http_1.send)(res, 404, { error: 'not found' });
100
+ }
101
+ return true;
102
+ }
103
+ exports.handleApprovals = handleApprovals;
104
+ //# sourceMappingURL=approvals-api.js.map
@@ -0,0 +1,163 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleDefinitions = 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 proposals_1 = require("./proposals");
11
+ const server_config_1 = require("./server-config");
12
+ // A bundle is JSON text with escapes, so the body may be somewhat larger than the files.
13
+ const MAX_PUBLISH_BYTES = bundle_1.MAX_BUNDLE_BYTES * 2;
14
+ /**
15
+ * The published build files:
16
+ *
17
+ * PUT /v1/definitions/<name> publish a bundle, or propose it when the
18
+ * caller may only propose (answers 202)
19
+ * GET /v1/definitions the names, with their latest version
20
+ * GET /v1/definitions/<name> the versions of one
21
+ * GET /v1/definitions/<name>/<hash|latest> one version
22
+ * GET /v1/definitions/<name>/<hash|latest>/files the files of one version
23
+ * POST /v1/definitions/<name>/<hash>/yank pull a version back (body: optional {reason})
24
+ * POST /v1/definitions/<name>/<hash>/unyank let it go on
25
+ */
26
+ async function handleDefinitions(config, store, proposals, log, principal, req, res, url) {
27
+ const match = /^\/v1\/definitions(?:\/([^/]+)(?:\/([^/]+)(\/files|\/yank|\/unyank)?)?)?$/.exec(url.pathname);
28
+ if (!match) {
29
+ return false;
30
+ }
31
+ const [, name, version, suffix] = match;
32
+ const files = suffix === '/files';
33
+ const grant = (0, policy_1.getGrant)(config.entitlements, principal);
34
+ if (name !== undefined && !server_config_1.definitionName.safeParse(name).success) {
35
+ (0, http_1.send)(res, 404, { error: 'not found' });
36
+ return true;
37
+ }
38
+ if (name !== undefined && version === undefined && req.method === 'PUT') {
39
+ await publish(config, store, proposals, log, principal, name, req, res);
40
+ return true;
41
+ }
42
+ if ((suffix === '/yank' || suffix === '/unyank') && req.method === 'POST') {
43
+ await yank(config, store, log, principal, name, version, suffix === '/unyank', req, res);
44
+ return true;
45
+ }
46
+ if (req.method !== 'GET') {
47
+ (0, http_1.send)(res, 405, { error: 'method not allowed' }, {
48
+ allow: suffix === '/yank' || suffix === '/unyank' ? 'POST' : name !== undefined && !version ? 'GET, PUT' : 'GET',
49
+ });
50
+ return true;
51
+ }
52
+ // what the caller may not see does not exist for them
53
+ if (!(0, policy_1.maySeeDefinitions)(grant)) {
54
+ (0, http_1.send)(res, 404, { error: 'not found' });
55
+ return true;
56
+ }
57
+ if (name === undefined) {
58
+ (0, http_1.send)(res, 200, {
59
+ definitions: store.names().map((n) => ({ name: n, latest: store.find(n, 'latest')?.hash })),
60
+ });
61
+ return true;
62
+ }
63
+ if (version === undefined) {
64
+ const versions = store.list(name);
65
+ versions.length > 0
66
+ ? (0, http_1.send)(res, 200, { name, latest: versions[0].hash, versions })
67
+ : (0, http_1.send)(res, 404, { error: 'not found' });
68
+ return true;
69
+ }
70
+ const found = version === 'latest' || bundle_1.HASH_PATTERN.test(version) ? store.find(name, version) : undefined;
71
+ if (!found) {
72
+ (0, http_1.send)(res, 404, { error: 'not found' });
73
+ return true;
74
+ }
75
+ if (files) {
76
+ const bundle = await store.files(found);
77
+ bundle ? (0, http_1.send)(res, 200, bundle) : (0, http_1.send)(res, 404, { error: 'not found' });
78
+ }
79
+ else {
80
+ (0, http_1.send)(res, 200, found);
81
+ }
82
+ return true;
83
+ }
84
+ exports.handleDefinitions = handleDefinitions;
85
+ const yankSchema = zod_1.z.object({ reason: zod_1.z.string().max(500).optional() }).strict();
86
+ // Yanking is allowed to who publishes the build file or uses an account the version
87
+ // uses; letting a version go on again is the publisher's call.
88
+ async function yank(config, store, log, principal, name, hash, undo, req, res) {
89
+ const grant = (0, policy_1.getGrant)(config.entitlements, principal);
90
+ const version = bundle_1.HASH_PATTERN.test(hash) ? store.find(name, hash) : undefined;
91
+ if (!version || !(0, policy_1.maySeeDefinitions)(grant)) {
92
+ (0, http_1.send)(res, 404, { error: 'not found' });
93
+ return;
94
+ }
95
+ if (undo ? !(0, policy_1.mayPublish)(grant) : !(0, policy_1.mayYank)(grant, version)) {
96
+ log(`refuse ${undo ? 'unyank' : 'yank'} ${name}@${hash} iss=${principal.iss} sub=${principal.sub}`);
97
+ (0, http_1.send)(res, 403, {
98
+ error: undo
99
+ ? 'a version is let go on by who publishes the build file'
100
+ : 'a version is yanked by who publishes the build file or uses an account it uses',
101
+ });
102
+ return;
103
+ }
104
+ const body = yankSchema.safeParse(req.headers['content-type'] ? await (0, http_1.readJson)(req) : {});
105
+ if (!body.success) {
106
+ const issue = body.error.issues[0];
107
+ (0, http_1.send)(res, 400, { error: `${issue.path.join('.') || 'request'}: ${issue.message}` });
108
+ return;
109
+ }
110
+ const changed = await store.yank(name, hash, principal, { reason: body.data.reason, undo });
111
+ log(`${undo ? 'unyank' : 'yank'} ${name}@${hash} iss=${principal.iss} sub=${principal.sub}${body.data.reason ? ` reason=${(0, oauth_1.quote)(body.data.reason)}` : ''}`);
112
+ (0, http_1.send)(res, 200, changed);
113
+ }
114
+ async function publish(config, store, proposals, log, principal, name, req, res) {
115
+ const grant = (0, policy_1.getGrant)(config.entitlements, principal);
116
+ if (!(0, policy_1.mayPublish)(grant) && !(0, policy_1.mayPropose)(grant)) {
117
+ log(`refuse publish iss=${principal.iss} sub=${principal.sub}: no builds permission`);
118
+ (0, http_1.send)(res, 403, { error: `you may not publish ${name}, it needs builds: propose or publish` });
119
+ return;
120
+ }
121
+ const parsed = bundle_1.bundleSchema.safeParse(await (0, http_1.readJson)(req, MAX_PUBLISH_BYTES));
122
+ if (!parsed.success) {
123
+ const issue = parsed.error.issues[0];
124
+ (0, http_1.send)(res, 400, { error: `${issue.path.join('.') || 'request'}: ${issue.message}` });
125
+ return;
126
+ }
127
+ try {
128
+ // a version can't run before it is approved, but it has to load, as it would
129
+ // have when published, so the approver is not asked about a broken one
130
+ const { version, existing } = await store.prepare(name, parsed.data, principal, {
131
+ noRemote: !(0, policy_1.mayPublish)(grant),
132
+ });
133
+ if (existing) {
134
+ (0, http_1.send)(res, 200, { ...existing, created: false });
135
+ return;
136
+ }
137
+ // a version that names an account is published by somebody who uses it; anyone
138
+ // else proposes it, and somebody who does approves it (four eyes)
139
+ const missing = (version.accounts ?? []).filter((account) => !(0, policy_1.canUse)(grant, account));
140
+ if (!(0, policy_1.mayPublish)(grant) || missing.length > 0) {
141
+ const { proposal, created } = proposals.propose(version, parsed.data, principal, missing, store.find(name, 'latest')?.hash ?? null);
142
+ log(`propose ${name} ${proposal.hash} iss=${principal.iss} sub=${principal.sub} created=${created} tasks=${proposal.tasks.length} missing=[${missing.join(',')}]`);
143
+ (0, http_1.send)(res, created ? 202 : 200, { ...proposal, created });
144
+ return;
145
+ }
146
+ const { version: published, created } = await store.publish(name, parsed.data, principal);
147
+ log(`publish ${name} ${published.hash} iss=${principal.iss} sub=${principal.sub} created=${created} tasks=${published.tasks.length}`);
148
+ (0, http_1.send)(res, created ? 201 : 200, { ...published, created });
149
+ }
150
+ catch (e) {
151
+ if (e instanceof definitions_1.DefinitionError) {
152
+ log(`refuse publish iss=${principal.iss} sub=${principal.sub}: ${name} does not load`);
153
+ (0, http_1.send)(res, 400, { error: `the build file does not load: ${e.message}` });
154
+ return;
155
+ }
156
+ if (e instanceof proposals_1.ProposalError) {
157
+ (0, http_1.send)(res, e.status, { error: e.message });
158
+ return;
159
+ }
160
+ throw e;
161
+ }
162
+ }
163
+ //# sourceMappingURL=definitions-api.js.map