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.
- package/build.schema.json +10 -0
- package/dist/cli.js +6 -0
- package/dist/executer/execute-command.js +7 -1
- package/dist/optimizer/get-cache-directory.js +5 -0
- package/dist/planner/utils/append-work-task.js +1 -0
- package/dist/planner/work-secret.js +6 -2
- package/dist/program.js +6 -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 +158 -0
- package/dist/remote/remote-work-commands.js +224 -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 +9 -2
- package/dist/schema/runner-schema.js +13 -0
- package/dist/secrets/provider-values.js +4 -0
- package/dist/server/accounts-api.js +42 -0
- package/dist/server/approvals-api.js +104 -0
- package/dist/server/definitions-api.js +163 -0
- package/dist/server/definitions.js +160 -0
- package/dist/server/discovery.js +9 -0
- package/dist/server/executor.js +237 -0
- package/dist/server/git-checkout.js +115 -0
- package/dist/server/http.js +39 -0
- package/dist/server/oauth.js +226 -0
- package/dist/server/plan.js +296 -0
- package/dist/server/policy.js +138 -0
- package/dist/server/proposals-api.js +145 -0
- package/dist/server/proposals.js +173 -0
- package/dist/server/repo-access.js +127 -0
- package/dist/server/repo.js +94 -0
- package/dist/server/run-environment.js +20 -0
- package/dist/server/run-plan.js +44 -0
- package/dist/server/run-request.js +85 -0
- package/dist/server/run-store.js +61 -0
- package/dist/server/run-worker-command.js +17 -0
- package/dist/server/run-worker-main.js +7 -0
- package/dist/server/run-worker.js +179 -0
- package/dist/server/runs.js +432 -0
- package/dist/server/server-command.js +61 -0
- package/dist/server/server-config.js +394 -0
- package/dist/server/server.js +434 -0
- package/dist/server/sources-api.js +106 -0
- package/dist/server/sources.js +258 -0
- 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
|
-
|
|
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 =
|
|
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
|