hammerkit 1.27.0-next.13 → 1.27.0-next.15

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,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.awaitRunObjectsGone = exports.removeRunObjects = exports.CallerNamespaces = exports.getVolumeScope = exports.KubernetesExecutor = exports.getRunRoleBinding = exports.getNamespacePolicies = exports.getClusterAccess = exports.isCallerNamespace = exports.getCallerNamespace = void 0;
3
+ exports.awaitRunObjectsGone = exports.removeRunObjects = exports.CallerNamespaces = exports.getVolumeScope = exports.KubernetesExecutor = exports.getRunRoleBinding = exports.removeServiceRunNamespace = exports.getServerIngressPolicy = exports.getNamespacePolicies = exports.getClusterAccess = exports.isCallerNamespace = exports.SERVICE_RUN_LABEL = exports.getServiceRunNamespace = exports.getCallerNamespace = void 0;
4
4
  const crypto_1 = require("crypto");
5
5
  const fs_1 = require("fs");
6
6
  const os_1 = require("os");
@@ -34,6 +34,19 @@ function getCallerNamespace(prefix, owner) {
34
34
  return `${prefix}${(0, crypto_1.createHash)('sha256').update(`${owner.iss}\0${owner.sub}`).digest('hex').slice(0, 16)}`;
35
35
  }
36
36
  exports.getCallerNamespace = getCallerNamespace;
37
+ /**
38
+ * The namespace of a service run: one of its own, as the services stay up for hours while
39
+ * the caller's other runs go on in theirs (one at a time, each removing what the one
40
+ * before left).
41
+ */
42
+ function getServiceRunNamespace(prefix, run, wakes = 0) {
43
+ // a woken run starts in a namespace of its own, the one it slept in may still be removed
44
+ const name = wakes > 0 ? `up\0${run}\0${wakes}` : `up\0${run}`;
45
+ return `${prefix}${(0, crypto_1.createHash)('sha256').update(name).digest('hex').slice(0, 16)}`;
46
+ }
47
+ exports.getServiceRunNamespace = getServiceRunNamespace;
48
+ // the label of the namespace of a service run: the run label value of its run
49
+ exports.SERVICE_RUN_LABEL = 'hammerkit.dev/service-run';
37
50
  // Whether a namespace is one getCallerNamespace makes with this prefix: a server named `hk`
38
51
  // passes over the namespaces of a server named `hk-test` on the same cluster.
39
52
  function isCallerNamespace(prefix, name) {
@@ -145,6 +158,64 @@ function getNamespacePolicies(namespace, deniedEgress, allowedEgress = []) {
145
158
  ];
146
159
  }
147
160
  exports.getNamespacePolicies = getNamespacePolicies;
