hammerkit 1.24.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.
@@ -1,10 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.detectPlatform = exports.readServerConfig = exports.serverConfigSchema = exports.entitlementSchema = exports.definitionName = exports.issuerSchema = exports.serverPlatformSchema = void 0;
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
4
  const zod_1 = require("zod");
5
5
  const yaml_1 = require("yaml");
6
6
  const log_1 = require("../log");
7
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");
8
10
  const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '::1'];
9
11
  exports.serverPlatformSchema = zod_1.z
10
12
  .object({
@@ -62,50 +64,79 @@ function checkWho(who, ctx) {
62
64
  ctx.addIssue({ code: zod_1.z.ZodIssueCode.custom, path: ['claims', 'match'], message: 'match at least one claim' });
63
65
  }
64
66
  }
65
- exports.entitlementSchema = zod_1.z
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
66
74
  .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
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
83
80
  source: zod_1.z
84
81
  .array(zod_1.z.enum(['upload', 'ref']))
85
82
  .min(1)
86
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
87
  })
88
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) {
89
+ .superRefine((account, ctx) => {
90
+ if (account.refs && (account.source?.length !== 1 || account.source[0] !== 'ref')) {
95
91
  ctx.addIssue({
96
92
  code: zod_1.z.ZodIssueCode.custom,
97
- message: 'name accounts to use or request, or build files to publish or propose',
93
+ path: ['refs'],
94
+ message: 'refs only restricts runs from a git ref, set source: [ref] too, an upload names no ref',
98
95
  });
99
96
  }
100
- if (entitlement.default &&
101
- ![...entitlement.accounts, ...(entitlement.request ?? [])].includes(entitlement.default)) {
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) {
102
117
  ctx.addIssue({
103
118
  code: zod_1.z.ZodIssueCode.custom,
104
- path: ['default'],
105
- message: 'default has to be one of accounts or request',
119
+ message: 'name accounts to use or request, or a builds level',
106
120
  });
107
121
  }
108
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();
109
140
  const duration = zod_1.z.string().refine((value) => {
110
141
  try {
111
142
  return (0, units_1.parseDuration)(value) > 0;
@@ -136,7 +167,11 @@ exports.serverConfigSchema = zod_1.z
136
167
  // the OAuth issuers whose tokens the server accepts, and the audience those
137
168
  // tokens have to carry
138
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'),
139
- // who may use which service account; default deny, so no entry means no account
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
140
175
  entitlements: zod_1.z.array(exports.entitlementSchema).default([]),
141
176
  approvals: zod_1.z
142
177
  .object({
@@ -144,21 +179,24 @@ exports.serverConfigSchema = zod_1.z
144
179
  timeout: duration.default('1h'),
145
180
  // runs waiting at the same time, more are refused
146
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'),
147
185
  })
148
186
  .strict()
149
187
  .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({}),
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']),
162
200
  // where the server keeps what must survive a restart: the published build files
163
201
  storage: zod_1.z
164
202
  .object({ directory: zod_1.z.string().min(1).optional() })
@@ -171,6 +209,39 @@ exports.serverConfigSchema = zod_1.z
171
209
  maxConcurrent: zod_1.z.number().int().min(1).default(4),
172
210
  // finished runs that are kept for their state and logs
173
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({}),
174
245
  })
175
246
  .strict()
176
247
  .default({}),
@@ -196,6 +267,70 @@ exports.serverConfigSchema = zod_1.z
196
267
  });
197
268
  }
198
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
+ });
199
334
  if (!config.tls && !config.behindTlsProxy && !LOOPBACK_HOSTS.includes(config.listen.host)) {
200
335
  ctx.addIssue({
201
336
  code: zod_1.z.ZodIssueCode.custom,
@@ -204,6 +339,11 @@ exports.serverConfigSchema = zod_1.z
204
339
  });
205
340
  }
206
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;
207
347
  async function readServerConfig(environment, path) {
208
348
  if (!(await environment.file.exists(path))) {
209
349
  throw new Error(`unable to read ${path}: no such file`);
@@ -224,6 +364,27 @@ async function readServerConfig(environment, path) {
224
364
  return result.data;
225
365
  }
226
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;
227
388
  function detectPlatform() {
228
389
  const os = { darwin: 'macos', win32: 'windows' }[process.platform] ?? 'linux';
229
390
  const arch = process.arch === 'arm64' ? 'arm64' : 'amd64';