hammerkit 1.23.0 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/build.schema.json +10 -0
  2. package/dist/cli.js +6 -0
  3. package/dist/executer/execute-command.js +7 -1
  4. package/dist/optimizer/get-cache-directory.js +5 -0
  5. package/dist/planner/utils/append-work-task.js +1 -0
  6. package/dist/planner/work-secret.js +6 -2
  7. package/dist/program.js +6 -0
  8. package/dist/remote/bundle.js +59 -0
  9. package/dist/remote/collect-bundle.js +41 -0
  10. package/dist/remote/credentials.js +93 -0
  11. package/dist/remote/device-login.js +177 -0
  12. package/dist/remote/host-config.js +170 -0
  13. package/dist/remote/location.js +46 -0
  14. package/dist/remote/remote-commands.js +158 -0
  15. package/dist/remote/remote-work-commands.js +224 -0
  16. package/dist/remote/server-client.js +88 -0
  17. package/dist/remote/source-manifest.js +79 -0
  18. package/dist/remote/text-diff.js +89 -0
  19. package/dist/schema/build-file-base-task-schema.js +4 -0
  20. package/dist/schema/build-file-schema.js +3 -0
  21. package/dist/schema/reference-parser.js +9 -2
  22. package/dist/schema/runner-schema.js +13 -0
  23. package/dist/secrets/provider-values.js +4 -0
  24. package/dist/server/accounts-api.js +42 -0
  25. package/dist/server/approvals-api.js +104 -0
  26. package/dist/server/definitions-api.js +163 -0
  27. package/dist/server/definitions.js +160 -0
  28. package/dist/server/discovery.js +9 -0
  29. package/dist/server/executor.js +237 -0
  30. package/dist/server/git-checkout.js +115 -0
  31. package/dist/server/http.js +39 -0
  32. package/dist/server/oauth.js +226 -0
  33. package/dist/server/plan.js +296 -0
  34. package/dist/server/policy.js +138 -0
  35. package/dist/server/proposals-api.js +145 -0
  36. package/dist/server/proposals.js +173 -0
  37. package/dist/server/repo-access.js +127 -0
  38. package/dist/server/repo.js +94 -0
  39. package/dist/server/run-environment.js +20 -0
  40. package/dist/server/run-plan.js +44 -0
  41. package/dist/server/run-request.js +85 -0
  42. package/dist/server/run-store.js +61 -0
  43. package/dist/server/run-worker-command.js +17 -0
  44. package/dist/server/run-worker-main.js +7 -0
  45. package/dist/server/run-worker.js +179 -0
  46. package/dist/server/runs.js +432 -0
  47. package/dist/server/server-command.js +61 -0
  48. package/dist/server/server-config.js +394 -0
  49. package/dist/server/server.js +434 -0
  50. package/dist/server/sources-api.js +106 -0
  51. package/dist/server/sources.js +258 -0
  52. package/package.json +1 -1
