hammerkit 1.23.0 → 1.24.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/planner/utils/append-work-task.js +1 -0
- package/dist/program.js +4 -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 +154 -0
- package/dist/remote/remote-work-commands.js +152 -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 +1 -0
- package/dist/schema/runner-schema.js +13 -0
- package/dist/server/approvals-api.js +99 -0
- package/dist/server/definitions-api.js +119 -0
- package/dist/server/definitions.js +248 -0
- package/dist/server/discovery.js +9 -0
- package/dist/server/executor.js +238 -0
- package/dist/server/git-checkout.js +63 -0
- package/dist/server/http.js +39 -0
- package/dist/server/oauth.js +226 -0
- package/dist/server/policy.js +90 -0
- package/dist/server/proposals-api.js +123 -0
- package/dist/server/proposals.js +126 -0
- package/dist/server/run-request.js +67 -0
- package/dist/server/runs.js +279 -0
- package/dist/server/server-command.js +43 -0
- package/dist/server/server-config.js +233 -0
- package/dist/server/server.js +338 -0
- package/dist/server/sources-api.js +106 -0
- package/dist/server/sources.js +258 -0
- package/package.json +1 -1
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.detectPlatform = exports.readServerConfig = exports.serverConfigSchema = exports.entitlementSchema = exports.definitionName = exports.issuerSchema = exports.serverPlatformSchema = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const yaml_1 = require("yaml");
|
|
6
|
+
const log_1 = require("../log");
|
|
7
|
+
const units_1 = require("../utils/units");
|
|
8
|
+
const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '::1'];
|
|
9
|
+
exports.serverPlatformSchema = zod_1.z
|
|
10
|
+
.object({
|
|
11
|
+
os: zod_1.z.enum(['linux', 'macos', 'windows']),
|
|
12
|
+
arch: zod_1.z.enum(['amd64', 'arm64']),
|
|
13
|
+
})
|
|
14
|
+
.strict();
|
|
15
|
+
// The issuer is compared with the `iss` of a token as written, and its keys are
|
|
16
|
+
// fetched from it, so it has to be https (http only for local testing).
|
|
17
|
+
const issuerUrlSchema = zod_1.z.string().refine((value) => {
|
|
18
|
+
try {
|
|
19
|
+
const url = new URL(value);
|
|
20
|
+
return (url.protocol === 'https:' ||
|
|
21
|
+
(url.protocol === 'http:' && LOOPBACK_HOSTS.includes(url.hostname.replace(/^\[|\]$/g, ''))));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}, 'issuer must be an https url (http only for localhost)');
|
|
27
|
+
exports.issuerSchema = zod_1.z
|
|
28
|
+
.object({
|
|
29
|
+
issuer: issuerUrlSchema,
|
|
30
|
+
audience: zod_1.z.string().min(1),
|
|
31
|
+
// the claim listing the groups of the caller
|
|
32
|
+
groupsClaim: zod_1.z.string().min(1).default('groups'),
|
|
33
|
+
// the public OAuth client `remote login` signs in with (device authorization
|
|
34
|
+
// grant); without one this issuer can only be used with a token obtained
|
|
35
|
+
// elsewhere, such as a CI job's or an agent's own
|
|
36
|
+
clientId: zod_1.z.string().min(1).optional(),
|
|
37
|
+
// what the login asks for, such as `openid api://hammerkit/.default`
|
|
38
|
+
scope: zod_1.z.string().min(1).default('openid'),
|
|
39
|
+
})
|
|
40
|
+
.strict();
|
|
41
|
+
// What a published build file is called: the project, as a build file author names it.
|
|
42
|
+
exports.definitionName = zod_1.z
|
|
43
|
+
.string()
|
|
44
|
+
.regex(/^[a-z0-9][a-z0-9._-]{0,63}$/i, 'use letters, digits, ".", "_" and "-", starting with a letter or digit');
|
|
45
|
+
const subjectSchema = zod_1.z.object({ iss: zod_1.z.string().min(1), sub: zod_1.z.string().min(1) }).strict();
|
|
46
|
+
const groupSchema = zod_1.z.object({ iss: zod_1.z.string().min(1), name: zod_1.z.string().min(1) }).strict();
|
|
47
|
+
const claimsSchema = zod_1.z.object({ iss: zod_1.z.string().min(1), match: zod_1.z.record(zod_1.z.string()) }).strict();
|
|
48
|
+
// Who: the person or client (`subject`), everyone in a group of an issuer
|
|
49
|
+
// (`group`), or any token of an issuer whose claims match (`claims`, for CI jobs:
|
|
50
|
+
// repository, ref, environment). Only validated token claims are matched.
|
|
51
|
+
const whoShape = {
|
|
52
|
+
subject: subjectSchema.optional(),
|
|
53
|
+
group: groupSchema.optional(),
|
|
54
|
+
claims: claimsSchema.optional(),
|
|
55
|
+
};
|
|
56
|
+
function checkWho(who, ctx) {
|
|
57
|
+
const named = [who.subject, who.group, who.claims].filter((w) => w !== undefined);
|
|
58
|
+
if (named.length !== 1) {
|
|
59
|
+
ctx.addIssue({ code: zod_1.z.ZodIssueCode.custom, message: 'name exactly one of subject, group or claims' });
|
|
60
|
+
}
|
|
61
|
+
if (who.claims && Object.keys(who.claims.match).length === 0) {
|
|
62
|
+
ctx.addIssue({ code: zod_1.z.ZodIssueCode.custom, path: ['claims', 'match'], message: 'match at least one claim' });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
exports.entitlementSchema = zod_1.z
|
|
66
|
+
.object({
|
|
67
|
+
...whoShape,
|
|
68
|
+
// admin: runs on these accounts at once, and approves the runs others
|
|
69
|
+
// request on them
|
|
70
|
+
accounts: zod_1.z.array(zod_1.z.string().min(1)).default([]),
|
|
71
|
+
// may request runs on these accounts, which wait until an admin of the account
|
|
72
|
+
// (someone with it in `accounts`) approves them
|
|
73
|
+
request: zod_1.z.array(zod_1.z.string().min(1)).optional(),
|
|
74
|
+
// the build files (by name) the caller may publish, `*` for any; a separate
|
|
75
|
+
// permission from running them, so who defines work and who runs it can differ
|
|
76
|
+
publish: zod_1.z.array(zod_1.z.union([zod_1.z.literal('*'), exports.definitionName])).optional(),
|
|
77
|
+
// the build files the caller may propose a new version of: it can not run until
|
|
78
|
+
// someone who may `publish` that name approves it
|
|
79
|
+
propose: zod_1.z.array(zod_1.z.union([zod_1.z.literal('*'), exports.definitionName])).optional(),
|
|
80
|
+
// the account used when the caller names none; one of accounts or request
|
|
81
|
+
default: zod_1.z.string().min(1).optional(),
|
|
82
|
+
// where the code may come from: the caller's working tree or a git ref
|
|
83
|
+
source: zod_1.z
|
|
84
|
+
.array(zod_1.z.enum(['upload', 'ref']))
|
|
85
|
+
.min(1)
|
|
86
|
+
.optional(),
|
|
87
|
+
})
|
|
88
|
+
.strict()
|
|
89
|
+
.superRefine((entitlement, ctx) => {
|
|
90
|
+
checkWho(entitlement, ctx);
|
|
91
|
+
if (entitlement.accounts.length === 0 &&
|
|
92
|
+
(entitlement.request ?? []).length === 0 &&
|
|
93
|
+
(entitlement.propose ?? []).length === 0 &&
|
|
94
|
+
(entitlement.publish ?? []).length === 0) {
|
|
95
|
+
ctx.addIssue({
|
|
96
|
+
code: zod_1.z.ZodIssueCode.custom,
|
|
97
|
+
message: 'name accounts to use or request, or build files to publish or propose',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (entitlement.default &&
|
|
101
|
+
![...entitlement.accounts, ...(entitlement.request ?? [])].includes(entitlement.default)) {
|
|
102
|
+
ctx.addIssue({
|
|
103
|
+
code: zod_1.z.ZodIssueCode.custom,
|
|
104
|
+
path: ['default'],
|
|
105
|
+
message: 'default has to be one of accounts or request',
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
const duration = zod_1.z.string().refine((value) => {
|
|
110
|
+
try {
|
|
111
|
+
return (0, units_1.parseDuration)(value) > 0;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}, 'expected a duration like 30m, 1h or 1d');
|
|
117
|
+
// The server config is written by an operator and reviewed like code, so a typo
|
|
118
|
+
// is an error and not a silently ignored key.
|
|
119
|
+
exports.serverConfigSchema = zod_1.z
|
|
120
|
+
.object({
|
|
121
|
+
listen: zod_1.z
|
|
122
|
+
.object({
|
|
123
|
+
host: zod_1.z.string().min(1).default('127.0.0.1'),
|
|
124
|
+
port: zod_1.z.number().int().min(0).max(65535).default(8480),
|
|
125
|
+
})
|
|
126
|
+
.strict()
|
|
127
|
+
.default({}),
|
|
128
|
+
// paths of a certificate and key
|
|
129
|
+
tls: zod_1.z
|
|
130
|
+
.object({ cert: zod_1.z.string().min(1), key: zod_1.z.string().min(1) })
|
|
131
|
+
.strict()
|
|
132
|
+
.optional(),
|
|
133
|
+
// plain http carries OAuth tokens, so it is only accepted on the loopback
|
|
134
|
+
// interface, or when a proxy in front terminates TLS and this says so
|
|
135
|
+
behindTlsProxy: zod_1.z.boolean().default(false),
|
|
136
|
+
// the OAuth issuers whose tokens the server accepts, and the audience those
|
|
137
|
+
// tokens have to carry
|
|
138
|
+
issuers: zod_1.z.array(exports.issuerSchema).min(1, 'at least one issuer is needed, OAuth is the only way to call the server'),
|
|
139
|
+
// who may use which service account; default deny, so no entry means no account
|
|
140
|
+
entitlements: zod_1.z.array(exports.entitlementSchema).default([]),
|
|
141
|
+
approvals: zod_1.z
|
|
142
|
+
.object({
|
|
143
|
+
// how long a run waits for its approval before it is rejected
|
|
144
|
+
timeout: duration.default('1h'),
|
|
145
|
+
// runs waiting at the same time, more are refused
|
|
146
|
+
maxWaiting: zod_1.z.number().int().min(1).default(100),
|
|
147
|
+
})
|
|
148
|
+
.strict()
|
|
149
|
+
.default({}),
|
|
150
|
+
// where a run gets its code from: the repository of each build file, by its name.
|
|
151
|
+
// The caller names a branch, tag or commit of it, never a repository.
|
|
152
|
+
sources: zod_1.z
|
|
153
|
+
.record(exports.definitionName, zod_1.z
|
|
154
|
+
.object({
|
|
155
|
+
git: zod_1.z
|
|
156
|
+
.string()
|
|
157
|
+
.min(1)
|
|
158
|
+
.refine((value) => !value.startsWith('-'), 'a repository can not look like an option'),
|
|
159
|
+
})
|
|
160
|
+
.strict())
|
|
161
|
+
.default({}),
|
|
162
|
+
// where the server keeps what must survive a restart: the published build files
|
|
163
|
+
storage: zod_1.z
|
|
164
|
+
.object({ directory: zod_1.z.string().min(1).optional() })
|
|
165
|
+
.strict()
|
|
166
|
+
.default({}),
|
|
167
|
+
// limits of the run registry
|
|
168
|
+
runs: zod_1.z
|
|
169
|
+
.object({
|
|
170
|
+
// runs that execute at the same time, the others wait in queue
|
|
171
|
+
maxConcurrent: zod_1.z.number().int().min(1).default(4),
|
|
172
|
+
// finished runs that are kept for their state and logs
|
|
173
|
+
keep: zod_1.z.number().int().min(1).default(200),
|
|
174
|
+
})
|
|
175
|
+
.strict()
|
|
176
|
+
.default({}),
|
|
177
|
+
backend: zod_1.z
|
|
178
|
+
.object({
|
|
179
|
+
type: zod_1.z.literal('host'),
|
|
180
|
+
// what this machine offers; detected when left out
|
|
181
|
+
platform: exports.serverPlatformSchema.optional(),
|
|
182
|
+
})
|
|
183
|
+
.strict()
|
|
184
|
+
.default({ type: 'host' }),
|
|
185
|
+
})
|
|
186
|
+
.strict()
|
|
187
|
+
.superRefine((config, ctx) => {
|
|
188
|
+
const issuers = config.issuers.map((i) => i.issuer);
|
|
189
|
+
config.entitlements.forEach((entitlement, index) => {
|
|
190
|
+
const iss = (entitlement.subject ?? entitlement.group ?? entitlement.claims)?.iss;
|
|
191
|
+
if (iss && !issuers.includes(iss)) {
|
|
192
|
+
ctx.addIssue({
|
|
193
|
+
code: zod_1.z.ZodIssueCode.custom,
|
|
194
|
+
path: ['entitlements', index],
|
|
195
|
+
message: `issuer ${iss} is not one of the accepted issuers`,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
if (!config.tls && !config.behindTlsProxy && !LOOPBACK_HOSTS.includes(config.listen.host)) {
|
|
200
|
+
ctx.addIssue({
|
|
201
|
+
code: zod_1.z.ZodIssueCode.custom,
|
|
202
|
+
path: ['listen', 'host'],
|
|
203
|
+
message: `plain http on ${config.listen.host} would send tokens in the clear, set tls or behindTlsProxy: true`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
async function readServerConfig(environment, path) {
|
|
208
|
+
if (!(await environment.file.exists(path))) {
|
|
209
|
+
throw new Error(`unable to read ${path}: no such file`);
|
|
210
|
+
}
|
|
211
|
+
let raw;
|
|
212
|
+
try {
|
|
213
|
+
raw = (0, yaml_1.parse)(await environment.file.read(path));
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
throw new Error(`unable to read ${path}: ${(0, log_1.getErrorMessage)(e)}`);
|
|
217
|
+
}
|
|
218
|
+
const result = exports.serverConfigSchema.safeParse(raw ?? {});
|
|
219
|
+
if (!result.success) {
|
|
220
|
+
const issue = result.error.issues[0];
|
|
221
|
+
const where = issue.path.length > 0 ? `${issue.path.join('.')}: ` : '';
|
|
222
|
+
throw new Error(`invalid ${path}: ${where}${issue.message}`);
|
|
223
|
+
}
|
|
224
|
+
return result.data;
|
|
225
|
+
}
|
|
226
|
+
exports.readServerConfig = readServerConfig;
|
|
227
|
+
function detectPlatform() {
|
|
228
|
+
const os = { darwin: 'macos', win32: 'windows' }[process.platform] ?? 'linux';
|
|
229
|
+
const arch = process.arch === 'arm64' ? 'arm64' : 'amd64';
|
|
230
|
+
return { os, arch };
|
|
231
|
+
}
|
|
232
|
+
exports.detectPlatform = detectPlatform;
|
|
233
|
+
//# sourceMappingURL=server-config.js.map
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.startServer = exports.getDiscoveryDocument = exports.SERVER_PROTOCOL = exports.DISCOVERY_PATH = void 0;
|
|
4
|
+
const http_1 = require("http");
|
|
5
|
+
const https_1 = require("https");
|
|
6
|
+
const version_1 = require("../version");
|
|
7
|
+
const server_config_1 = require("./server-config");
|
|
8
|
+
const discovery_1 = require("./discovery");
|
|
9
|
+
const oauth_1 = require("./oauth");
|
|
10
|
+
const policy_1 = require("./policy");
|
|
11
|
+
const runs_1 = require("./runs");
|
|
12
|
+
const executor_1 = require("./executor");
|
|
13
|
+
const run_request_1 = require("./run-request");
|
|
14
|
+
const http_2 = require("./http");
|
|
15
|
+
const definitions_1 = require("./definitions");
|
|
16
|
+
const definitions_api_1 = require("./definitions-api");
|
|
17
|
+
const approvals_api_1 = require("./approvals-api");
|
|
18
|
+
const proposals_api_1 = require("./proposals-api");
|
|
19
|
+
const proposals_1 = require("./proposals");
|
|
20
|
+
const sources_1 = require("./sources");
|
|
21
|
+
const sources_api_1 = require("./sources-api");
|
|
22
|
+
const units_1 = require("../utils/units");
|
|
23
|
+
const get_cache_directory_1 = require("../optimizer/get-cache-directory");
|
|
24
|
+
const path_1 = require("path");
|
|
25
|
+
var discovery_2 = require("./discovery");
|
|
26
|
+
Object.defineProperty(exports, "DISCOVERY_PATH", { enumerable: true, get: function () { return discovery_2.DISCOVERY_PATH; } });
|
|
27
|
+
Object.defineProperty(exports, "SERVER_PROTOCOL", { enumerable: true, get: function () { return discovery_2.SERVER_PROTOCOL; } });
|
|
28
|
+
function getDiscoveryDocument(config) {
|
|
29
|
+
return {
|
|
30
|
+
name: 'hammerkit',
|
|
31
|
+
version: (0, version_1.getVersion)(),
|
|
32
|
+
protocol: discovery_1.SERVER_PROTOCOL,
|
|
33
|
+
issuers: config.issuers.map((i) => ({
|
|
34
|
+
issuer: i.issuer,
|
|
35
|
+
audience: i.audience,
|
|
36
|
+
...(i.clientId ? { clientId: i.clientId } : {}),
|
|
37
|
+
scope: i.scope,
|
|
38
|
+
})),
|
|
39
|
+
backend: { type: config.backend.type, platform: config.backend.platform ?? (0, server_config_1.detectPlatform)() },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
exports.getDiscoveryDocument = getDiscoveryDocument;
|
|
43
|
+
const HEARTBEAT_MS = 15000;
|
|
44
|
+
// Server-sent events: `id` is the sequence number, so a client that lost the
|
|
45
|
+
// connection continues with Last-Event-ID (or ?after=) and misses nothing.
|
|
46
|
+
function streamEvents(registry, principal, id, req, res, url) {
|
|
47
|
+
const after = Number(req.headers['last-event-id'] ?? url.searchParams.get('after') ?? 0);
|
|
48
|
+
const write = (event) => res.write(`id: ${event.seq}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
49
|
+
const watch = registry.watch(principal, id, Number.isFinite(after) && after > 0 ? after : 0, (event) => {
|
|
50
|
+
write(event);
|
|
51
|
+
if (event.type === 'state' && (0, runs_1.isFinal)(event.state)) {
|
|
52
|
+
end();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
if (!watch) {
|
|
56
|
+
(0, http_2.send)(res, 404, { error: 'not found' });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const heartbeat = setInterval(() => res.write(': keep-alive\n\n'), HEARTBEAT_MS);
|
|
60
|
+
const end = () => {
|
|
61
|
+
clearInterval(heartbeat);
|
|
62
|
+
watch.stop();
|
|
63
|
+
res.end();
|
|
64
|
+
};
|
|
65
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
|
|
66
|
+
watch.events.forEach(write);
|
|
67
|
+
if (watch.finished) {
|
|
68
|
+
end();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
res.on('close', () => {
|
|
72
|
+
clearInterval(heartbeat);
|
|
73
|
+
watch.stop();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
async function handleRuns(config, registry, definitions, sources, log, principal, req, res, url) {
|
|
77
|
+
const match = /^\/v1\/runs(?:\/([^/]+)(?:\/(cancel|events))?)?$/.exec(url.pathname);
|
|
78
|
+
if (!match) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
const [, id, action] = match;
|
|
82
|
+
const method = req.method;
|
|
83
|
+
if (!id) {
|
|
84
|
+
if (method === 'GET') {
|
|
85
|
+
(0, http_2.send)(res, 200, { runs: registry.list(principal).map(toRunResponse) });
|
|
86
|
+
}
|
|
87
|
+
else if (method === 'POST') {
|
|
88
|
+
await submitRun(config, registry, definitions, sources, log, principal, req, res);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
(0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET, POST' });
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
if (!runs_1.RUN_ID_PATTERN.test(id)) {
|
|
96
|
+
(0, http_2.send)(res, 404, { error: 'not found' });
|
|
97
|
+
}
|
|
98
|
+
else if (action === 'cancel') {
|
|
99
|
+
if (method !== 'POST') {
|
|
100
|
+
(0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'POST' });
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
const run = registry.cancel(principal, id);
|
|
104
|
+
log(`cancel iss=${principal.iss} sub=${principal.sub} run=${id} found=${Boolean(run)}`);
|
|
105
|
+
run ? (0, http_2.send)(res, 200, toRunResponse(run)) : (0, http_2.send)(res, 404, { error: 'not found' });
|
|
106
|
+
}
|
|
107
|
+
else if (method !== 'GET') {
|
|
108
|
+
(0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
|
|
109
|
+
}
|
|
110
|
+
else if (action === 'events') {
|
|
111
|
+
streamEvents(registry, principal, id, req, res, url);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const run = registry.get(principal, id);
|
|
115
|
+
run ? (0, http_2.send)(res, 200, toRunResponse(run)) : (0, http_2.send)(res, 404, { error: 'not found' });
|
|
116
|
+
}
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
function toRunResponse(run) {
|
|
120
|
+
const { owner, ...visible } = run;
|
|
121
|
+
void owner;
|
|
122
|
+
return visible;
|
|
123
|
+
}
|
|
124
|
+
async function submitRun(config, registry, definitions, sources, log, principal, req, res) {
|
|
125
|
+
const parsed = run_request_1.runRequestSchema.safeParse(await (0, http_2.readJson)(req));
|
|
126
|
+
if (!parsed.success) {
|
|
127
|
+
const issue = parsed.error.issues[0];
|
|
128
|
+
(0, http_2.send)(res, 400, { error: `${issue.path.join('.') || 'request'}: ${issue.message}` });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const request = parsed.data;
|
|
132
|
+
const grant = (0, policy_1.getGrant)(config.entitlements, principal);
|
|
133
|
+
const account = request.account ?? grant.default;
|
|
134
|
+
// what the caller may not do is said plainly: they already know who they are
|
|
135
|
+
if (account !== null && !grant.accounts.includes(account) && !grant.request.includes(account)) {
|
|
136
|
+
log(`refuse run iss=${principal.iss} sub=${principal.sub}: service account ${(0, oauth_1.quote)(account)} is not granted`);
|
|
137
|
+
(0, http_2.send)(res, 403, { error: `you may not use the service account ${account}` });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (!grant.source.includes(request.source.type)) {
|
|
141
|
+
log(`refuse run iss=${principal.iss} sub=${principal.sub}: source ${request.source.type} is not granted`);
|
|
142
|
+
(0, http_2.send)(res, 403, { error: `runs from ${request.source.type} are not allowed for you` });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!(0, policy_1.maySeeDefinitions)(grant)) {
|
|
146
|
+
log(`refuse run iss=${principal.iss} sub=${principal.sub}: no entitlement`);
|
|
147
|
+
(0, http_2.send)(res, 403, { error: 'you are not entitled to run anything on this server' });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (request.source.type === 'upload') {
|
|
151
|
+
// a run starts from files the server has all of, never from a half-sent tree
|
|
152
|
+
const tree = await sources.get(principal, request.source.snapshot);
|
|
153
|
+
if (!tree) {
|
|
154
|
+
(0, http_2.send)(res, 400, {
|
|
155
|
+
error: `the files ${request.source.snapshot} are not known, announce them with POST /v1/sources`,
|
|
156
|
+
});
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (tree.missing.length > 0) {
|
|
160
|
+
(0, http_2.send)(res, 400, {
|
|
161
|
+
error: `the files ${tree.snapshot} are not complete, ${tree.missing.length} of them still have to be sent`,
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const version = definitions.find(request.definition.name, request.definition.hash);
|
|
167
|
+
if (!version) {
|
|
168
|
+
(0, http_2.send)(res, 400, { error: `there is no build file ${request.definition.name} at ${request.definition.hash}` });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const known = [...version.tasks, ...version.services];
|
|
172
|
+
const unknown = request.tasks.filter((task) => !known.includes(task));
|
|
173
|
+
if (unknown.length > 0) {
|
|
174
|
+
(0, http_2.send)(res, 400, { error: `${version.name} has no task ${unknown.map(oauth_1.quote).join(', ')}` });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
// asking for an account is not using it: an admin of it has to approve
|
|
178
|
+
const needsApproval = account !== null && !grant.accounts.includes(account);
|
|
179
|
+
try {
|
|
180
|
+
const run = registry.submit(principal, {
|
|
181
|
+
definition: { name: version.name, hash: version.hash },
|
|
182
|
+
tasks: request.tasks,
|
|
183
|
+
account,
|
|
184
|
+
source: request.source,
|
|
185
|
+
options: request.options,
|
|
186
|
+
}, needsApproval);
|
|
187
|
+
log(`run ${run.id} iss=${principal.iss} sub=${principal.sub} account=${account ?? '-'} source=${run.source.type} tasks=${run.tasks.length}${needsApproval ? ' approval=required' : ''}`);
|
|
188
|
+
(0, http_2.send)(res, 202, toRunResponse(run));
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
if (e instanceof runs_1.RunError) {
|
|
192
|
+
(0, http_2.send)(res, 503, { error: e.message });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
throw e;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function bearerToken(req) {
|
|
199
|
+
const match = /^Bearer ([A-Za-z0-9._~+/-]+=*)$/.exec(req.headers.authorization ?? '');
|
|
200
|
+
return match ? match[1] : null;
|
|
201
|
+
}
|
|
202
|
+
// Callers are known by their validated token alone. The reason a token was
|
|
203
|
+
// refused goes to the log, the caller only learns that it was.
|
|
204
|
+
async function authenticate(verifier, log, req, res) {
|
|
205
|
+
const token = bearerToken(req);
|
|
206
|
+
try {
|
|
207
|
+
if (!token) {
|
|
208
|
+
throw new oauth_1.TokenError('no bearer token');
|
|
209
|
+
}
|
|
210
|
+
return await verifier.verify(token);
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
log(`deny ${req.method} ${req.url}: ${e instanceof oauth_1.TokenError ? e.message : 'token could not be checked'}`);
|
|
214
|
+
(0, http_2.send)(res, 401, { error: 'invalid token' }, { 'www-authenticate': 'Bearer error="invalid_token"' });
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
async function handle(config, registry, definitions, proposals, sources, verifier, log, req, res) {
|
|
219
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
220
|
+
const path = url.pathname;
|
|
221
|
+
if (path === discovery_1.DISCOVERY_PATH || path === '/healthz') {
|
|
222
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
223
|
+
(0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET, HEAD' });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
(0, http_2.send)(res, 200, path === '/healthz' ? { status: 'ok' } : getDiscoveryDocument(config));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const principal = await authenticate(verifier, log, req, res);
|
|
230
|
+
if (!principal) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (path === '/v1/me' && req.method === 'GET') {
|
|
234
|
+
const grant = (0, policy_1.getGrant)(config.entitlements, principal);
|
|
235
|
+
log(`grant iss=${principal.iss} sub=${principal.sub} accounts=[${grant.accounts.join(',')}] entitlements=[${grant.matched.join(',')}]`);
|
|
236
|
+
(0, http_2.send)(res, 200, {
|
|
237
|
+
iss: principal.iss,
|
|
238
|
+
sub: principal.sub,
|
|
239
|
+
accounts: grant.accounts,
|
|
240
|
+
default: grant.default,
|
|
241
|
+
request: grant.request,
|
|
242
|
+
publish: grant.publish,
|
|
243
|
+
propose: grant.propose,
|
|
244
|
+
});
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (await (0, definitions_api_1.handleDefinitions)(config, definitions, proposals, log, principal, req, res, url)) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (await (0, sources_api_1.handleSources)(config, sources, log, principal, req, res, url)) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (await handleRuns(config, registry, definitions, sources, log, principal, req, res, url)) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (await (0, approvals_api_1.handleApprovals)(config, registry, log, principal, req, res, url)) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (await (0, proposals_api_1.handleProposals)(config, definitions, proposals, log, principal, req, res, url)) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
(0, http_2.send)(res, 404, { error: 'not found' });
|
|
263
|
+
}
|
|
264
|
+
async function startServer(config, environment, options = {}) {
|
|
265
|
+
const verifier = new oauth_1.TokenVerifier(config.issuers, options);
|
|
266
|
+
const write = options.log ?? (() => undefined);
|
|
267
|
+
// request urls and subjects come from callers: no control characters, so no forged log lines
|
|
268
|
+
const log = (line) => write(line.replace(/\p{Cc}/gu, '?').slice(0, 1000));
|
|
269
|
+
const definitions = new definitions_1.DefinitionStore((0, path_1.join)(config.storage.directory ?? (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'server'), 'definitions'), environment);
|
|
270
|
+
await definitions.load();
|
|
271
|
+
const executor = options.executor ??
|
|
272
|
+
new executor_1.HostExecutor({
|
|
273
|
+
config,
|
|
274
|
+
definitions,
|
|
275
|
+
environment,
|
|
276
|
+
directory: (0, path_1.join)(config.storage.directory ?? (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'server'), 'runs'),
|
|
277
|
+
});
|
|
278
|
+
const sources = new sources_1.SourceStore((0, path_1.join)(config.storage.directory ?? (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'server'), 'sources'));
|
|
279
|
+
const registry = new runs_1.RunRegistry({
|
|
280
|
+
executor,
|
|
281
|
+
maxConcurrent: config.runs.maxConcurrent,
|
|
282
|
+
keep: config.runs.keep,
|
|
283
|
+
maxWaiting: config.approvals.maxWaiting,
|
|
284
|
+
approvalTimeout: (0, units_1.parseDuration)(config.approvals.timeout),
|
|
285
|
+
});
|
|
286
|
+
const proposals = new proposals_1.ProposalRegistry({
|
|
287
|
+
maxWaiting: config.approvals.maxWaiting,
|
|
288
|
+
timeout: (0, units_1.parseDuration)(config.approvals.timeout),
|
|
289
|
+
});
|
|
290
|
+
const listener = (req, res) => {
|
|
291
|
+
handle(config, registry, definitions, proposals, sources, verifier, log, req, res).catch((e) => {
|
|
292
|
+
if (e instanceof http_2.HttpError) {
|
|
293
|
+
(0, http_2.send)(res, e.status, { error: e.message });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
log(`error ${req.method} ${req.url}: ${e instanceof Error ? e.message : String(e)}`);
|
|
297
|
+
if (!res.headersSent) {
|
|
298
|
+
(0, http_2.send)(res, 500, { error: 'internal error' });
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
res.end();
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
};
|
|
305
|
+
let server;
|
|
306
|
+
if (config.tls) {
|
|
307
|
+
const [cert, key] = await Promise.all([
|
|
308
|
+
environment.file.read(config.tls.cert),
|
|
309
|
+
environment.file.read(config.tls.key),
|
|
310
|
+
]);
|
|
311
|
+
server = (0, https_1.createServer)({ cert, key }, listener);
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
server = (0, http_1.createServer)(listener);
|
|
315
|
+
}
|
|
316
|
+
await new Promise((resolve, reject) => {
|
|
317
|
+
server.once('error', reject);
|
|
318
|
+
server.listen(config.listen.port, config.listen.host, () => {
|
|
319
|
+
server.off('error', reject);
|
|
320
|
+
resolve();
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
const address = server.address();
|
|
324
|
+
const host = address.family === 'IPv6' ? `[${address.address}]` : address.address;
|
|
325
|
+
return {
|
|
326
|
+
url: `${config.tls ? 'https' : 'http'}://${host}:${address.port}`,
|
|
327
|
+
close: async () => {
|
|
328
|
+
proposals.close();
|
|
329
|
+
await registry.close();
|
|
330
|
+
await new Promise((resolve, reject) => {
|
|
331
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
332
|
+
server.closeAllConnections();
|
|
333
|
+
});
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
exports.startServer = startServer;
|
|
338
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.handleSources = void 0;
|
|
4
|
+
const bundle_1 = require("../remote/bundle");
|
|
5
|
+
const source_manifest_1 = require("../remote/source-manifest");
|
|
6
|
+
const http_1 = require("./http");
|
|
7
|
+
const policy_1 = require("./policy");
|
|
8
|
+
const sources_1 = require("./sources");
|
|
9
|
+
/**
|
|
10
|
+
* The project files of a caller, in two steps so that only what the server
|
|
11
|
+
* lacks is sent:
|
|
12
|
+
*
|
|
13
|
+
* POST /v1/sources announce a tree (a manifest of paths and digests);
|
|
14
|
+
* answers its name and the digests still missing
|
|
15
|
+
* PUT /v1/sources/blobs/<sha256> send the content of one file
|
|
16
|
+
* GET /v1/sources the caller's trees and what they take
|
|
17
|
+
* GET /v1/sources/<hash> one tree and what is still missing
|
|
18
|
+
* DELETE /v1/sources/<hash> forget a tree and the content nothing else needs
|
|
19
|
+
*
|
|
20
|
+
* Everything is the caller's own: another caller's tree is `404`, and content is
|
|
21
|
+
* never shared between callers.
|
|
22
|
+
*/
|
|
23
|
+
async function handleSources(config, store, log, principal, req, res, url) {
|
|
24
|
+
const match = /^\/v1\/sources(?:\/(blobs)\/([^/]+)|\/([^/]+))?$/.exec(url.pathname);
|
|
25
|
+
if (!match) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
const [, blobs, digest, rawSnapshot] = match;
|
|
29
|
+
const method = req.method;
|
|
30
|
+
try {
|
|
31
|
+
if (blobs) {
|
|
32
|
+
if (method !== 'PUT') {
|
|
33
|
+
(0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'PUT' });
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
mustMayUpload(config, principal);
|
|
37
|
+
const result = await store.put(principal, digest, req);
|
|
38
|
+
(0, http_1.send)(res, result === 'created' ? 201 : 200, { stored: digest });
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
if (rawSnapshot === undefined) {
|
|
42
|
+
if (method === 'GET') {
|
|
43
|
+
(0, http_1.send)(res, 200, await store.list(principal));
|
|
44
|
+
}
|
|
45
|
+
else if (method === 'POST') {
|
|
46
|
+
mustMayUpload(config, principal);
|
|
47
|
+
const parsed = source_manifest_1.sourceManifestSchema.safeParse(await (0, http_1.readJson)(req, source_manifest_1.MAX_MANIFEST_BODY_BYTES));
|
|
48
|
+
if (!parsed.success) {
|
|
49
|
+
const issue = parsed.error.issues[0];
|
|
50
|
+
(0, http_1.send)(res, 400, { error: `${issue.path.join('.') || 'request'}: ${issue.message}` });
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
const { status, created } = await store.announce(principal, parsed.data);
|
|
54
|
+
log(`source ${status.snapshot} iss=${principal.iss} sub=${principal.sub} files=${status.files} missing=${status.missing.length}`);
|
|
55
|
+
(0, http_1.send)(res, created ? 201 : 200, { ...status, complete: status.missing.length === 0 });
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
(0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET, POST' });
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
const snapshot = decodeSnapshot(rawSnapshot);
|
|
63
|
+
if (method === 'GET') {
|
|
64
|
+
const status = await store.get(principal, snapshot);
|
|
65
|
+
status ? (0, http_1.send)(res, 200, { ...status, complete: status.missing.length === 0 }) : notFound(res);
|
|
66
|
+
}
|
|
67
|
+
else if (method === 'DELETE') {
|
|
68
|
+
const removed = await store.remove(principal, snapshot);
|
|
69
|
+
log(`source remove ${snapshot} iss=${principal.iss} sub=${principal.sub} found=${removed}`);
|
|
70
|
+
removed ? (0, http_1.send)(res, 200, { removed: snapshot }) : notFound(res);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
(0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET, DELETE' });
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
if (e instanceof sources_1.SourceError) {
|
|
79
|
+
(0, http_1.send)(res, e.status, { error: e.message });
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
throw e;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
exports.handleSources = handleSources;
|
|
86
|
+
// The colon of `sha256:<hex>` may arrive escaped.
|
|
87
|
+
function decodeSnapshot(raw) {
|
|
88
|
+
try {
|
|
89
|
+
const value = decodeURIComponent(raw);
|
|
90
|
+
return bundle_1.HASH_PATTERN.test(value) ? value : '';
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return '';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function notFound(res) {
|
|
97
|
+
(0, http_1.send)(res, 404, { error: 'not found' });
|
|
98
|
+
}
|
|
99
|
+
// Sending files is only for callers who may run something from an upload.
|
|
100
|
+
function mustMayUpload(config, principal) {
|
|
101
|
+
const grant = (0, policy_1.getGrant)(config.entitlements, principal);
|
|
102
|
+
if (!(0, policy_1.maySeeDefinitions)(grant) || !grant.source.includes('upload')) {
|
|
103
|
+
throw new sources_1.SourceError(403, 'uploads are not allowed for you');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=sources-api.js.map
|