hammerkit 1.22.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 +92 -2
- package/dist/cli.js +25 -0
- package/dist/executer/environment-mock.js +4 -1
- package/dist/executer/execute-command.js +7 -1
- package/dist/index.js +4 -1
- package/dist/planner/service-definition.js +3 -1
- package/dist/planner/utils/append-work-service.js +1 -1
- package/dist/planner/utils/append-work-task.js +2 -1
- package/dist/planner/work-item-status.js +3 -2
- package/dist/planner/work-secret.js +87 -10
- 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 +6 -0
- package/dist/schema/build-file-container-service-schema.js +2 -0
- package/dist/schema/build-file-schema.js +6 -0
- package/dist/schema/build-file-secret-provider-schema.js +36 -0
- package/dist/schema/build-file-secret-schema.js +6 -3
- package/dist/schema/reference-parser.js +4 -0
- package/dist/schema/runner-schema.js +13 -0
- package/dist/secrets/command-secret-provider.js +108 -0
- package/dist/secrets/prefetch-secrets.js +27 -0
- package/dist/secrets/provider-values.js +47 -0
- package/dist/secrets/secret-catalog.js +78 -0
- package/dist/secrets/secret-provider.js +19 -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/dist/utils/redact.js +75 -19
- package/package.json +1 -1
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.unavailableExecutor = exports.RunRegistry = exports.RUN_ID_PATTERN = exports.RunError = exports.isFinal = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const FINAL_STATES = ['succeeded', 'failed', 'cancelled', 'rejected'];
|
|
6
|
+
function isFinal(state) {
|
|
7
|
+
return FINAL_STATES.includes(state);
|
|
8
|
+
}
|
|
9
|
+
exports.isFinal = isFinal;
|
|
10
|
+
class RunError extends Error {
|
|
11
|
+
}
|
|
12
|
+
exports.RunError = RunError;
|
|
13
|
+
exports.RUN_ID_PATTERN = /^run_[A-Za-z0-9_-]{16}$/;
|
|
14
|
+
// Where runs are kept while the server is up. Everything is scoped to the
|
|
15
|
+
// caller that submitted the run: the same id asked for by someone else is
|
|
16
|
+
// "not found", so ids can't be used to find out which runs exist.
|
|
17
|
+
class RunRegistry {
|
|
18
|
+
constructor(options) {
|
|
19
|
+
this.options = options;
|
|
20
|
+
this.entries = new Map();
|
|
21
|
+
this.queue = [];
|
|
22
|
+
this.running = 0;
|
|
23
|
+
this.closed = false;
|
|
24
|
+
this.maxConcurrent = options.maxConcurrent ?? 4;
|
|
25
|
+
this.keep = options.keep ?? 200;
|
|
26
|
+
this.maxEvents = options.maxEvents ?? 5000;
|
|
27
|
+
this.maxWaiting = options.maxWaiting ?? 100;
|
|
28
|
+
this.approvalTimeout = options.approvalTimeout ?? 60 * 60 * 1000;
|
|
29
|
+
this.now = options.now ?? Date.now;
|
|
30
|
+
}
|
|
31
|
+
// A run that needs an approval waits for it, the others are queued at once.
|
|
32
|
+
submit(owner, spec, needsApproval = false) {
|
|
33
|
+
if (this.closed) {
|
|
34
|
+
throw new RunError('the server is shutting down');
|
|
35
|
+
}
|
|
36
|
+
const waiting = needsApproval;
|
|
37
|
+
if (waiting && this.waiting().length >= this.maxWaiting) {
|
|
38
|
+
throw new RunError('too many runs are waiting for an approval');
|
|
39
|
+
}
|
|
40
|
+
const run = {
|
|
41
|
+
...spec,
|
|
42
|
+
id: `run_${(0, crypto_1.randomBytes)(12).toString('base64url')}`,
|
|
43
|
+
owner: { iss: owner.iss, sub: owner.sub },
|
|
44
|
+
state: waiting ? 'awaiting-approval' : 'queued',
|
|
45
|
+
createdAt: this.timestamp(),
|
|
46
|
+
};
|
|
47
|
+
const entry = { run, events: [], listeners: new Set(), abort: new AbortController(), truncated: false };
|
|
48
|
+
this.entries.set(run.id, entry);
|
|
49
|
+
this.record(entry, { type: 'state', state: run.state });
|
|
50
|
+
if (waiting) {
|
|
51
|
+
run.approval = {
|
|
52
|
+
expiresAt: new Date(this.now() + this.approvalTimeout).toISOString(),
|
|
53
|
+
};
|
|
54
|
+
entry.timer = setTimeout(() => this.expire(entry), this.approvalTimeout);
|
|
55
|
+
entry.timer.unref();
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
this.queue.push(entry);
|
|
59
|
+
}
|
|
60
|
+
this.evict();
|
|
61
|
+
// what the caller is told is the run as accepted, even when it starts at once
|
|
62
|
+
const accepted = this.copy(run);
|
|
63
|
+
this.dispatch();
|
|
64
|
+
return accepted;
|
|
65
|
+
}
|
|
66
|
+
get(owner, id) {
|
|
67
|
+
const entry = this.find(owner, id);
|
|
68
|
+
return entry ? this.copy(entry.run) : undefined;
|
|
69
|
+
}
|
|
70
|
+
// newest first
|
|
71
|
+
list(owner) {
|
|
72
|
+
return [...this.entries.values()]
|
|
73
|
+
.filter((entry) => this.owns(owner, entry))
|
|
74
|
+
.map((entry) => this.copy(entry.run))
|
|
75
|
+
.reverse();
|
|
76
|
+
}
|
|
77
|
+
// A queued run is cancelled at once; a running one when its executor stops.
|
|
78
|
+
cancel(owner, id) {
|
|
79
|
+
const entry = this.find(owner, id);
|
|
80
|
+
if (!entry) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
if (entry.run.state === 'queued') {
|
|
84
|
+
this.queue.splice(this.queue.indexOf(entry), 1);
|
|
85
|
+
this.finish(entry, 'cancelled');
|
|
86
|
+
}
|
|
87
|
+
else if (entry.run.state === 'awaiting-approval') {
|
|
88
|
+
this.finish(entry, 'cancelled');
|
|
89
|
+
}
|
|
90
|
+
else if (entry.run.state === 'running') {
|
|
91
|
+
entry.abort.abort();
|
|
92
|
+
}
|
|
93
|
+
return this.copy(entry.run);
|
|
94
|
+
}
|
|
95
|
+
// The runs that wait for an approval, of anybody, oldest first. Who may see
|
|
96
|
+
// them is for the caller to decide.
|
|
97
|
+
waiting() {
|
|
98
|
+
return [...this.entries.values()]
|
|
99
|
+
.filter((entry) => entry.run.state === 'awaiting-approval')
|
|
100
|
+
.map((entry) => this.copy(entry.run));
|
|
101
|
+
}
|
|
102
|
+
// One run that waits (or waited), if `canDecide` says the decider is admin of
|
|
103
|
+
// its account.
|
|
104
|
+
getForApproval(id, canDecide) {
|
|
105
|
+
const entry = this.entries.get(id);
|
|
106
|
+
if (!entry || !this.mayDecide(entry, canDecide)) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
return this.copy(entry.run);
|
|
110
|
+
}
|
|
111
|
+
// An approval queues the run, a rejection ends it. Nobody decides on their own run.
|
|
112
|
+
decide(decider, id, outcome, canDecide, reason) {
|
|
113
|
+
const entry = this.entries.get(id);
|
|
114
|
+
if (!entry || !this.mayDecide(entry, canDecide)) {
|
|
115
|
+
return { status: 'not-found' };
|
|
116
|
+
}
|
|
117
|
+
if (this.owns(decider, entry)) {
|
|
118
|
+
return { status: 'own-run' };
|
|
119
|
+
}
|
|
120
|
+
if (entry.run.state !== 'awaiting-approval' || !entry.run.approval) {
|
|
121
|
+
return { status: 'not-waiting', state: entry.run.state };
|
|
122
|
+
}
|
|
123
|
+
const by = { iss: decider.iss, sub: decider.sub };
|
|
124
|
+
this.record(entry, { type: 'approval', outcome, by, ...(reason ? { reason } : {}) });
|
|
125
|
+
if (outcome === 'rejected') {
|
|
126
|
+
this.finish(entry, 'rejected', `rejected by ${by.sub}${reason ? `: ${reason}` : ''}`);
|
|
127
|
+
return { status: 'ok', run: this.copy(entry.run) };
|
|
128
|
+
}
|
|
129
|
+
entry.run.approval.approvedBy = by;
|
|
130
|
+
entry.run.approval.approvedAt = this.timestamp();
|
|
131
|
+
this.clearTimer(entry);
|
|
132
|
+
entry.run.state = 'queued';
|
|
133
|
+
this.record(entry, { type: 'state', state: 'queued' });
|
|
134
|
+
this.queue.push(entry);
|
|
135
|
+
const approved = this.copy(entry.run);
|
|
136
|
+
this.dispatch();
|
|
137
|
+
return { status: 'ok', run: approved };
|
|
138
|
+
}
|
|
139
|
+
// The events after `after` (0 for all), and a way to follow new ones. The
|
|
140
|
+
// listener is not called for a run that is already over.
|
|
141
|
+
watch(owner, id, after, listener) {
|
|
142
|
+
const entry = this.find(owner, id);
|
|
143
|
+
if (!entry) {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
const events = entry.events.filter((event) => event.seq > after);
|
|
147
|
+
const finished = FINAL_STATES.includes(entry.run.state);
|
|
148
|
+
if (!finished) {
|
|
149
|
+
entry.listeners.add(listener);
|
|
150
|
+
}
|
|
151
|
+
return { events, finished, stop: () => entry.listeners.delete(listener) };
|
|
152
|
+
}
|
|
153
|
+
// Stops accepting runs and aborts the ones in flight.
|
|
154
|
+
async close() {
|
|
155
|
+
this.closed = true;
|
|
156
|
+
for (const entry of this.queue.splice(0)) {
|
|
157
|
+
this.finish(entry, 'cancelled');
|
|
158
|
+
}
|
|
159
|
+
for (const entry of this.entries.values()) {
|
|
160
|
+
if (entry.run.state === 'awaiting-approval') {
|
|
161
|
+
this.finish(entry, 'cancelled');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
for (const entry of this.entries.values()) {
|
|
165
|
+
if (entry.run.state === 'running') {
|
|
166
|
+
entry.abort.abort();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
while (this.running > 0) {
|
|
170
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
find(owner, id) {
|
|
174
|
+
const entry = this.entries.get(id);
|
|
175
|
+
return entry && this.owns(owner, entry) ? entry : undefined;
|
|
176
|
+
}
|
|
177
|
+
mayDecide(entry, canDecide) {
|
|
178
|
+
return entry.run.approval !== undefined && canDecide(entry.run.account);
|
|
179
|
+
}
|
|
180
|
+
expire(entry) {
|
|
181
|
+
if (entry.run.state === 'awaiting-approval') {
|
|
182
|
+
this.finish(entry, 'rejected', 'nobody approved it in time');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
clearTimer(entry) {
|
|
186
|
+
if (entry.timer) {
|
|
187
|
+
clearTimeout(entry.timer);
|
|
188
|
+
entry.timer = undefined;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
owns(owner, entry) {
|
|
192
|
+
return entry.run.owner.iss === owner.iss && entry.run.owner.sub === owner.sub;
|
|
193
|
+
}
|
|
194
|
+
copy(run) {
|
|
195
|
+
return {
|
|
196
|
+
...run,
|
|
197
|
+
tasks: [...run.tasks],
|
|
198
|
+
definition: { ...run.definition },
|
|
199
|
+
source: { ...run.source },
|
|
200
|
+
options: JSON.parse(JSON.stringify(run.options)),
|
|
201
|
+
owner: { ...run.owner },
|
|
202
|
+
...(run.approval ? { approval: JSON.parse(JSON.stringify(run.approval)) } : {}),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
timestamp() {
|
|
206
|
+
return new Date(this.now()).toISOString();
|
|
207
|
+
}
|
|
208
|
+
dispatch() {
|
|
209
|
+
while (this.running < this.maxConcurrent && this.queue.length > 0) {
|
|
210
|
+
const entry = this.queue.shift();
|
|
211
|
+
this.running++;
|
|
212
|
+
this.execute(entry).finally(() => {
|
|
213
|
+
this.running--;
|
|
214
|
+
this.dispatch();
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
async execute(entry) {
|
|
219
|
+
entry.run.state = 'running';
|
|
220
|
+
entry.run.startedAt = this.timestamp();
|
|
221
|
+
this.record(entry, { type: 'state', state: 'running' });
|
|
222
|
+
try {
|
|
223
|
+
await this.options.executor.execute(this.copy(entry.run), {
|
|
224
|
+
emit: (event) => this.record(entry, event),
|
|
225
|
+
signal: entry.abort.signal,
|
|
226
|
+
});
|
|
227
|
+
this.finish(entry, entry.abort.signal.aborted ? 'cancelled' : 'succeeded');
|
|
228
|
+
}
|
|
229
|
+
catch (e) {
|
|
230
|
+
this.finish(entry, entry.abort.signal.aborted ? 'cancelled' : 'failed', entry.abort.signal.aborted ? undefined : e instanceof Error ? e.message : String(e));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
finish(entry, state, error) {
|
|
234
|
+
this.clearTimer(entry);
|
|
235
|
+
entry.run.state = state;
|
|
236
|
+
entry.run.finishedAt = this.timestamp();
|
|
237
|
+
if (error) {
|
|
238
|
+
entry.run.error = error;
|
|
239
|
+
}
|
|
240
|
+
this.record(entry, { type: 'state', state, ...(error ? { error } : {}) }, true);
|
|
241
|
+
entry.listeners.clear();
|
|
242
|
+
this.evict();
|
|
243
|
+
}
|
|
244
|
+
record(entry, event, force = false) {
|
|
245
|
+
// an executor that keeps talking can't fill the memory of the server; the
|
|
246
|
+
// state changes, which are few, are always kept
|
|
247
|
+
if (!force && event.type !== 'state' && entry.events.length >= this.maxEvents) {
|
|
248
|
+
if (!entry.truncated) {
|
|
249
|
+
entry.truncated = true;
|
|
250
|
+
this.push(entry, { type: 'truncated' });
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
this.push(entry, event);
|
|
255
|
+
}
|
|
256
|
+
push(entry, event) {
|
|
257
|
+
const stored = { ...event, seq: entry.events.length + 1, time: this.timestamp() };
|
|
258
|
+
entry.events.push(stored);
|
|
259
|
+
for (const listener of entry.listeners) {
|
|
260
|
+
listener(stored);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Only finished runs are dropped, oldest first.
|
|
264
|
+
evict() {
|
|
265
|
+
const finished = [...this.entries.values()].filter((entry) => FINAL_STATES.includes(entry.run.state));
|
|
266
|
+
for (const entry of finished.slice(0, Math.max(0, finished.length - this.keep))) {
|
|
267
|
+
this.entries.delete(entry.run.id);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
exports.RunRegistry = RunRegistry;
|
|
272
|
+
// Used until a backend is wired in: runs are accepted, recorded and failed with
|
|
273
|
+
// a reason, so the API can be used and tested without pretending to execute.
|
|
274
|
+
exports.unavailableExecutor = {
|
|
275
|
+
async execute() {
|
|
276
|
+
throw new Error('this server has no backend that can execute runs yet');
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
//# sourceMappingURL=runs.js.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerServerCommand = void 0;
|
|
4
|
+
const commander_1 = require("commander");
|
|
5
|
+
const log_1 = require("../log");
|
|
6
|
+
const server_config_1 = require("./server-config");
|
|
7
|
+
const server_1 = require("./server");
|
|
8
|
+
// Runs this machine as a hammerkit server until interrupted. Like `remote`, it
|
|
9
|
+
// only needs its own config file, not a build file.
|
|
10
|
+
function registerServerCommand(program, environment) {
|
|
11
|
+
program
|
|
12
|
+
.command('server')
|
|
13
|
+
.description('serve this machine to remote runs (experimental)')
|
|
14
|
+
.addOption(new commander_1.Option('-c, --config <file>', 'server config file').env('HAMMERKIT_SERVER_CONFIG'))
|
|
15
|
+
.action(async (options) => {
|
|
16
|
+
try {
|
|
17
|
+
if (!options.config) {
|
|
18
|
+
throw new Error('pass the server config with --config or HAMMERKIT_SERVER_CONFIG');
|
|
19
|
+
}
|
|
20
|
+
const config = await (0, server_config_1.readServerConfig)(environment, options.config);
|
|
21
|
+
const server = await (0, server_1.startServer)(config, environment, {
|
|
22
|
+
log: (line) => environment.stdout.write(`${line}\n`),
|
|
23
|
+
});
|
|
24
|
+
environment.stdout.write(`hammerkit server listening on ${server.url}\n`);
|
|
25
|
+
await new Promise((resolve) => {
|
|
26
|
+
if (environment.abortCtrl.signal.aborted) {
|
|
27
|
+
resolve();
|
|
28
|
+
}
|
|
29
|
+
environment.abortCtrl.signal.addEventListener('abort', () => resolve(), { once: true });
|
|
30
|
+
});
|
|
31
|
+
await server.close();
|
|
32
|
+
environment.stdout.write('hammerkit server stopped\n');
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
if (e instanceof commander_1.CommanderError) {
|
|
36
|
+
throw e;
|
|
37
|
+
}
|
|
38
|
+
program.error(`Server was not successful: ${(0, log_1.getErrorMessage)(e)}`, { exitCode: 1 });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
exports.registerServerCommand = registerServerCommand;
|
|
43
|
+
//# sourceMappingURL=server-command.js.map
|
|
@@ -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
|