161
+ /**
162
+ * What lets the server into the namespace of a service run, to the ports of its endpoints:
163
+ * policies add up, so this one opens the pods to the server's namespace besides what the
164
+ * others let in, limited ones too.
165
+ */
166
+ // The server reaches the ports of the endpoints, from its own namespace, and no other port:
167
+ // what else the run starts (a database) stays for the pods of the run.
168
+ function getServerIngressPolicy(namespace, server, ports) {
169
+ return {
170
+ apiVersion: 'networking.k8s.io/v1',
171
+ kind: 'NetworkPolicy',
172
+ metadata: {
173
+ name: 'hammerkit-server-ingress',
174
+ namespace,
175
+ annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() },
176
+ labels: { [MANAGED_LABEL]: 'true' },
177
+ },
178
+ spec: {
179
+ podSelector: {},
180
+ policyTypes: ['Ingress'],
181
+ ingress: ports.length > 0
182
+ ? [
183
+ {
184
+ _from: [{ namespaceSelector: { matchLabels: { 'kubernetes.io/metadata.name': server } } }],
185
+ ports: [...new Set(ports)].map((port) => ({ protocol: 'TCP', port })),
186
+ },
187
+ ]
188
+ : [],
189
+ },
190
+ };
191
+ }
192
+ exports.getServerIngressPolicy = getServerIngressPolicy;
193
+ // The namespace of a service run goes with all it holds; waits up to `timeout` for it.
194
+ async function removeServiceRunNamespace(instance, namespace, timeout) {
195
+ try {
196
+ await instance.coreApi.deleteNamespace({ name: namespace, propagationPolicy: 'Background' });
197
+ }
198
+ catch (e) {
199
+ if ((0, apply_1.statusCodeOf)(e) === 404) {
200
+ return;
201
+ }
202
+ throw e;
203
+ }
204
+ const deadline = Date.now() + timeout;
205
+ while (Date.now() < deadline) {
206
+ try {
207
+ await instance.coreApi.readNamespace({ name: namespace });
208
+ }
209
+ catch (e) {
210
+ if ((0, apply_1.statusCodeOf)(e) === 404) {
211
+ return;
212
+ }
213
+ throw e;
214
+ }
215
+ await (0, sleep_1.sleep)(1000);
216
+ }
217
+ }
218
+ exports.removeServiceRunNamespace = removeServiceRunNamespace;
148
219
  /**
149
220
  * The server's rights in a caller's namespace: the operator's ClusterRole, bound there
150
221
  * only, so the server needs no rights on Secrets or pods anywhere else.
@@ -191,6 +262,9 @@ class KubernetesExecutor {
191
262
  // the namespaces where no run of this process left anything: what a run makes goes at its
192
263
  // end, unless its release failed
193
264
  this.tidy = new Set();
265
+ // the namespaces of the service runs that execute, and whether those of earlier ones went
266
+ this.serviceRuns = new Set();
267
+ this.serviceRunsSwept = false;
194
268
  this.access = getClusterAccess(config.backend, processEnvs);
195
269
  this.instance = (0, kubernetes_instance_1.createKubernetesInstances)({
196
270
  type: 'kubernetes',
@@ -212,6 +286,13 @@ class KubernetesExecutor {
212
286
  this.host = new executor_1.HostExecutor({ ...options, placement: this.placement() });
213
287
  }
214
288
  async execute(run, context) {
289
+ if (run.up) {
290
+ if (run.source.type === 'sandbox') {
291
+ throw new Error('on a cluster a service run starts from a branch, push the sandbox and start it from there');
292
+ }
293
+ // in a namespace of its own, apart from the caller's runs and their claims
294
+ return this.host.execute(run, context);
295
+ }
215
296
  const namespace = getCallerNamespace(this.config.backend.namespacePrefix, run.owner);
216
297
  const label = (0, job_placement_1.getRunLabelValue)(run.id);
217
298
  let runs = this.active.get(namespace);
@@ -230,11 +311,15 @@ class KubernetesExecutor {
230
311
  }
231
312
  }
232
313
  }
314
+ namespaceOf(run) {
315
+ const prefix = this.config.backend.namespacePrefix;
316
+ return run.up ? getServiceRunNamespace(prefix, run.id, run.wakes) : getCallerNamespace(prefix, run.owner);
317
+ }
233
318
  // The runs of a scope share its kept claims, which one of them may change at a time:
234
319
  // what a task finds in one is what the last run left there. Without kept claims, runs
235
320
  // share no object and nothing is held.
236
321
  exclusive(run) {
237
- if (this.keep === 0) {
322
+ if (this.keep === 0 || run.up) {
238
323
  return undefined;
239
324
  }
240
325
  return `${getCallerNamespace(this.config.backend.namespacePrefix, run.owner)}/${getVolumeScope(run)}`;
@@ -263,10 +348,25 @@ class KubernetesExecutor {
263
348
  return execution;
264
349
  }
265
350
  placement() {
266
- const backend = this.config.backend;
267
351
  return {
268
352
  prepare: async (run, context, work) => {
269
- const namespace = getCallerNamespace(backend.namespacePrefix, run.owner);
353
+ const namespace = this.namespaceOf(run);
354
+ if (run.up) {
355
+ if (context.signal.aborted) {
356
+ throw new Error('the run was cancelled');
357
+ }
358
+ this.serviceRuns.add(namespace);
359
+ this.sweepServiceRuns();
360
+ // reached by the server at the Services of its endpoints
361
+ await this.namespaces.prepare(namespace, {
362
+ serviceRun: (0, job_placement_1.getRunLabelValue)(run.id),
363
+ server: this.namespace,
364
+ ports: (run.up.endpoints ?? []).map((endpoint) => endpoint.port),
365
+ quota: this.config.runs.up.quota,
366
+ });
367
+ context.emit({ type: 'log', task: 'hammerkit', stream: 'stdout', text: `runs in the namespace ${namespace}` });
368
+ return { kubernetes: this.environmentOf(run, namespace), env: this.access.env };
369
+ }
270
370
  const label = (0, job_placement_1.getRunLabelValue)(run.id);
271
371
  await this.enqueue(namespace, async () => {
272
372
  if (context.signal.aborted) {
@@ -294,32 +394,41 @@ class KubernetesExecutor {
294
394
  });
295
395
  this.sweepLeftClaims();
296
396
  context.emit({ type: 'log', task: 'hammerkit', stream: 'stdout', text: `runs in the namespace ${namespace}` });
297
- const kubernetes = {
298
- type: 'kubernetes',
299
- namespace,
300
- kubeConfig: this.access.kubeConfig,
301
- context: this.access.context,
302
- httpRoutes: [],
303
- storageClass: backend.storageClass,
304
- volumeSize: backend.volumeSize,
305
- maxVolumeSize: (0, server_config_1.getMaxVolumeSize)(backend),
306
- networkPolicyEnforced: backend.networkPolicy === 'enforced',
307
- server: {
308
- run: run.id,
309
- namespace: this.namespace,
310
- pools: backend.pools,
311
- providers: backend.providers,
312
- accounts: Object.fromEntries(Object.entries(this.config.accounts).flatMap(([name, account]) => account.kubernetes ? [[name, account.kubernetes]] : [])),
313
- ...(work?.claim
314
- ? { workspace: getWorkspace(work.root, work.claim, namespace, backend.sandboxes.runner) }
315
- : {}),
316
- ...(this.keep > 0 ? { volumeScope: getVolumeScope(run) } : {}),
317
- },
318
- };
319
- return { kubernetes, env: this.access.env };
397
+ return { kubernetes: this.environmentOf(run, namespace, work), env: this.access.env };
320
398
  },
321
399
  release: async (run, context) => {
322
- const namespace = getCallerNamespace(backend.namespacePrefix, run.owner);
400
+ const namespace = this.namespaceOf(run);
401
+ if (run.up) {
402
+ try {
403
+ await removeServiceRunNamespace(this.instance, namespace, RELEASE_TIMEOUT);
404
+ }
405
+ catch (e) {
406
+ // at least nothing of it runs on: its objects are the server's to remove (runRole)
407
+ const forbidden = (0, apply_1.statusCodeOf)(e) === 403;
408
+ const text = forbidden
409
+ ? `the server may not delete the namespace ${namespace}, its ClusterRole needs delete on namespaces (see the backend docs); removing what runs in it`
410
+ : `the namespace ${namespace} could not be removed: ${(0, log_1.getErrorMessage)(e)}`;
411
+ this.log?.(text);
412
+ context.emit({ type: 'log', task: 'hammerkit', stream: 'stderr', text });
413
+ try {
414
+ await removeRunObjects(this.instance, namespace, job_placement_1.RUN_LABEL, RELEASE_TIMEOUT);
415
+ }
416
+ catch (inner) {
417
+ context.emit({
418
+ type: 'log',
419
+ task: 'hammerkit',
420
+ stream: 'stderr',
421
+ text: `what the run made in ${namespace} could not be removed: ${(0, log_1.getErrorMessage)(inner)}`,
422
+ });
423
+ }
424
+ // the next look removes the namespace
425
+ this.serviceRunsSwept = false;
426
+ }
427
+ finally {
428
+ this.serviceRuns.delete(namespace);
429
+ }
430
+ return;
431
+ }
323
432
  try {
324
433
  const selector = `${job_placement_1.RUN_LABEL}=${(0, job_placement_1.getRunLabelValue)(run.id)}`;
325
434
  await removeRunObjects(this.instance, namespace, selector, RELEASE_TIMEOUT);
@@ -352,6 +461,57 @@ class KubernetesExecutor {
352
461
  },
353
462
  };
354
463
  }
464
+ // The cluster as the process of a run sees it: the namespace its tasks and services go in.
465
+ environmentOf(run, namespace, work) {
466
+ const backend = this.config.backend;
467
+ return {
468
+ type: 'kubernetes',
469
+ namespace,
470
+ kubeConfig: this.access.kubeConfig,
471
+ context: this.access.context,
472
+ httpRoutes: [],
473
+ storageClass: backend.storageClass,
474
+ volumeSize: backend.volumeSize,
475
+ maxVolumeSize: (0, server_config_1.getMaxVolumeSize)(backend),
476
+ networkPolicyEnforced: backend.networkPolicy === 'enforced',
477
+ server: {
478
+ run: run.id,
479
+ namespace: this.namespace,
480
+ pools: backend.pools,
481
+ providers: backend.providers,
482
+ accounts: Object.fromEntries(Object.entries(this.config.accounts).flatMap(([name, account]) => account.kubernetes ? [[name, account.kubernetes]] : [])),
483
+ ...(work?.claim ? { workspace: getWorkspace(work.root, work.claim, namespace, backend.sandboxes.runner) } : {}),
484
+ // the claims of a service run go with its namespace
485
+ ...(this.keep > 0 && !run.up ? { volumeScope: getVolumeScope(run) } : {}),
486
+ },
487
+ };
488
+ }
489
+ // Once, on the first service run: the namespaces of service runs the server left when it
490
+ // stopped, whose services went with it
491
+ // Once when the server starts, and on the next service run after a namespace could not be
492
+ // removed: the namespaces of service runs that none of the server's runs is in, whose
493
+ // services went with the process of a server that stopped
494
+ sweepServiceRuns() {
495
+ if (this.serviceRunsSwept) {
496
+ return;
497
+ }
498
+ this.serviceRunsSwept = true;
499
+ Promise.resolve()
500
+ .then(() => this.instance.coreApi.listNamespace({ labelSelector: `${MANAGED_LABEL}=true,${exports.SERVICE_RUN_LABEL}` }))
501
+ .then(async (namespaces) => {
502
+ for (const namespace of namespaces.items) {
503
+ const name = namespace.metadata?.name;
504
+ if (name &&
505
+ isCallerNamespace(this.config.backend.namespacePrefix, name) &&
506
+ !this.serviceRuns.has(name) &&
507
+ !namespace.metadata?.deletionTimestamp) {
508
+ this.log?.(`removing the namespace ${name} of a service run from before the server started`);
509
+ await removeServiceRunNamespace(this.instance, name, 0);
510
+ }
511
+ }
512
+ })
513
+ .catch((e) => this.log?.(`the namespaces of service runs could not be removed: ${(0, log_1.getErrorMessage)(e)}`));
514
+ }
355
515
  // The kept claims of a namespace that expired go at the soonest expiry the server knows
356
516
  // of, but those of a scope a run of the caller executes in
357
517
  sweepLater(namespace, delay) {
@@ -390,7 +550,7 @@ class KubernetesExecutor {
390
550
  }
391
551
  this.swept = true;
392
552
  Promise.resolve()
393
- .then(() => this.instance.coreApi.listNamespace({ labelSelector: `${MANAGED_LABEL}=true` }))
553
+ .then(() => this.instance.coreApi.listNamespace({ labelSelector: `${MANAGED_LABEL}=true,!${exports.SERVICE_RUN_LABEL}` }))
394
554
  .then((namespaces) => {
395
555
  for (const namespace of namespaces.items) {
396
556
  const name = namespace.metadata?.name;
@@ -436,7 +596,10 @@ class CallerNamespaces {
436
596
  // a namespace it did not set up in the last `NAMESPACE_SETUP_REUSE_MS` (it reads the
437
597
  // config once, at its start), so the namespace follows the config; a namespace made anew
438
598
  // since (deleted and made again) is set up again at once.
439
- async prepare(namespace) {
599
+ async prepare(namespace,
600
+ // a service run's own namespace: its label, the namespace of the server that reaches its
601
+ // endpoints, and its quota
602
+ serviceRun) {
440
603
  const backend = this.backend;
441
604
  let existing;
442
605
  try {
@@ -469,6 +632,7 @@ class CallerNamespaces {
469
632
  labels: {
470
633
  [MANAGED_LABEL]: 'true',
471
634
  'hammerkit.dev/caller': namespace,
635
+ ...(serviceRun ? { [exports.SERVICE_RUN_LABEL]: serviceRun.serviceRun } : {}),
472
636
  // no privileged pods, host paths or host network, whatever a build asks
473
637
  'pod-security.kubernetes.io/enforce': 'baseline',
474
638
  },
@@ -486,7 +650,10 @@ class CallerNamespaces {
486
650
  const [first, ...policies] = getNamespacePolicies(namespace, backend.deniedEgress, backend.allowedEgress);
487
651
  // the API server may refuse what the binding allows for a moment after it was made
488
652
  await untilAllowed(() => (0, apply_1.replace)(this.instance, first));
489
- for (const policy of policies) {
653
+ for (const policy of [
654
+ ...policies,
655
+ ...(serviceRun ? [getServerIngressPolicy(namespace, serviceRun.server, serviceRun.ports)] : []),
656
+ ]) {
490
657
  await (0, apply_1.replace)(this.instance, policy);
491
658
  }
492
659
  const metadata = {
@@ -499,7 +666,7 @@ class CallerNamespaces {
499
666
  apiVersion: 'v1',
500
667
  kind: 'ResourceQuota',
501
668
  metadata,
502
- spec: { hard: backend.quota },
669
+ spec: { hard: serviceRun?.quota ?? backend.quota },
503
670
  };
504
671
  const defaults = backend.defaultResources;
505
672
  const limits = {
@@ -517,7 +684,7 @@ class CallerNamespaces {
517
684
  },
518
685
  };
519
686
  for (const [object, wanted] of [
520
- [quota, !!backend.quota],
687
+ [quota, !!(serviceRun?.quota ?? backend.quota)],
521
688
  [limits, !!defaults],
522
689
  ]) {
523
690
  if (wanted) {
@@ -284,6 +284,7 @@ function toPlan(root, item) {
284
284
  const isTask = data.type === 'local-task' || data.type === 'container-task';
285
285
  return {
286
286
  kind: isTask ? 'task' : 'service',
287
+ ...(data.description ? { description: data.description } : {}),
287
288
  runtime: data.type === 'local-task' ? 'local' : 'docker',
288
289
  ...((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data) ? { image: data.image } : {}),
289
290
  cmds: 'cmds' in data ? data.cmds.map((cmd) => cmd.cmd) : 'cmd' in data && data.cmd ? [data.cmd.cmd] : [],
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.decideRun = exports.sandboxRepos = exports.decideSandbox = exports.sandboxPushBranches = exports.sandboxBranchType = exports.getGrant = exports.usedAccounts = exports.mayYank = exports.canUse = exports.maySeeDefinitions = exports.mayPublish = exports.mayPropose = exports.mayRun = exports.matches = void 0;
3
+ exports.decideRun = exports.sandboxRepos = exports.decideSandbox = exports.sandboxPushBranches = exports.sandboxBranchType = exports.endpointLevel = exports.getGrant = exports.usedAccounts = exports.mayYank = exports.canUse = exports.maySeeDefinitions = exports.mayPublish = exports.mayPropose = exports.mayRun = exports.matches = void 0;
4
4
  const repo_1 = require("./repo");
5
5
  const BUILDS_ORDER = ['run', 'propose', 'publish'];
6
6
  function matches(entitlement, principal) {
@@ -55,6 +55,7 @@ function getGrant(entitlements, principal) {
55
55
  const accounts = {};
56
56
  const repos = [];
57
57
  const sandboxes = [];
58
+ const endpoints = [];
58
59
  let builds = null;
59
60
  entitlements.forEach((entitlement, index) => {
60
61
  if (!matches(entitlement, principal)) {
@@ -85,10 +86,31 @@ function getGrant(entitlements, principal) {
85
86
  known.branches[pattern] = higherType(known.branches[pattern], type);
86
87
  }
87
88
  }
89
+ for (const endpoint of entitlement.endpoints ?? []) {
90
+ const known = endpoints.find((entry) => entry.repo === endpoint.repo);
91
+ if (!known) {
92
+ endpoints.push({ ...endpoint });
93
+ }
94
+ else if (endpoint.level === 'publish') {
95
+ known.level = 'publish';
96
+ }
97
+ }
88
98
  });
89
- return { accounts, repos, builds, sandboxes, matched };
99
+ return { accounts, repos, builds, sandboxes, endpoints, matched };
90
100
  }
91
101
  exports.getGrant = getGrant;
102
+ // What the caller may do with the endpoints of service runs of a repository: the higher
103
+ // level of the entries whose `repo` covers it, null for nothing.
104
+ function endpointLevel(grant, repo) {
105
+ let found = null;
106
+ for (const endpoint of grant.endpoints) {
107
+ if ((0, repo_1.matchesRepo)(endpoint.repo, repo) && found !== 'publish') {
108
+ found = endpoint.level;
109
+ }
110
+ }
111
+ return found;
112
+ }
113
+ exports.endpointLevel = endpointLevel;
92
114
  const BRANCH_TYPES = ['pull', 'push'];
93
115
  function higherType(a, b) {
94
116
  return a !== undefined && BRANCH_TYPES.indexOf(a) > BRANCH_TYPES.indexOf(b) ? a : b;
@@ -105,6 +105,11 @@ class RepoAccess {
105
105
  env.GIT_CONFIG_GLOBAL = '/dev/null';
106
106
  return { env, values: credentials.values };
107
107
  }
108
+ // A secret only the server reads, as an account no caller may use: the client secret of
109
+ // the sign-in at endpoints, read like the credential of a source.
110
+ serverSecret(secret) {
111
+ return this.token(secret);
112
+ }
108
113
  async token(secret) {
109
114
  const key = `${secret.account}\0${secret.from}`;
110
115
  const known = this.tokens.get(key);
@@ -1,15 +1,19 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.refuseArguments = exports.planRun = exports.getRunScopes = void 0;
3
+ exports.describeArguments = exports.refuseArguments = exports.planRun = exports.getRunScopes = void 0;
4
4
  const parse_label_arguments_1 = require("../parser/parse-label-arguments");
5
5
  const plan_1 = require("./plan");
6
6
  function labels(values) {
7
7
  return values && values.length > 0 ? (0, parse_label_arguments_1.parseLabelArguments)(values) : undefined;
8
8
  }
9
- // The tasks in the order they were named, one scope each; by labels when none is.
9
+ // The tasks in the order they were named, one scope each; by labels when none is. A
10
+ // service run is one scope of its services.
10
11
  // The arguments, if any, go to the one task named (the request allows no more). That
11
12
  // task runs uncached unless the request names a `cache` method, as `run --cache` does.
12
- function getRunScopes(tasks, options) {
13
+ function getRunScopes(tasks, options, services = []) {
14
+ if (services.length > 0) {
15
+ return [{ name: services.join(', '), scope: { serviceNames: services, environmentName: options.env ?? null } }];
16
+ }
13
17
  const args = options.args && options.args.length > 0
14
18
  ? { values: options.args, services: false, cache: options.cache !== undefined }
15
19
  : undefined;
@@ -37,7 +41,7 @@ exports.getRunScopes = getRunScopes;
37
41
  */