@@ -0,0 +1,394 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.detectPlatform = exports.getSecretCatalog = exports.readServerConfig = exports.getSourceAccounts = exports.serverConfigSchema = exports.sourceSchema = exports.entitlementSchema = exports.buildsLevelSchema = exports.accountLevelSchema = exports.accountSchema = 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 build_file_secret_provider_schema_1 = require("../schema/build-file-secret-provider-schema");
9
+ const repo_1 = require("./repo");
10
+ const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '::1'];
11
+ exports.serverPlatformSchema = zod_1.z
12
+ .object({
13
+ os: zod_1.z.enum(['linux', 'macos', 'windows']),
14
+ arch: zod_1.z.enum(['amd64', 'arm64']),
15
+ })
16
+ .strict();
17
+ // The issuer is compared with the `iss` of a token as written, and its keys are
18
+ // fetched from it, so it has to be https (http only for local testing).
19
+ const issuerUrlSchema = zod_1.z.string().refine((value) => {
20
+ try {
21
+ const url = new URL(value);
22
+ return (url.protocol === 'https:' ||
23
+ (url.protocol === 'http:' && LOOPBACK_HOSTS.includes(url.hostname.replace(/^\[|\]$/g, ''))));
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }, 'issuer must be an https url (http only for localhost)');
29
+ exports.issuerSchema = zod_1.z
30
+ .object({
31
+ issuer: issuerUrlSchema,
32
+ audience: zod_1.z.string().min(1),
33
+ // the claim listing the groups of the caller
34
+ groupsClaim: zod_1.z.string().min(1).default('groups'),
35
+ // the public OAuth client `remote login` signs in with (device authorization
36
+ // grant); without one this issuer can only be used with a token obtained
37
+ // elsewhere, such as a CI job's or an agent's own
38
+ clientId: zod_1.z.string().min(1).optional(),
39
+ // what the login asks for, such as `openid api://hammerkit/.default`
40
+ scope: zod_1.z.string().min(1).default('openid'),
41
+ })
42
+ .strict();
43
+ // What a published build file is called: the project, as a build file author names it.
44
+ exports.definitionName = zod_1.z
45
+ .string()
46
+ .regex(/^[a-z0-9][a-z0-9._-]{0,63}$/i, 'use letters, digits, ".", "_" and "-", starting with a letter or digit');
47
+ const subjectSchema = zod_1.z.object({ iss: zod_1.z.string().min(1), sub: zod_1.z.string().min(1) }).strict();
48
+ const groupSchema = zod_1.z.object({ iss: zod_1.z.string().min(1), name: zod_1.z.string().min(1) }).strict();
49
+ const claimsSchema = zod_1.z.object({ iss: zod_1.z.string().min(1), match: zod_1.z.record(zod_1.z.string()) }).strict();
50
+ // Who: the person or client (`subject`), everyone in a group of an issuer
51
+ // (`group`), or any token of an issuer whose claims match (`claims`, for CI jobs:
52
+ // repository, ref, environment). Only validated token claims are matched.
53
+ const whoShape = {
54
+ subject: subjectSchema.optional(),
55
+ group: groupSchema.optional(),
56
+ claims: claimsSchema.optional(),
57
+ };
58
+ function checkWho(who, ctx) {
59
+ const named = [who.subject, who.group, who.claims].filter((w) => w !== undefined);
60
+ if (named.length !== 1) {
61
+ ctx.addIssue({ code: zod_1.z.ZodIssueCode.custom, message: 'name exactly one of subject, group or claims' });
62
+ }
63
+ if (who.claims && Object.keys(who.claims.match).length === 0) {
64
+ ctx.addIssue({ code: zod_1.z.ZodIssueCode.custom, path: ['claims', 'match'], message: 'match at least one claim' });
65
+ }
66
+ }
67
+ // The service accounts a server knows. A run never names one: the accounts it
68
+ // uses are the ones its secrets are read as, and an entitlement says who may use
69
+ // or only request each.
70
+ const accountNameSchema = zod_1.z
71
+ .string()
72
+ .regex(/^[A-Za-z][A-Za-z0-9_-]*$/, 'a name starts with a letter and holds letters, digits, - and _');
73
+ exports.accountSchema = zod_1.z
74
+ .object({
75
+ description: zod_1.z.string().min(1).max(200).optional(),
76
+ // per secret provider, the environment that makes its command act as this
77
+ // account; the credentials are files and variables of the operator's machine
78
+ providers: build_file_secret_provider_schema_1.buildFileSecretAccountSchema.shape.providers.default({}),
79
+ // where the code of a run using this account may come from; both when left out
80
+ source: zod_1.z
81
+ .array(zod_1.z.enum(['upload', 'ref']))
82
+ .min(1)
83
+ .optional(),
84
+ // the refs (branches or tags, `release/*` for a level) a run using this account
85
+ // may be started from; a commit is not accepted then, it names no branch
86
+ refs: zod_1.z.array(zod_1.z.string().min(1).max(200)).min(1).optional(),
87
+ })
88
+ .strict()
89
+ .superRefine((account, ctx) => {
90
+ if (account.refs && (account.source?.length !== 1 || account.source[0] !== 'ref')) {
91
+ ctx.addIssue({
92
+ code: zod_1.z.ZodIssueCode.custom,
93
+ path: ['refs'],
94
+ message: 'refs only restricts runs from a git ref, set source: [ref] too, an upload names no ref',
95
+ });
96
+ }
97
+ });
98
+ exports.accountLevelSchema = zod_1.z.enum(['use', 'request']);
99
+ // What a caller may do with the build files: run what is published, propose new
100
+ // versions that someone else approves, or publish. Each level includes the lower.
101
+ exports.buildsLevelSchema = zod_1.z.enum(['run', 'propose', 'publish']);
102
+ const repoPatternSchema = zod_1.z.string().refine(repo_1.isRepoPattern, 'expected host/path, such as github.com/corp/*');
103
+ exports.entitlementSchema = zod_1.z
104
+ .object({
105
+ ...whoShape,
106
+ // `use`: runs on the account at once and approves the runs others request on it;
107
+ // `request`: runs wait until somebody who may `use` the account approves them
108
+ accounts: zod_1.z.record(accountNameSchema, exports.accountLevelSchema).default({}),
109
+ // the repositories (`host/path` patterns) the caller may run from a git ref
110
+ repos: zod_1.z.array(repoPatternSchema).default([]),
111
+ builds: exports.buildsLevelSchema.optional(),
112
+ })
113
+ .strict()
114
+ .superRefine((entitlement, ctx) => {
115
+ checkWho(entitlement, ctx);
116
+ if (Object.keys(entitlement.accounts).length === 0 && entitlement.builds === undefined) {
117
+ ctx.addIssue({
118
+ code: zod_1.z.ZodIssueCode.custom,
119
+ message: 'name accounts to use or request, or a builds level',
120
+ });
121
+ }
122
+ });
123
+ // A repository the server can read: `github.com/corp/*` for an organisation,
124
+ // `gitlab.corp.com/platform/**` for a group with its subgroups. The credential is a
125
+ // secret of a provider, read as `account`, and handed to git for that fetch only.
126
+ exports.sourceSchema = zod_1.z
127
+ .object({
128
+ match: repoPatternSchema,
129
+ secret: zod_1.z
130
+ .object({
131
+ from: zod_1.z.string().regex(/^[A-Za-z][A-Za-z0-9_-]*:.+$/, 'expected provider:reference'),
132
+ account: accountNameSchema,
133
+ // the user name of the basic authentication the token is sent with
134
+ username: zod_1.z.string().min(1).default('x-access-token'),
135
+ })
136
+ .strict()
137
+ .optional(),
138
+ })
139
+ .strict();
140
+ const duration = zod_1.z.string().refine((value) => {
141
+ try {
142
+ return (0, units_1.parseDuration)(value) > 0;
143
+ }
144
+ catch {
145
+ return false;
146
+ }
147
+ }, 'expected a duration like 30m, 1h or 1d');
148
+ // The server config is written by an operator and reviewed like code, so a typo
149
+ // is an error and not a silently ignored key.
150
+ exports.serverConfigSchema = zod_1.z
151
+ .object({
152
+ listen: zod_1.z
153
+ .object({
154
+ host: zod_1.z.string().min(1).default('127.0.0.1'),
155
+ port: zod_1.z.number().int().min(0).max(65535).default(8480),
156
+ })
157
+ .strict()
158
+ .default({}),
159
+ // paths of a certificate and key
160
+ tls: zod_1.z
161
+ .object({ cert: zod_1.z.string().min(1), key: zod_1.z.string().min(1) })
162
+ .strict()
163
+ .optional(),
164
+ // plain http carries OAuth tokens, so it is only accepted on the loopback
165
+ // interface, or when a proxy in front terminates TLS and this says so
166
+ behindTlsProxy: zod_1.z.boolean().default(false),
167
+ // the OAuth issuers whose tokens the server accepts, and the audience those
168
+ // tokens have to carry
169
+ issuers: zod_1.z.array(exports.issuerSchema).min(1, 'at least one issuer is needed, OAuth is the only way to call the server'),
170
+ // the service accounts, and the commands that read secrets as them; a build file
171
+ // can not declare either, only name an account on its secrets
172
+ accounts: zod_1.z.record(accountNameSchema, exports.accountSchema).default({}),
173
+ secretProviders: build_file_secret_provider_schema_1.buildFileSecretProvidersSchema.default({}),
174
+ // who may use which service account and which build files; default deny
175
+ entitlements: zod_1.z.array(exports.entitlementSchema).default([]),
176
+ approvals: zod_1.z
177
+ .object({
178
+ // how long a run waits for its approval before it is rejected
179
+ timeout: duration.default('1h'),
180
+ // runs waiting at the same time, more are refused
181
+ maxWaiting: zod_1.z.number().int().min(1).default(100),
182
+ // after a version was rejected, who proposed it can not propose that build
183
+ // file again, and nobody that version, for this long
184
+ cooldown: duration.default('10m'),
185
+ })
186
+ .strict()
187
+ .default({}),
188
+ // the repositories a run may get its code from, as patterns of `host/path`, and
189
+ // the secret that reads them: a token in a secret provider, read as a service
190
+ // account of the operator. The caller names a repository and a ref in the request,
191
+ // never a url.
192
+ sources: zod_1.z.array(exports.sourceSchema).default([]),
193
+ // what tasks may run on: containers, and the local runtime that runs commands on
194
+ // this machine as the user of the server. Local tasks read the whole file system of that
195
+ // user, so they have to be allowed by name.
196
+ runtimes: zod_1.z
197
+ .array(zod_1.z.enum(['docker', 'local']))
198
+ .min(1)
199
+ .default(['docker']),
200
+ // where the server keeps what must survive a restart: the published build files
201
+ storage: zod_1.z
202
+ .object({ directory: zod_1.z.string().min(1).optional() })
203
+ .strict()
204
+ .default({}),
205
+ // limits of the run registry
206
+ runs: zod_1.z
207
+ .object({
208
+ // runs that execute at the same time, the others wait in queue
209
+ maxConcurrent: zod_1.z.number().int().min(1).default(4),
210
+ // finished runs that are kept for their state and logs
211
+ keep: zod_1.z.number().int().min(1).default(200),
212
+ // what one caller (issuer and subject) may have at a time, so a single
213
+ // caller can not take the whole machine; over it a request gets 429
214
+ limits: zod_1.z
215
+ .object({
216
+ // runs that wait for an approval
217
+ waiting: zod_1.z.number().int().min(1).default(10),
218
+ // runs that wait in the queue for a free place
219
+ queued: zod_1.z.number().int().min(1).default(20),
220
+ // runs that execute at the same time, the others wait in the queue
221
+ running: zod_1.z.number().int().min(1).default(2),
222
+ })
223
+ .strict()
224
+ .default({}),
225
+ // a run that executes longer is stopped and fails
226
+ maxDuration: duration.default('1h'),
227
+ // log output kept per run; the rest is cut off
228
+ maxLogBytes: zod_1.z
229
+ .number()
230
+ .int()
231
+ .min(1024)
232
+ .default(1024 * 1024),
233
+ // the environment of a run: the variables of the server process that tasks
234
+ // (and the git fetch) can see. Nothing else of the server's environment
235
+ // is passed on, so there is no list built in; an operator names what the
236
+ // tools of the host need, such as PATH and DOCKER_HOST.
237
+ env: zod_1.z
238
+ .object({
239
+ allow: zod_1.z.array(zod_1.z.string().regex(/^[A-Za-z_][A-Za-z0-9_()]*$/)).default([]),
240
+ // fixed values that are set in addition
241
+ set: zod_1.z.record(zod_1.z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/), zod_1.z.string()).default({}),
242
+ })
243
+ .strict()
244
+ .default({}),
245
+ })
246
+ .strict()
247
+ .default({}),
248
+ backend: zod_1.z
249
+ .object({
250
+ type: zod_1.z.literal('host'),
251
+ // what this machine offers; detected when left out
252
+ platform: exports.serverPlatformSchema.optional(),
253
+ })
254
+ .strict()
255
+ .default({ type: 'host' }),
256
+ })
257
+ .strict()
258
+ .superRefine((config, ctx) => {
259
+ const issuers = config.issuers.map((i) => i.issuer);
260
+ config.entitlements.forEach((entitlement, index) => {
261
+ const iss = (entitlement.subject ?? entitlement.group ?? entitlement.claims)?.iss;
262
+ if (iss && !issuers.includes(iss)) {
263
+ ctx.addIssue({
264
+ code: zod_1.z.ZodIssueCode.custom,
265
+ path: ['entitlements', index],
266
+ message: `issuer ${iss} is not one of the accepted issuers`,
267
+ });
268
+ }
269
+ });
270
+ for (const [name, account] of Object.entries(config.accounts)) {
271
+ for (const provider of Object.keys(account.providers)) {
272
+ if (!config.secretProviders[provider]) {
273
+ ctx.addIssue({
274
+ code: zod_1.z.ZodIssueCode.custom,
275
+ path: ['accounts', name, 'providers', provider],
276
+ message: `secret provider ${provider} is not declared in secretProviders`,
277
+ });
278
+ }
279
+ }
280
+ }
281
+ config.entitlements.forEach((entitlement, index) => {
282
+ for (const account of Object.keys(entitlement.accounts)) {
283
+ if (!config.accounts[account]) {
284
+ const known = Object.keys(config.accounts);
285
+ ctx.addIssue({
286
+ code: zod_1.z.ZodIssueCode.custom,
287
+ path: ['entitlements', index, 'accounts', account],
288
+ message: `unknown account ${account}, ${known.length > 0 ? `accounts: ${known.join(', ')}` : 'none is defined'}`,
289
+ });
290
+ }
291
+ }
292
+ });
293
+ config.sources.forEach((source, index) => {
294
+ if (!source.secret) {
295
+ return;
296
+ }
297
+ const { from, account } = source.secret;
298
+ const kind = from.substring(0, from.indexOf(':'));
299
+ const where = ['sources', index, 'secret'];
300
+ if (!config.accounts[account]) {
301
+ ctx.addIssue({
302
+ code: zod_1.z.ZodIssueCode.custom,
303
+ path: [...where, 'account'],
304
+ message: `unknown account ${account}`,
305
+ });
306
+ }
307
+ else if (!config.accounts[account].providers[kind]) {
308
+ ctx.addIssue({
309
+ code: zod_1.z.ZodIssueCode.custom,
310
+ path: [...where, 'from'],
311
+ message: `account ${account} has no credentials for the provider ${kind}`,
312
+ });
313
+ }
314
+ if (!config.secretProviders[kind]) {
315
+ ctx.addIssue({
316
+ code: zod_1.z.ZodIssueCode.custom,
317
+ path: [...where, 'from'],
318
+ message: `secret provider ${kind} is not declared in secretProviders`,
319
+ });
320
+ }
321
+ });
322
+ const readers = getSourceAccounts(config);
323
+ config.entitlements.forEach((entitlement, index) => {
324
+ for (const account of Object.keys(entitlement.accounts)) {
325
+ if (readers.includes(account)) {
326
+ ctx.addIssue({
327
+ code: zod_1.z.ZodIssueCode.custom,
328
+ path: ['entitlements', index, 'accounts', account],
329
+ message: `the account ${account} reads the sources, no entitlement may name it`,
330
+ });
331
+ }
332
+ }
333
+ });
334
+ if (!config.tls && !config.behindTlsProxy && !LOOPBACK_HOSTS.includes(config.listen.host)) {
335
+ ctx.addIssue({
336
+ code: zod_1.z.ZodIssueCode.custom,
337
+ path: ['listen', 'host'],
338
+ message: `plain http on ${config.listen.host} would send tokens in the clear, set tls or behindTlsProxy: true`,
339
+ });
340
+ }
341
+ });
342
+ // The accounts that read repositories: no caller uses them and no build file names them.
343
+ function getSourceAccounts(config) {
344
+ return [...new Set(config.sources.flatMap((source) => (source.secret ? [source.secret.account] : [])))];
345
+ }
346
+ exports.getSourceAccounts = getSourceAccounts;
347
+ async function readServerConfig(environment, path) {
348
+ if (!(await environment.file.exists(path))) {
349
+ throw new Error(`unable to read ${path}: no such file`);
350
+ }
351
+ let raw;
352
+ try {
353
+ raw = (0, yaml_1.parse)(await environment.file.read(path));
354
+ }
355
+ catch (e) {
356
+ throw new Error(`unable to read ${path}: ${(0, log_1.getErrorMessage)(e)}`);
357
+ }
358
+ const result = exports.serverConfigSchema.safeParse(raw ?? {});
359
+ if (!result.success) {
360
+ const issue = result.error.issues[0];
361
+ const where = issue.path.length > 0 ? `${issue.path.join('.')}: ` : '';
362
+ throw new Error(`invalid ${path}: ${where}${issue.message}`);
363
+ }
364
+ return result.data;
365
+ }
366
+ exports.readServerConfig = readServerConfig;
367
+ // The secret providers and accounts of the config as the engine reads them, so a
368
+ // secret of a build file is read as an account of the operator.
369
+ function getSecretCatalog(config) {
370
+ const providers = {};
371
+ for (const [name, provider] of Object.entries(config.secretProviders)) {
372
+ providers[name] = {
373
+ name,
374
+ type: 'command',
375
+ command: provider.command,
376
+ env: provider.env ?? {},
377
+ timeoutMs: (0, units_1.parseDuration)(provider.timeout ?? '30s'),
378
+ declaredIn: 'the server config',
379
+ };
380
+ }
381
+ const accounts = {};
382
+ for (const [name, account] of Object.entries(config.accounts)) {
383
+ accounts[name] = { name, default: false, providers: account.providers, declaredIn: 'the server config' };
384
+ }
385
+ return { providers, accounts, explicitAccounts: true };
386
+ }
387
+ exports.getSecretCatalog = getSecretCatalog;
388
+ function detectPlatform() {
389
+ const os = { darwin: 'macos', win32: 'windows' }[process.platform] ?? 'linux';
390
+ const arch = process.arch === 'arm64' ? 'arm64' : 'amd64';
391
+ return { os, arch };
392
+ }
393
+ exports.detectPlatform = detectPlatform;
394
+ //# sourceMappingURL=server-config.js.map