hammerkit 1.27.0-next.2 → 1.27.0-next.21
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 +27 -0
- package/dist/cache/backends/registry-cache-backend.js +2 -2
- package/dist/cache/explain.js +17 -0
- package/dist/cli.js +1 -0
- package/dist/docker/item-network.js +13 -5
- package/dist/executer/deadline.js +17 -3
- package/dist/executer/docker-service.js +30 -8
- package/dist/executer/event-cache.js +20 -2
- package/dist/executer/execute-work-task.js +5 -3
- package/dist/executer/execution-steps.js +25 -4
- package/dist/executer/reset-work-tree.js +4 -0
- package/dist/executer/scheduler/enqueue-next.js +7 -0
- package/dist/executer/scheduler/state-key.js +42 -5
- package/dist/harness/connect.js +20 -6
- package/dist/harness/mcp-command.js +22 -6
- package/dist/harness/mcp.js +63 -114
- package/dist/harness/opencode.js +158 -50
- package/dist/harness/pi.js +29 -0
- package/dist/harness/sandbox-client.js +207 -65
- package/dist/harness/service-tools.js +141 -0
- package/dist/harness/task-tools.js +221 -0
- package/dist/kubernetes/apply.js +27 -16
- package/dist/kubernetes/claim-layout.js +71 -0
- package/dist/kubernetes/ensure-http-route.js +1 -1
- package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +5 -9
- package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +1 -1
- package/dist/kubernetes/ensure-kubernetes-service-exists.js +6 -9
- package/dist/kubernetes/ensure-persistent-data.js +30 -20
- package/dist/kubernetes/get-service-ip.js +2 -2
- package/dist/kubernetes/job-secrets.js +33 -19
- package/dist/kubernetes/kept-claims.js +22 -10
- package/dist/kubernetes/network-policy.js +85 -27
- package/dist/kubernetes/remove-persistent-data.js +3 -1
- package/dist/kubernetes/resources.js +25 -3
- package/dist/kubernetes/restore-kubernetes-data.js +9 -3
- package/dist/kubernetes/sandbox-runner.js +626 -0
- package/dist/kubernetes/secrets.js +7 -11
- package/dist/kubernetes/volumes.js +6 -3
- package/dist/network/egress-proxy.js +45 -6
- package/dist/optimizer/calculate-checksum.js +3 -2
- package/dist/optimizer/check-key.js +94 -0
- package/dist/optimizer/check-keys.js +64 -0
- package/dist/optimizer/get-work-cache-stats.js +17 -3
- package/dist/optimizer/work-task-cache-description.js +51 -1
- package/dist/planner/utils/append-work-dependencies.js +10 -5
- package/dist/planner/utils/append-work-service.js +1 -0
- package/dist/planner/utils/append-work-task.js +15 -2
- package/dist/planner/utils/get-work-runtime.js +6 -1
- package/dist/planner/utils/normalize-path.js +19 -1
- package/dist/planner/utils/parse-work-mount.js +7 -1
- package/dist/planner/utils/parse-work-port.js +4 -1
- package/dist/planner/utils/task-arguments.js +3 -0
- package/dist/planner/validate.js +22 -0
- package/dist/planner/work-network.js +3 -1
- package/dist/planner/work-runtime-aggregate.js +32 -0
- package/dist/planner/work-runtime-docker.js +2 -1
- package/dist/planner/work-runtime-kubernetes.js +97 -48
- package/dist/planner/work-runtime-local.js +2 -1
- package/dist/planner/work-task.js +6 -1
- package/dist/program.js +35 -13
- package/dist/remote/annotations.js +13 -1
- package/dist/remote/device-login.js +7 -2
- package/dist/remote/host-config.js +64 -3
- package/dist/remote/remote-commands.js +41 -9
- package/dist/remote/remote-work-commands.js +246 -1
- package/dist/remote/server-client.js +15 -2
- package/dist/remote/token-source.js +64 -26
- package/dist/run-program.js +9 -0
- package/dist/schema/build-file-base-task-schema.js +5 -1
- package/dist/schema/build-file-container-service-schema.js +2 -0
- package/dist/schema/build-file-include-schema.js +10 -4
- package/dist/schema/reference-parser.js +9 -1
- package/dist/schema/schema-parser.js +36 -4
- package/dist/schema/work-scope-parser.js +33 -10
- package/dist/secrets/github-app-token-command.js +3 -1
- package/dist/server/accounts-api.js +3 -1
- package/dist/server/approvals-api.js +1 -1
- package/dist/server/checks-api.js +83 -0
- package/dist/server/checks.js +116 -0
- package/dist/server/definitions-api.js +4 -2
- package/dist/server/endpoints.js +612 -0
- package/dist/server/executor.js +56 -5
- package/dist/server/kubernetes-backend.js +303 -64
- package/dist/server/kubernetes-sandbox-storage.js +48 -3
- package/dist/server/oauth.js +12 -1
- package/dist/server/plan.js +126 -62
- package/dist/server/policy.js +32 -2
- package/dist/server/repo-access.js +5 -0
- package/dist/server/run-plan.js +50 -19
- package/dist/server/run-request.js +64 -3
- package/dist/server/run-worker.js +146 -10
- package/dist/server/runs.js +347 -27
- package/dist/server/sandbox-pod.js +28 -0
- package/dist/server/sandbox-storage.js +23 -0
- package/dist/server/sandboxes-api.js +10 -4
- package/dist/server/sandboxes.js +8 -0
- package/dist/server/server-command.js +39 -20
- package/dist/server/server-config.js +213 -14
- package/dist/server/server.js +268 -15
- package/dist/server/service-runs.js +82 -0
- package/dist/utils/abort-on-signals.js +46 -6
- package/dist/utils/tar.js +36 -0
- package/package.json +1 -1
package/dist/run-program.js
CHANGED
|
@@ -7,6 +7,8 @@ exports.runProgram = void 0;
|
|
|
7
7
|
const program_1 = require("./program");
|
|
8
8
|
const parse_error_1 = require("./schema/parse-error");
|
|
9
9
|
const colors_1 = __importDefault(require("colors"));
|
|
10
|
+
const commander_1 = require("commander");
|
|
11
|
+
const log_1 = require("./log");
|
|
10
12
|
function runProgram(env, argv, exitOverride) {
|
|
11
13
|
return (0, program_1.getProgram)(env, argv)
|
|
12
14
|
.then(({ program, args }) => {
|
|
@@ -18,8 +20,15 @@ function runProgram(env, argv, exitOverride) {
|
|
|
18
20
|
for (const error of err.zod.errors) {
|
|
19
21
|
env.stderr.write(` ${colors_1.default.underline(colors_1.default.blue(`error`))} at ${colors_1.default.gray(error.path.join('.'))} ${error.message} ${colors_1.default.gray(error.code)}\n`);
|
|
20
22
|
}
|
|
23
|
+
// printed, but still a failure: CI must not pass on a build file that doesn't parse
|
|
24
|
+
throw err;
|
|
21
25
|
}
|
|
22
26
|
else {
|
|
27
|
+
// what commander reports (program.error, a wrong option) is printed already; anything
|
|
28
|
+
// else, such as a build file that is no YAML, would otherwise end the process silently
|
|
29
|
+
if (!(err instanceof commander_1.CommanderError)) {
|
|
30
|
+
env.stderr.write(`${colors_1.default.red('error')}: ${(0, log_1.getErrorMessage)(err)}\n`);
|
|
31
|
+
}
|
|
23
32
|
throw err;
|
|
24
33
|
}
|
|
25
34
|
});
|
|
@@ -40,8 +40,12 @@ exports.buildFileBaseTaskSchema = (0, zod_1.object)({
|
|
|
40
40
|
account: (0, zod_1.string)().optional(),
|
|
41
41
|
// CPU and memory limits of the container; a local task is not limited
|
|
42
42
|
resources: resources_schema_1.resourcesSchema.optional(),
|
|
43
|
-
// what the container reaches besides its needs; a local task can
|
|
43
|
+
// what the container reaches besides its needs; a local task can be limited only where
|
|
44
|
+
// it runs in a container, on a server's cluster
|
|
44
45
|
network: network_schema_1.networkSchema.optional(),
|
|
46
|
+
// whether its last command takes the arguments after `--`: false refuses them, true
|
|
47
|
+
// says it is meant to, which a server with `runs.args: declared` requires
|
|
48
|
+
args: (0, zod_1.boolean)().optional(),
|
|
45
49
|
// Name of the server (see `hammerkit remote`) the task has to run on; defaults
|
|
46
50
|
// to the build file's `runner`. Never a local fallback when it is unknown.
|
|
47
51
|
runner: runner_schema_1.runnerSchema.optional(),
|
|
@@ -45,6 +45,8 @@ exports.buildFileContainerServiceSchema = (0, zod_1.object)({
|
|
|
45
45
|
volumeSize: size_schema_1.sizeSchema.describe('size of its claim on Kubernetes, e.g. 10Gi').optional(),
|
|
46
46
|
// what the service reaches besides its needs; tasks needing it always reach it
|
|
47
47
|
network: network_schema_1.networkSchema.optional(),
|
|
48
|
+
// whether its `cmd` takes the arguments after `--` of `up`: false refuses them
|
|
49
|
+
args: (0, zod_1.boolean)().optional(),
|
|
48
50
|
})
|
|
49
51
|
.strict()
|
|
50
52
|
.describe('container service');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.isGitSource = exports.buildFileIncludeSchema = exports.buildFileGitSourceSchema = void 0;
|
|
3
|
+
exports.isGitSource = exports.buildFileIncludeSchema = exports.buildFileLocalSourceSchema = exports.buildFileGitSourceSchema = void 0;
|
|
4
4
|
const zod_1 = require("zod");
|
|
5
5
|
const envs_schema_1 = require("./envs-schema");
|
|
6
6
|
// A repo URL or ref starting with `-` would be read by git as an option
|
|
@@ -22,10 +22,16 @@ exports.buildFileGitSourceSchema = (0, zod_1.object)({
|
|
|
22
22
|
// overrides for the top-level `envs` the included file declares as its inputs
|
|
23
23
|
with: envs_schema_1.envsSchema.optional(),
|
|
24
24
|
}).strict();
|
|
25
|
-
// A
|
|
26
|
-
|
|
25
|
+
// A local path with values for the inputs of the file it names, like `with` of a git
|
|
26
|
+
// source. A value may pass on an `envs` value of the including file as `${NAME}`.
|
|
27
|
+
exports.buildFileLocalSourceSchema = (0, zod_1.object)({
|
|
28
|
+
path: (0, zod_1.string)().min(1).describe('Build file or directory, relative to the including file'),
|
|
29
|
+
with: envs_schema_1.envsSchema.optional(),
|
|
30
|
+
}).strict();
|
|
31
|
+
// A string is a local path; an object is a local path with inputs, or a git source (ADR-0004).
|
|
32
|
+
exports.buildFileIncludeSchema = (0, zod_1.union)([(0, zod_1.string)(), exports.buildFileLocalSourceSchema, exports.buildFileGitSourceSchema]);
|
|
27
33
|
function isGitSource(include) {
|
|
28
|
-
return typeof include !== 'string';
|
|
34
|
+
return typeof include !== 'string' && 'git' in include;
|
|
29
35
|
}
|
|
30
36
|
exports.isGitSource = isGitSource;
|
|
31
37
|
//# sourceMappingURL=build-file-include-schema.js.map
|
|
@@ -129,6 +129,7 @@ function applySchemaExtension(reference, task) {
|
|
|
129
129
|
task.schema.timeout = task.schema.timeout ?? extend.schema.timeout;
|
|
130
130
|
task.schema.runner = task.schema.runner ?? extend.schema.runner;
|
|
131
131
|
task.schema.network = task.schema.network ?? extend.schema.network;
|
|
132
|
+
task.schema.args = task.schema.args ?? extend.schema.args;
|
|
132
133
|
if (!reset.has('cmds'))
|
|
133
134
|
task.schema.cmds = extendArray(task.schema.cmds, extend.schema.cmds);
|
|
134
135
|
if (!reset.has('src'))
|
|
@@ -209,7 +210,9 @@ function resolveNeeds(reference, taskOrService, scope, schema) {
|
|
|
209
210
|
for (const needName of schema.needs || []) {
|
|
210
211
|
if (typeof needName === 'string') {
|
|
211
212
|
taskOrService.needs.push({
|
|
212
|
-
|
|
213
|
+
// the host name it is reached by: a service of an include, `services:postgres`, as
|
|
214
|
+
// `services-postgres`, since Docker and DNS take no colon
|
|
215
|
+
relativeName: needName.replace(/:/g, '-'),
|
|
213
216
|
service: findService(reference, scope, needName),
|
|
214
217
|
});
|
|
215
218
|
}
|
|
@@ -220,6 +223,11 @@ function resolveNeeds(reference, taskOrService, scope, schema) {
|
|
|
220
223
|
});
|
|
221
224
|
}
|
|
222
225
|
}
|
|
226
|
+
const names = taskOrService.needs.map((need) => need.relativeName);
|
|
227
|
+
const twice = names.find((name, index) => names.indexOf(name) !== index);
|
|
228
|
+
if (twice) {
|
|
229
|
+
throw new Error(`${taskOrService.relativeName} needs two services as ${twice}, give one another name with name:`);
|
|
230
|
+
}
|
|
223
231
|
}
|
|
224
232
|
function findTask(ctx, scope, name, type) {
|
|
225
233
|
const ref = (0, split_name_1.splitName)(name);
|
|
@@ -89,13 +89,45 @@ async function locateInclude(ctx, scope, include, base, environment) {
|
|
|
89
89
|
if (!(0, resolve_git_source_1.isWithin)(remote.root, path)) {
|
|
90
90
|
throw new Error(`${include.path} in ${include.git} points outside the repository`);
|
|
91
91
|
}
|
|
92
|
-
return { fileName: await (0, default_build_file_1.getBuildFilename)(path, environment), remote, inputs: include.with };
|
|
92
|
+
return { fileName: await (0, default_build_file_1.getBuildFilename)(path, environment), remote, inputs: passEnvs(include.with, scope) };
|
|
93
93
|
}
|
|
94
|
-
const
|
|
94
|
+
const localPath = typeof include === 'string' ? include : include.path;
|
|
95
|
+
const path = (0, path_1.join)(base, localPath);
|
|
95
96
|
if (scope.remote && !(0, resolve_git_source_1.isWithin)(scope.remote.root, path)) {
|
|
96
|
-
throw new Error(`${
|
|
97
|
+
throw new Error(`${localPath} in ${scope.fileName} points outside ${scope.remote.git}`);
|
|
97
98
|
}
|
|
98
|
-
|
|
99
|
+
const inputs = typeof include === 'string' ? undefined : passEnvs(include.with, scope);
|
|
100
|
+
return { fileName: await (0, default_build_file_1.getBuildFilename)(path, environment), remote: scope.remote, inputs };
|
|
101
|
+
}
|
|
102
|
+
// `${NAME}` (or `${NAME:-default}`) in a `with` value reads as it does in the including
|
|
103
|
+
// file: the environment or a `.env` file first, then that file's own `envs` value. So one
|
|
104
|
+
// file holds a value such as NODE_VERSION for all it includes, and CI can still set it. A
|
|
105
|
+
// name the file does not declare is left to the included file, which reads it the same way.
|
|
106
|
+
function passEnvs(inputs, scope) {
|
|
107
|
+
if (!inputs) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
const envs = scope.schema.envs ?? {};
|
|
111
|
+
const result = {};
|
|
112
|
+
for (const [key, value] of Object.entries(inputs)) {
|
|
113
|
+
result[key] =
|
|
114
|
+
typeof value === 'string'
|
|
115
|
+
? value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (match, name, fallback) => name in envs ? passEnv(name, `${envs[name]}`, fallback) : match)
|
|
116
|
+
: value;
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
// The including file's value as an interpolation the included file resolves in the same
|
|
121
|
+
// order. A value built from other variables is passed on as it is written.
|
|
122
|
+
function passEnv(name, value, fallback) {
|
|
123
|
+
const reference = /^\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(value);
|
|
124
|
+
if (reference) {
|
|
125
|
+
return fallback === undefined ? `\${${reference[1]}}` : `\${${reference[1]}:-${fallback}}`;
|
|
126
|
+
}
|
|
127
|
+
if (value.includes('$') || value.includes('}')) {
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
return `\${${name}:-${value}}`;
|
|
99
131
|
}
|
|
100
132
|
// `includes pull` fetches each source once, however often the build files name it.
|
|
101
133
|
async function resolveRemote(ctx, source, environment) {
|
|
@@ -47,19 +47,27 @@ function getWorkContext(context, scope, environment) {
|
|
|
47
47
|
// its init task's need on it, which narrowing follows
|
|
48
48
|
const tasks = Object.values(context.tasks).map((task) => (0, append_work_task_1.appendWorkTask)(workTree, task.cwd, task, environment, context));
|
|
49
49
|
const services = Object.values(context.services).map((service) => (0, append_work_service_1.appendWorkService)(workTree, service, environment, context));
|
|
50
|
+
const apply = { servicesAlone: !(0, work_scope_1.isContextTaskFilter)(scope) && !!scope.names && !!scope.stop };
|
|
50
51
|
const selectedTasks = [];
|
|
51
52
|
const selectedServices = [];
|
|
52
53
|
for (const item of tasks) {
|
|
53
54
|
if (matchesTaskWorkScope(item, scope)) {
|
|
54
55
|
requested.push(item.name);
|
|
55
56
|
selectedTasks.push(item.data);
|
|
56
|
-
applyTask(filteredWorkTree, item);
|
|
57
|
+
applyTask(filteredWorkTree, item, apply);
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
for (const item of services) {
|
|
60
61
|
if (matchesServiceWorkScope(item.data, scope)) {
|
|
61
62
|
selectedServices.push(item.data);
|
|
62
|
-
applyService(filteredWorkTree, item);
|
|
63
|
+
applyService(filteredWorkTree, item, apply);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (!(0, work_scope_1.isContextTaskFilter)(scope) && scope.names) {
|
|
67
|
+
const known = new Set([...tasks.map((task) => task.name), ...services.map((service) => service.data.name)]);
|
|
68
|
+
const unknown = scope.names.filter((name) => !known.has(name));
|
|
69
|
+
if (unknown.length > 0) {
|
|
70
|
+
throw new Error(`no task or service named ${unknown.join(', ')}, see hammerkit ls`);
|
|
63
71
|
}
|
|
64
72
|
}
|
|
65
73
|
if (scope.args) {
|
|
@@ -86,43 +94,58 @@ function keepRequirersInScope(workTree) {
|
|
|
86
94
|
}
|
|
87
95
|
}
|
|
88
96
|
}
|
|
89
|
-
function applyTask(workTree, item) {
|
|
97
|
+
function applyTask(workTree, item, options) {
|
|
90
98
|
if (workTree.tasks[item.name]) {
|
|
91
99
|
return;
|
|
92
100
|
}
|
|
93
101
|
workTree.tasks[item.name] = item;
|
|
94
|
-
applyNeedsAndDeps(workTree, item);
|
|
102
|
+
applyNeedsAndDeps(workTree, item, options);
|
|
95
103
|
}
|
|
96
|
-
function applyService(workTree, need) {
|
|
104
|
+
function applyService(workTree, need, options) {
|
|
97
105
|
const item = (0, append_work_service_1.getPlannedService)(need);
|
|
98
106
|
if (workTree.services[item.name]) {
|
|
99
107
|
return;
|
|
100
108
|
}
|
|
101
109
|
workTree.services[item.name] = item;
|
|
102
|
-
|
|
110
|
+
if (options.servicesAlone) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
applyNeedsAndDeps(workTree, item, options);
|
|
103
114
|
// a service in the run brings its init task, which runs once it is healthy
|
|
104
115
|
if (item.data.type === 'container-service' && item.data.init) {
|
|
105
|
-
applyTask(workTree, item.data.init.task);
|
|
116
|
+
applyTask(workTree, item.data.init.task, options);
|
|
106
117
|
}
|
|
107
118
|
}
|
|
108
|
-
function applyNeedsAndDeps(workTree, item) {
|
|
119
|
+
function applyNeedsAndDeps(workTree, item, options) {
|
|
109
120
|
for (const dep of item.deps) {
|
|
110
|
-
applyTask(workTree, dep);
|
|
121
|
+
applyTask(workTree, dep, options);
|
|
111
122
|
}
|
|
112
123
|
for (const need of item.needs) {
|
|
113
|
-
applyService(workTree, need.service);
|
|
124
|
+
applyService(workTree, need.service, options);
|
|
114
125
|
}
|
|
115
126
|
}
|
|
116
127
|
function matchesTaskWorkScope(task, scope) {
|
|
117
128
|
if ((0, work_scope_1.isContextTaskFilter)(scope)) {
|
|
118
129
|
return task.name === scope.taskName;
|
|
119
130
|
}
|
|
131
|
+
if (scope.serviceNames) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
if (scope.names) {
|
|
135
|
+
return scope.names.includes(task.name);
|
|
136
|
+
}
|
|
120
137
|
return (0, label_values_1.appliesToLabels)(task.data.labels, scope);
|
|
121
138
|
}
|
|
122
139
|
function matchesServiceWorkScope(service, scope) {
|
|
123
140
|
if ((0, work_scope_1.isContextTaskFilter)(scope)) {
|
|
124
141
|
return false;
|
|
125
142
|
}
|
|
143
|
+
if (scope.serviceNames) {
|
|
144
|
+
return scope.serviceNames.includes(service.name);
|
|
145
|
+
}
|
|
146
|
+
if (scope.names) {
|
|
147
|
+
return scope.names.includes(service.name);
|
|
148
|
+
}
|
|
126
149
|
return (0, label_values_1.appliesToLabels)(service.labels, scope);
|
|
127
150
|
}
|
|
128
151
|
//# sourceMappingURL=work-scope-parser.js.map
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.registerGithubAppTokenCommand = void 0;
|
|
4
|
+
const commander_1 = require("commander");
|
|
4
5
|
const log_1 = require("../log");
|
|
5
6
|
const github_app_token_1 = require("./github-app-token");
|
|
6
7
|
// `hammerkit github-app-token <owner/repo>`: a secret provider command for the token a
|
|
@@ -18,7 +19,8 @@ function registerGithubAppTokenCommand(program, environment) {
|
|
|
18
19
|
}
|
|
19
20
|
catch (e) {
|
|
20
21
|
environment.stderr.write(`github-app-token: ${(0, log_1.getErrorMessage)(e)}\n`);
|
|
21
|
-
|
|
22
|
+
// said already: the CLI does not print it again
|
|
23
|
+
throw new commander_1.CommanderError(1, 'hammerkit.githubAppToken', (0, log_1.getErrorMessage)(e));
|
|
22
24
|
}
|
|
23
25
|
});
|
|
24
26
|
}
|
|
@@ -6,7 +6,7 @@ const policy_1 = require("./policy");
|
|
|
6
6
|
/**
|
|
7
7
|
* Who the caller is and what they may do:
|
|
8
8
|
*
|
|
9
|
-
* GET /v1/me identity, accounts with their level, repositories, the builds level
|
|
9
|
+
* GET /v1/me identity, accounts with their level, repositories, the builds level the sandbox and endpoint grants
|
|
10
10
|
* GET /v1/accounts the accounts the caller has a level on, with what the operator says about them
|
|
11
11
|
*/
|
|
12
12
|
async function handleAccounts(config, log, principal, req, res, url) {
|
|
@@ -31,7 +31,9 @@ async function handleAccounts(config, log, principal, req, res, url) {
|
|
|
31
31
|
accounts,
|
|
32
32
|
repos: grant.repos,
|
|
33
33
|
builds: grant.builds,
|
|
34
|
+
checks: grant.checks || grant.builds !== null ? 'read' : null,
|
|
34
35
|
sandboxes: grant.sandboxes,
|
|
36
|
+
endpoints: grant.endpoints,
|
|
35
37
|
});
|
|
36
38
|
return true;
|
|
37
39
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.handleChecks = exports.MAX_LOOKUP_KEYS = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const http_1 = require("./http");
|
|
6
|
+
const policy_1 = require("./policy");
|
|
7
|
+
const checks_1 = require("./checks");
|
|
8
|
+
const repo_1 = require("./repo");
|
|
9
|
+
// the tasks one lookup may ask about
|
|
10
|
+
exports.MAX_LOOKUP_KEYS = 100;
|
|
11
|
+
const lookupSchema = zod_1.z
|
|
12
|
+
.object({
|
|
13
|
+
repo: zod_1.z.string().transform((value, ctx) => {
|
|
14
|
+
const repo = (0, repo_1.normalizeRepo)(value);
|
|
15
|
+
if (!repo) {
|
|
16
|
+
ctx.addIssue({ code: zod_1.z.ZodIssueCode.custom, message: 'expected a repository like github.com/corp/payments' });
|
|
17
|
+
return zod_1.z.NEVER;
|
|
18
|
+
}
|
|
19
|
+
return repo;
|
|
20
|
+
}),
|
|
21
|
+
keys: zod_1.z
|
|
22
|
+
.array(zod_1.z
|
|
23
|
+
.object({
|
|
24
|
+
task: zod_1.z.string().min(1).max(200),
|
|
25
|
+
key: zod_1.z.string().regex(checks_1.CHECK_KEY_PATTERN, 'expected a check key, 64 hex digits'),
|
|
26
|
+
})
|
|
27
|
+
.strict())
|
|
28
|
+
.min(1)
|
|
29
|
+
.max(exports.MAX_LOOKUP_KEYS),
|
|
30
|
+
})
|
|
31
|
+
.strict();
|
|
32
|
+
/**
|
|
33
|
+
* What runs passed, for CI that verifies instead of running (`hammerkit remote verify`):
|
|
34
|
+
*
|
|
35
|
+
* POST /v1/checks/lookup { repo, keys: [{ task, key }] } the newest passed run per key
|
|
36
|
+
*
|
|
37
|
+
* The caller computed the keys from its own checkout (check-key.ts); a key nothing passed
|
|
38
|
+
* for, or whose version of the build file was yanked since, is answered with none.
|
|
39
|
+
*/
|
|
40
|
+
async function handleChecks(config, checks, definitions, log, principal, req, res, url) {
|
|
41
|
+
if (url.pathname !== '/v1/checks/lookup') {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
if (req.method !== 'POST') {
|
|
45
|
+
(0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'POST' });
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
const parsed = lookupSchema.safeParse(await (0, http_1.readJson)(req));
|
|
49
|
+
if (!parsed.success) {
|
|
50
|
+
const issue = parsed.error.issues[0];
|
|
51
|
+
(0, http_1.send)(res, 400, { error: `${issue.path.join('.') || 'request'}: ${issue.message}` });
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
const { repo, keys } = parsed.data;
|
|
55
|
+
const grant = (0, policy_1.getGrant)(config.entitlements, principal);
|
|
56
|
+
if (!(0, policy_1.mayReadChecks)(grant, repo)) {
|
|
57
|
+
log(`refuse checks iss=${principal.iss} sub=${principal.sub} repo=${repo}`);
|
|
58
|
+
(0, http_1.send)(res, 403, { error: `you may not read the checks of ${repo}` });
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
const answers = await Promise.all(keys.map(async ({ task, key }) => {
|
|
62
|
+
const record = await checks.find(repo, key);
|
|
63
|
+
const yanked = record ? definitions.find(record.definition)?.yanked : undefined;
|
|
64
|
+
if (!record || yanked) {
|
|
65
|
+
return {
|
|
66
|
+
task,
|
|
67
|
+
key,
|
|
68
|
+
passed: null,
|
|
69
|
+
...(yanked ? { reason: `it passed with ${record?.definition}, which was yanked since` } : {}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const { repo: _repo, key: _key, accounts: _accounts, ...view } = record;
|
|
73
|
+
void _repo;
|
|
74
|
+
void _key;
|
|
75
|
+
void _accounts;
|
|
76
|
+
return { task, key, passed: view };
|
|
77
|
+
}));
|
|
78
|
+
log(`checks iss=${principal.iss} sub=${principal.sub} repo=${repo} passed=${answers.filter((a) => a.passed).length}/${answers.length}`);
|
|
79
|
+
(0, http_1.send)(res, 200, { checks: answers });
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
exports.handleChecks = handleChecks;
|
|
83
|
+
//# sourceMappingURL=checks-api.js.map
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CheckStore = exports.getCheckRecords = exports.isCheckRun = exports.CHECK_KEY_PATTERN = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const promises_1 = require("fs/promises");
|
|
6
|
+
const path_1 = require("path");
|
|
7
|
+
exports.CHECK_KEY_PATTERN = /^[0-9a-f]{64}$/;
|
|
8
|
+
// The runs whose results are checks: those of a commit the server checked out, and those
|
|
9
|
+
// on a copy of the files of a sandbox, which hold nothing a commit of them would not. A run
|
|
10
|
+
// with arguments ran something other than the task, and is no check of it.
|
|
11
|
+
function isCheckRun(run) {
|
|
12
|
+
// a run of services keeps them up, it passes nothing
|
|
13
|
+
if ((run.options.args ?? []).length > 0 || run.up) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return run.source.type === 'ref' || (run.source.type === 'sandbox' && run.source.check === true);
|
|
17
|
+
}
|
|
18
|
+
exports.isCheckRun = isCheckRun;
|
|
19
|
+
// What a run that ended passed, with the keys its executor reported before the tasks ran.
|
|
20
|
+
function getCheckRecords(run, keys) {
|
|
21
|
+
if (!isCheckRun(run) || run.source.type === 'upload') {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
const source = run.source.type === 'ref'
|
|
25
|
+
? { type: 'ref', ref: run.source.ref, commit: run.source.commit }
|
|
26
|
+
: { type: 'sandbox', sandbox: run.source.id, commit: run.source.commit, tree: run.source.tree };
|
|
27
|
+
const records = [];
|
|
28
|
+
for (const [task, result] of Object.entries(run.results ?? {})) {
|
|
29
|
+
const key = keys[task];
|
|
30
|
+
if (!key || !exports.CHECK_KEY_PATTERN.test(key) || (result.status !== 'succeeded' && result.status !== 'cached')) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
records.push({
|
|
34
|
+
repo: run.source.repo,
|
|
35
|
+
key,
|
|
36
|
+
task,
|
|
37
|
+
status: result.status,
|
|
38
|
+
run: run.id,
|
|
39
|
+
by: { iss: run.owner.iss, sub: run.owner.sub },
|
|
40
|
+
source,
|
|
41
|
+
definition: run.definition,
|
|
42
|
+
accounts: [...run.accounts],
|
|
43
|
+
finishedAt: result.finishedAt ?? run.finishedAt ?? new Date().toISOString(),
|
|
44
|
+
...(result.durationMs !== undefined ? { durationMs: result.durationMs } : {}),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return records;
|
|
48
|
+
}
|
|
49
|
+
exports.getCheckRecords = getCheckRecords;
|
|
50
|
+
/**
|
|
51
|
+
* One file per repository and key, the newest run's: `<directory>/<repo hash>/<key>.json`,
|
|
52
|
+
* written to a temporary file and renamed. A record older than `keep` is not found and is
|
|
53
|
+
* removed by `sweep`.
|
|
54
|
+
*/
|
|
55
|
+
class CheckStore {
|
|
56
|
+
constructor(directory, options) {
|
|
57
|
+
this.directory = directory;
|
|
58
|
+
this.options = options;
|
|
59
|
+
this.now = options.now ?? Date.now;
|
|
60
|
+
}
|
|
61
|
+
async save(records) {
|
|
62
|
+
for (const record of records) {
|
|
63
|
+
const folder = (0, path_1.join)(this.directory, repoFolder(record.repo));
|
|
64
|
+
await (0, promises_1.mkdir)(folder, { recursive: true, mode: 0o700 });
|
|
65
|
+
const file = (0, path_1.join)(folder, `${record.key}.json`);
|
|
66
|
+
const temporary = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
67
|
+
await (0, promises_1.writeFile)(temporary, JSON.stringify(record), { mode: 0o600 });
|
|
68
|
+
await (0, promises_1.rename)(temporary, file);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async find(repo, key) {
|
|
72
|
+
if (!exports.CHECK_KEY_PATTERN.test(key)) {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
let record;
|
|
76
|
+
try {
|
|
77
|
+
record = JSON.parse(await (0, promises_1.readFile)((0, path_1.join)(this.directory, repoFolder(repo), `${key}.json`), 'utf8'));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
if (record.repo !== repo || record.key !== key || this.expired(record)) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
return record;
|
|
86
|
+
}
|
|
87
|
+
// Removes the records that are older than `keep`.
|
|
88
|
+
async sweep() {
|
|
89
|
+
let folders;
|
|
90
|
+
try {
|
|
91
|
+
folders = await (0, promises_1.readdir)(this.directory);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
for (const folder of folders) {
|
|
97
|
+
const path = (0, path_1.join)(this.directory, folder);
|
|
98
|
+
for (const name of await (0, promises_1.readdir)(path).catch(() => [])) {
|
|
99
|
+
const file = (0, path_1.join)(path, name);
|
|
100
|
+
const stats = await (0, promises_1.stat)(file).catch(() => undefined);
|
|
101
|
+
if (stats && this.now() - stats.mtimeMs > this.options.keep) {
|
|
102
|
+
await (0, promises_1.rm)(file, { force: true });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
expired(record) {
|
|
108
|
+
const at = Date.parse(record.finishedAt);
|
|
109
|
+
return !Number.isFinite(at) || this.now() - at > this.options.keep;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
exports.CheckStore = CheckStore;
|
|
113
|
+
function repoFolder(repo) {
|
|
114
|
+
return (0, crypto_1.createHash)('sha256').update(repo).digest('hex').slice(0, 32);
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=checks.js.map
|
|
@@ -5,6 +5,7 @@ const zod_1 = require("zod");
|
|
|
5
5
|
const bundle_1 = require("../remote/bundle");
|
|
6
6
|
const definitions_1 = require("./definitions");
|
|
7
7
|
const http_1 = require("./http");
|
|
8
|
+
const run_plan_1 = require("./run-plan");
|
|
8
9
|
const oauth_1 = require("./oauth");
|
|
9
10
|
const policy_1 = require("./policy");
|
|
10
11
|
const proposals_1 = require("./proposals");
|
|
@@ -19,7 +20,8 @@ const MAX_PUBLISH_BYTES = bundle_1.MAX_BUNDLE_BYTES * 2;
|
|
|
19
20
|
* `annotations`, which are not part of the hash
|
|
20
21
|
* GET /v1/definitions the versions, newest first; `?<key>=<value>`
|
|
21
22
|
* keeps the ones with those annotations
|
|
22
|
-
* GET /v1/definitions/<hash> one version
|
|
23
|
+
* GET /v1/definitions/<hash> one version, with `arguments`: per task null when
|
|
24
|
+
* a run of it may pass arguments, else why not
|
|
23
25
|
* GET /v1/definitions/<hash>/files its files
|
|
24
26
|
* POST /v1/definitions/<hash>/yank pull it back (body: optional {reason})
|
|
25
27
|
* POST /v1/definitions/<hash>/unyank let it go on
|
|
@@ -72,7 +74,7 @@ async function handleDefinitions(config, store, proposals, log, principal, req,
|
|
|
72
74
|
bundle ? (0, http_1.send)(res, 200, bundle) : (0, http_1.send)(res, 404, { error: 'not found' });
|
|
73
75
|
}
|
|
74
76
|
else {
|
|
75
|
-
(0, http_1.send)(res, 200, found);
|
|
77
|
+
(0, http_1.send)(res, 200, found.plan ? { ...found, arguments: (0, run_plan_1.describeArguments)(found.plan, config.runs) } : found);
|
|
76
78
|
}
|
|
77
79
|
return true;
|
|
78
80
|
}
|