38
42
  async function planRun(bundle, run, environment, policy) {
39
43
  const plan = {};
40
- for (const { scope } of getRunScopes(run.tasks, run.options)) {
44
+ for (const { scope } of getRunScopes(run.tasks, run.options, run.up?.services ?? run.services)) {
41
45
  Object.assign(plan, (await (0, plan_1.planBundle)(bundle, environment, { scope, policy })).plan);
42
46
  }
43
47
  return {
@@ -87,4 +91,34 @@ function refuseArguments(run, plan, policy = {}) {
87
91
  return null;
88
92
  }
89
93
  exports.refuseArguments = refuseArguments;
94
+ /**
95
+ * For each task of a version, whether a run of it alone may pass arguments: null when it may,
96
+ * else why not, as {@link refuseArguments} answers a run. The plan is the one of the whole
97
+ * version: a task's run is the task with its dependencies and services.
98
+ */
99
+ function describeArguments(plan, policy = {}) {
100
+ const described = {};
101
+ for (const [name, entry] of Object.entries(plan)) {
102
+ if (entry.kind !== 'task') {
103
+ continue;
104
+ }
105
+ const run = {};
106
+ const visit = (item) => {
107
+ if (run[item] || !plan[item]) {
108
+ return;
109
+ }
110
+ run[item] = plan[item];
111
+ for (const next of [...plan[item].deps, ...plan[item].needs]) {
112
+ visit(next);
113
+ }
114
+ };
115
+ visit(name);
116
+ described[name] =
117
+ entry.command === null
118
+ ? `${name} has no command to take arguments`
119
+ : refuseArguments({ tasks: [name], options: { args: ['-'] } }, run, policy);
120
+ }
121
+ return described;
122
+ }
123
+ exports.describeArguments = describeArguments;
90
124
  //# sourceMappingURL=run-plan.js.map
@@ -1,12 +1,17 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.runRequestSchema = exports.runOptionsSchema = exports.MAX_ARGS_BYTES = exports.MAX_ARGS = void 0;
3
+ exports.runRequestSchema = exports.runOptionsSchema = exports.MAX_ARGS_BYTES = exports.MAX_ARGS = exports.MAX_ENDPOINTS = exports.SERVICE_RUN_NAME_PATTERN = void 0;
4
4
  const zod_1 = require("zod");
5
5
  const units_1 = require("../utils/units");
6
6
  const bundle_1 = require("../remote/bundle");
7
7
  const repo_1 = require("./repo");
8
8
  const runs_1 = require("./runs");
9
9
  const name = zod_1.z.string().min(1).max(200);
10
+ // The name of a service run, one DNS label: the hosts of its endpoints carry it, and a run of
11
+ // the same caller with the same name replaces it
12
+ exports.SERVICE_RUN_NAME_PATTERN = /^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/;
13
+ // endpoints one service run may publish
14
+ exports.MAX_ENDPOINTS = 5;
10
15
  // what one run may pass after `--`
11
16
  exports.MAX_ARGS = 100;
12
17
  exports.MAX_ARGS_BYTES = 16 * 1024;
@@ -93,12 +98,49 @@ exports.runRequestSchema = zod_1.z
93
98
  .strict(),
94
99
  ]),
95
100
  options: exports.runOptionsSchema.default({}),
101
+ // a service run: these services of the build file, started as `hammerkit up` starts
102
+ // them and kept up until `ttl` ends or the run is cancelled, instead of tasks
103
+ services: zod_1.z.array(name).max(20).default([]),
104
+ // how long the services stay once they are ready, whether they sleep in between or not;
105
+ // `runs.up.ttl` when left out. At its end they are removed.
106
+ ttl: duration.optional(),
107
+ // how long the services stay up without a request to one of the endpoints, then they
108
+ // sleep until the next request wakes them; `runs.up.sleepTtl` when left out
109
+ sleepTtl: duration.optional(),
110
+ // a second service run of the caller with the same name replaces the first; the branch
111
+ // of the source when left out
112
+ name: zod_1.z
113
+ .string()
114
+ .regex(exports.SERVICE_RUN_NAME_PATTERN, 'expected lower case letters, digits and -, at most 40, as a DNS label')
115
+ .optional(),
116
+ // ports of the run's services published at hosts of their own, behind the server's sign-in
117
+ endpoints: zod_1.z
118
+ .array(zod_1.z.object({ service: name, port: zod_1.z.number().int().min(1).max(65535) }).strict())
119
+ .max(exports.MAX_ENDPOINTS)
120
+ .default([]),
96
121
  })
