hammerkit 1.27.0-next.9 → 2.0.0-next.2
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 +176 -6
- package/dist/cache/backends/local-cache-backend.js +6 -0
- package/dist/cache/backends/registry/registry-client.js +1 -0
- package/dist/cache/cache-sync.js +1 -1
- package/dist/cache/explain.js +17 -0
- package/dist/cli.js +2 -0
- package/dist/dashboard/assets/index-CjnKm5-3.css +2 -0
- package/dist/dashboard/assets/index-zPXa4klZ.js +13 -0
- package/dist/dashboard/favicon.svg +1 -0
- package/dist/dashboard/index.html +15 -0
- package/dist/docker/item-network.js +13 -5
- package/dist/executer/archive-task-entry.js +4 -2
- package/dist/executer/deadline.js +17 -3
- package/dist/executer/deferred-restore.js +36 -0
- package/dist/executer/docker-service.js +30 -8
- package/dist/executer/docker-task.js +21 -1
- package/dist/executer/execute-work-service.js +2 -0
- package/dist/executer/execute-work-task.js +11 -4
- package/dist/executer/execution-steps.js +25 -4
- package/dist/executer/reset-work-tree.js +4 -0
- package/dist/executer/scheduler/enqueue-next.js +57 -6
- package/dist/executer/scheduler/state-key.js +42 -5
- package/dist/executer/watch-loop.js +6 -3
- 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 +194 -23
- package/dist/harness/service-tools.js +141 -0
- package/dist/harness/task-tools.js +221 -0
- package/dist/kubernetes/apply.js +49 -1
- package/dist/kubernetes/await-running-state.js +172 -13
- package/dist/kubernetes/cluster-state.js +245 -0
- package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +133 -9
- package/dist/kubernetes/ensure-kubernetes-service-exists.js +2 -0
- package/dist/kubernetes/ensure-persistent-data.js +211 -6
- package/dist/kubernetes/follow-pod-logs.js +182 -0
- package/dist/kubernetes/job-placement.js +36 -12
- package/dist/kubernetes/kubernetes-instance.js +1 -0
- package/dist/kubernetes/network-policy.js +2 -1
- package/dist/kubernetes/remove-stale-items.js +46 -0
- package/dist/kubernetes/restore-kubernetes-data.js +9 -5
- package/dist/kubernetes/sandbox-runner.js +628 -0
- package/dist/kubernetes/service-rollouts.js +74 -0
- package/dist/kubernetes/store-kubernetes-data.js +5 -2
- package/dist/kubernetes/volumes.js +94 -9
- package/dist/main.js +2 -0
- 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 +49 -1
- package/dist/planner/service-definition.js +9 -4
- package/dist/planner/utils/append-work-dependencies.js +5 -3
- package/dist/planner/utils/append-work-service.js +18 -1
- package/dist/planner/utils/append-work-task.js +13 -3
- 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/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-kubernetes.js +136 -13
- package/dist/planner/work-task.js +6 -1
- package/dist/program.js +27 -9
- package/dist/remote/device-login.js +7 -2
- package/dist/remote/host-config.js +69 -3
- package/dist/remote/remote-commands.js +33 -7
- package/dist/remote/remote-status.js +112 -0
- package/dist/remote/remote-work-commands.js +562 -45
- 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 +3 -3
- package/dist/schema/build-file-container-service-schema.js +10 -0
- package/dist/schema/build-file-container-task-schema.js +16 -1
- package/dist/schema/build-file-include-schema.js +18 -4
- package/dist/schema/build-file-kubernetes-service-schema.js +2 -0
- package/dist/schema/build-file-task-schema.js +5 -1
- package/dist/schema/capabilities-schema.js +56 -0
- package/dist/schema/reference-parser.js +119 -11
- package/dist/schema/removed-properties.js +20 -0
- package/dist/schema/schema-parser.js +94 -41
- package/dist/schema/work-scope-parser.js +33 -10
- package/dist/secrets/command-secret-provider.js +3 -1
- package/dist/secrets/github-app-token-command.js +3 -1
- package/dist/secrets/secret-catalog.js +43 -19
- package/dist/server/accounts-api.js +4 -1
- package/dist/server/approvals-api.js +1 -1
- package/dist/server/capacity-format.js +36 -0
- package/dist/server/capacity.js +300 -0
- package/dist/server/checks-api.js +83 -0
- package/dist/server/checks.js +116 -0
- package/dist/server/dashboard.js +196 -0
- package/dist/server/definitions-api.js +66 -3
- package/dist/server/endpoints.js +794 -0
- package/dist/server/executor.js +164 -9
- package/dist/server/kubernetes-backend.js +415 -48
- package/dist/server/kubernetes-sandbox-storage.js +46 -2
- package/dist/server/overview.js +271 -0
- package/dist/server/plan.js +170 -121
- package/dist/server/policy.js +34 -2
- package/dist/server/proposals-api.js +17 -8
- package/dist/server/repo-access.js +5 -0
- package/dist/server/run-logs.js +4 -0
- package/dist/server/run-plan.js +38 -4
- package/dist/server/run-request.js +58 -3
- package/dist/server/run-worker.js +262 -13
- package/dist/server/runs.js +962 -36
- package/dist/server/sandbox-pod.js +28 -0
- package/dist/server/sandbox-storage.js +23 -0
- package/dist/server/sandboxes.js +8 -0
- package/dist/server/server-config.js +246 -10
- package/dist/server/server.js +429 -32
- package/dist/server/service-runs.js +82 -0
- package/dist/server/timings.js +132 -0
- package/dist/server/wait-reason.js +21 -0
- package/dist/utils/tar.js +28 -1
- package/dist/utils/terminal-prompt.js +53 -0
- package/package.json +8 -2
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.formatMemory = exports.formatCpu = exports.HostCapacity = exports.KubernetesCapacity = exports.cachedCapacity = exports.parseQuantity = void 0;
|
|
4
|
+
const os_1 = require("os");
|
|
5
|
+
const log_1 = require("../log");
|
|
6
|
+
const apply_1 = require("../kubernetes/apply");
|
|
7
|
+
const UNITS = {
|
|
8
|
+
n: 1e-9,
|
|
9
|
+
u: 1e-6,
|
|
10
|
+
m: 1e-3,
|
|
11
|
+
'': 1,
|
|
12
|
+
k: 1e3,
|
|
13
|
+
K: 1e3,
|
|
14
|
+
M: 1e6,
|
|
15
|
+
G: 1e9,
|
|
16
|
+
T: 1e12,
|
|
17
|
+
P: 1e15,
|
|
18
|
+
E: 1e18,
|
|
19
|
+
Ki: 1024,
|
|
20
|
+
Mi: 1024 ** 2,
|
|
21
|
+
Gi: 1024 ** 3,
|
|
22
|
+
Ti: 1024 ** 4,
|
|
23
|
+
Pi: 1024 ** 5,
|
|
24
|
+
Ei: 1024 ** 6,
|
|
25
|
+
};
|
|
26
|
+
// A quantity of Kubernetes (`250m`, `1.5`, `512Mi`, `12345678n`, `1e3`), as a number.
|
|
27
|
+
function parseQuantity(value) {
|
|
28
|
+
if (value === undefined) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
if (typeof value === 'number') {
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
const match = /^([+-]?[0-9.]+(?:[eE][+-]?\d+)?)([a-zA-Z]*)$/.exec(value.trim());
|
|
35
|
+
if (!match || !(match[2] in UNITS)) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
const number = Number(match[1]);
|
|
39
|
+
return Number.isFinite(number) ? number * UNITS[match[2]] : undefined;
|
|
40
|
+
}
|
|
41
|
+
exports.parseQuantity = parseQuantity;
|
|
42
|
+
function add(into, cpu, memory) {
|
|
43
|
+
if (cpu !== undefined) {
|
|
44
|
+
into.cpu = (into.cpu ?? 0) + cpu;
|
|
45
|
+
}
|
|
46
|
+
if (memory !== undefined) {
|
|
47
|
+
into.memory = (into.memory ?? 0) + memory;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function addAll(into, from) {
|
|
51
|
+
if (from) {
|
|
52
|
+
add(into, from.cpu, from.memory);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Reads at most once in `ms`, and never twice at the same time: the dashboard reloads. A read
|
|
56
|
+
// that takes longer than `wait` is not waited for: a slow cluster API does not hold up a page,
|
|
57
|
+
// the next one shows what it read.
|
|
58
|
+
function cachedCapacity(reader, ms = 15000, now = Date.now, wait = 3000) {
|
|
59
|
+
let last;
|
|
60
|
+
const failed = (problem) => ({
|
|
61
|
+
backend: 'host',
|
|
62
|
+
at: new Date(now()).toISOString(),
|
|
63
|
+
runs: { requested: {} },
|
|
64
|
+
groups: [],
|
|
65
|
+
problems: [problem],
|
|
66
|
+
});
|
|
67
|
+
return {
|
|
68
|
+
read(runs) {
|
|
69
|
+
if (!last || now() - last.at >= ms) {
|
|
70
|
+
const value = reader.read(runs).catch((e) => failed((0, log_1.getErrorMessage)(e)));
|
|
71
|
+
last = { at: now(), value };
|
|
72
|
+
}
|
|
73
|
+
let timer;
|
|
74
|
+
const late = new Promise((resolve) => {
|
|
75
|
+
timer = setTimeout(() => resolve(failed('the capacity could not be read in time')), wait);
|
|
76
|
+
timer.unref?.();
|
|
77
|
+
});
|
|
78
|
+
return Promise.race([last.value, late]).finally(() => clearTimeout(timer));
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
exports.cachedCapacity = cachedCapacity;
|
|
83
|
+
function why(what, e, rule) {
|
|
84
|
+
const code = (0, apply_1.statusCodeOf)(e);
|
|
85
|
+
if (code === 403 && rule) {
|
|
86
|
+
return `${what}: the server may not read it, it needs ${rule}`;
|
|
87
|
+
}
|
|
88
|
+
if (code === 404 && what.includes('metrics')) {
|
|
89
|
+
return `${what}: the cluster serves no metrics API (metrics-server)`;
|
|
90
|
+
}
|
|
91
|
+
return `${what}: ${(0, log_1.getErrorMessage)(e)}`;
|
|
92
|
+
}
|
|
93
|
+
const ENDED = ['Succeeded', 'Failed'];
|
|
94
|
+
// On Kubernetes: per namespace of the server, its pods' requests, its quota and its usage.
|
|
95
|
+
class KubernetesCapacity {
|
|
96
|
+
constructor(instance, options) {
|
|
97
|
+
this.instance = instance;
|
|
98
|
+
this.options = options;
|
|
99
|
+
}
|
|
100
|
+
async read(runs) {
|
|
101
|
+
const problems = [];
|
|
102
|
+
const groups = [];
|
|
103
|
+
let namespaces = [];
|
|
104
|
+
try {
|
|
105
|
+
namespaces = await this.options.namespaces();
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
problems.push(why('the namespaces of the server', e));
|
|
109
|
+
}
|
|
110
|
+
const said = new Set();
|
|
111
|
+
const once = (text) => {
|
|
112
|
+
if (!said.has(text)) {
|
|
113
|
+
said.add(text);
|
|
114
|
+
problems.push(text);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
await Promise.all(namespaces.map(async ({ name: namespace, kind }) => {
|
|
118
|
+
const group = {
|
|
119
|
+
name: namespace,
|
|
120
|
+
kind,
|
|
121
|
+
...this.options.ownerOf(namespace, runs),
|
|
122
|
+
pods: 0,
|
|
123
|
+
// a namespace whose pods could be listed and that has none asks for nothing
|
|
124
|
+
requested: { cpu: 0, memory: 0 },
|
|
125
|
+
};
|
|
126
|
+
try {
|
|
127
|
+
const pods = await this.instance.coreApi.listNamespacedPod({ namespace });
|
|
128
|
+
for (const pod of pods.items) {
|
|
129
|
+
if (ENDED.includes(pod.status?.phase ?? '')) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
group.pods++;
|
|
133
|
+
for (const container of pod.spec?.containers ?? []) {
|
|
134
|
+
const requests = container.resources?.requests ?? {};
|
|
135
|
+
add(group.requested, parseQuantity(requests.cpu) ?? 0, parseQuantity(requests.memory) ?? 0);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
once(why('the pods of the namespaces', e, 'list on pods in them (runRole)'));
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const quota = await this.instance.coreApi.readNamespacedResourceQuota({
|
|
144
|
+
name: this.options.quotaName,
|
|
145
|
+
namespace,
|
|
146
|
+
});
|
|
147
|
+
const hard = quota.spec?.hard ?? {};
|
|
148
|
+
const cpu = parseQuantity(hard['requests.cpu'] ?? hard.cpu);
|
|
149
|
+
const memory = parseQuantity(hard['requests.memory'] ?? hard.memory);
|
|
150
|
+
if (cpu !== undefined || memory !== undefined) {
|
|
151
|
+
group.quota = { ...(cpu !== undefined ? { cpu } : {}), ...(memory !== undefined ? { memory } : {}) };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
// a namespace without a quota is no problem
|
|
156
|
+
if ((0, apply_1.statusCodeOf)(e) !== 404) {
|
|
157
|
+
once(why('the quotas of the namespaces', e, 'get on resourcequotas in them (runRole)'));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (this.instance.metrics) {
|
|
161
|
+
try {
|
|
162
|
+
const metrics = await this.instance.metrics.getPodMetrics(namespace);
|
|
163
|
+
const used = { cpu: 0, memory: 0 };
|
|
164
|
+
for (const pod of metrics.items) {
|
|
165
|
+
for (const container of pod.containers) {
|
|
166
|
+
add(used, parseQuantity(container.usage.cpu), parseQuantity(container.usage.memory));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
group.used = used;
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
once(why('what the pods use (metrics)', e, 'list on pods.metrics.k8s.io in them (runRole, see the backend docs)'));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
groups.push(group);
|
|
176
|
+
}));
|
|
177
|
+
groups.sort((a, b) => a.name.localeCompare(b.name));
|
|
178
|
+
const total = { requested: {}, pods: 0 };
|
|
179
|
+
const used = {};
|
|
180
|
+
for (const group of groups) {
|
|
181
|
+
addAll(total.requested, group.requested);
|
|
182
|
+
total.pods = (total.pods ?? 0) + group.pods;
|
|
183
|
+
addAll(used, group.used);
|
|
184
|
+
}
|
|
185
|
+
if (groups.some((group) => group.used)) {
|
|
186
|
+
total.used = used;
|
|
187
|
+
}
|
|
188
|
+
let machines;
|
|
189
|
+
try {
|
|
190
|
+
const nodes = await this.instance.coreApi.listNode();
|
|
191
|
+
const allocatable = {};
|
|
192
|
+
for (const node of nodes.items) {
|
|
193
|
+
const of = node.status?.allocatable ?? {};
|
|
194
|
+
add(allocatable, parseQuantity(of.cpu), parseQuantity(of.memory));
|
|
195
|
+
}
|
|
196
|
+
machines = { count: nodes.items.length, allocatable };
|
|
197
|
+
if (this.instance.metrics) {
|
|
198
|
+
try {
|
|
199
|
+
const metrics = await this.instance.metrics.getNodeMetrics();
|
|
200
|
+
const nodesUsed = {};
|
|
201
|
+
for (const node of metrics.items) {
|
|
202
|
+
add(nodesUsed, parseQuantity(node.usage.cpu), parseQuantity(node.usage.memory));
|
|
203
|
+
}
|
|
204
|
+
machines.used = nodesUsed;
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
once(why('what the nodes use (metrics)', e, 'list on nodes.metrics.k8s.io (see the backend docs)'));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
// listing nodes is optional, and many clusters do not let a workload do it
|
|
213
|
+
if ((0, apply_1.statusCodeOf)(e) === 403) {
|
|
214
|
+
once('the nodes: not shown, the server may not list nodes (optional, see the backend docs)');
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
once(why('the nodes', e));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
backend: 'kubernetes',
|
|
222
|
+
at: new Date().toISOString(),
|
|
223
|
+
...(machines ? { machines } : {}),
|
|
224
|
+
runs: total,
|
|
225
|
+
groups,
|
|
226
|
+
problems,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
exports.KubernetesCapacity = KubernetesCapacity;
|
|
231
|
+
// On a host: the machine of the docker daemon, and the containers runs started on it.
|
|
232
|
+
class HostCapacity {
|
|
233
|
+
constructor(docker,
|
|
234
|
+
// whether the docker daemon runs on this machine, so its load is the server's
|
|
235
|
+
local = true) {
|
|
236
|
+
this.docker = docker;
|
|
237
|
+
this.local = local;
|
|
238
|
+
}
|
|
239
|
+
async read() {
|
|
240
|
+
const problems = [];
|
|
241
|
+
let machines;
|
|
242
|
+
try {
|
|
243
|
+
const info = (await this.docker.info());
|
|
244
|
+
machines = {
|
|
245
|
+
count: 1,
|
|
246
|
+
allocatable: { cpu: info.NCPU ?? (0, os_1.cpus)().length, memory: info.MemTotal ?? (0, os_1.totalmem)() },
|
|
247
|
+
...(this.local ? { used: { memory: (0, os_1.totalmem)() - (0, os_1.freemem)() }, load: (0, os_1.loadavg)() } : {}),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
catch (e) {
|
|
251
|
+
problems.push(why('the docker daemon', e));
|
|
252
|
+
machines = this.local
|
|
253
|
+
? {
|
|
254
|
+
count: 1,
|
|
255
|
+
allocatable: { cpu: (0, os_1.cpus)().length, memory: (0, os_1.totalmem)() },
|
|
256
|
+
used: { memory: (0, os_1.totalmem)() - (0, os_1.freemem)() },
|
|
257
|
+
load: (0, os_1.loadavg)(),
|
|
258
|
+
}
|
|
259
|
+
: undefined;
|
|
260
|
+
}
|
|
261
|
+
const runs = { requested: {}, used: {}, containers: 0 };
|
|
262
|
+
try {
|
|
263
|
+
const containers = await this.docker.listContainers({ filters: { label: ['app=hammerkit'] } });
|
|
264
|
+
runs.containers = containers.length;
|
|
265
|
+
await Promise.all(containers.map(async (container) => {
|
|
266
|
+
try {
|
|
267
|
+
const stats = (await this.docker.getContainer(container.Id).stats({ stream: false }));
|
|
268
|
+
const cpu = stats.cpu_stats?.cpu_usage?.total_usage ?? 0;
|
|
269
|
+
const before = stats.precpu_stats?.cpu_usage?.total_usage ?? 0;
|
|
270
|
+
const system = (stats.cpu_stats?.system_cpu_usage ?? 0) - (stats.precpu_stats?.system_cpu_usage ?? 0);
|
|
271
|
+
const online = stats.cpu_stats?.online_cpus ?? 1;
|
|
272
|
+
const cores = system > 0 ? ((cpu - before) / system) * online : 0;
|
|
273
|
+
const memory = (stats.memory_stats?.usage ?? 0) -
|
|
274
|
+
(stats.memory_stats?.stats?.inactive_file ?? stats.memory_stats?.stats?.cache ?? 0);
|
|
275
|
+
add(runs.used, Math.max(0, cores), Math.max(0, memory));
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// a container that just ended
|
|
279
|
+
}
|
|
280
|
+
}));
|
|
281
|
+
}
|
|
282
|
+
catch (e) {
|
|
283
|
+
problems.push(why('the containers of runs', e));
|
|
284
|
+
delete runs.used;
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
backend: 'host',
|
|
288
|
+
at: new Date().toISOString(),
|
|
289
|
+
...(machines ? { machines } : {}),
|
|
290
|
+
runs,
|
|
291
|
+
groups: [],
|
|
292
|
+
problems,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
exports.HostCapacity = HostCapacity;
|
|
297
|
+
var capacity_format_1 = require("./capacity-format");
|
|
298
|
+
Object.defineProperty(exports, "formatCpu", { enumerable: true, get: function () { return capacity_format_1.formatCpu; } });
|
|
299
|
+
Object.defineProperty(exports, "formatMemory", { enumerable: true, get: function () { return capacity_format_1.formatMemory; } });
|
|
300
|
+
//# sourceMappingURL=capacity.js.map
|
|
@@ -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
|