97
122
  .strict()
98
- .refine((request) => request.tasks.length > 0 || (request.options.filter ?? []).length > 0, {
123
+ .refine((request) => request.services.length > 0 || request.tasks.length > 0 || (request.options.filter ?? []).length > 0, {
99
124
  message: 'name at least one task, or filter by labels',
100
125
  path: ['tasks'],
101
126
  })
127
+ .refine((request) => request.services.length === 0 || request.tasks.length === 0, {
128
+ message: 'a run names tasks or services, not both',
129
+ path: ['services'],
130
+ })
131
+ .refine((request) => request.services.length === 0 ||
132
+ ((request.options.filter ?? []).length === 0 && (request.options.exclude ?? []).length === 0), { message: 'a service run names its services, labels do not select them', path: ['options', 'filter'] })
133
+ .refine((request) => request.services.length === 0 || (request.options.args ?? []).length === 0, {
134
+ message: 'a service run takes no arguments',
135
+ path: ['options', 'args'],
136
+ })
137
+ .refine((request) => request.services.length > 0 ||
138
+ (request.ttl === undefined &&
139
+ request.sleepTtl === undefined &&
140
+ request.name === undefined &&
141
+ request.endpoints.length === 0), { message: 'ttl, sleepTtl, name and endpoints are for a run of services', path: ['services'] })
142
+ .refine((request) => new Set(request.endpoints.map((endpoint) => `${endpoint.service}:${endpoint.port}`)).size ===
143
+ request.endpoints.length, { message: 'an endpoint is named twice', path: ['endpoints'] })
102
144
  .refine((request) => (request.options.args ?? []).length === 0 || request.tasks.length === 1, {
103
145
  message: 'arguments go to one task, name exactly one in tasks',
104
146
  path: ['options', 'args'],