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.
- package/dist/docker/item-network.js +13 -5
- package/dist/executer/docker-service.js +30 -8
- package/dist/harness/connect.js +19 -5
- package/dist/harness/mcp-command.js +22 -6
- package/dist/harness/mcp.js +62 -114
- package/dist/harness/opencode.js +151 -50
- package/dist/harness/pi.js +14 -0
- package/dist/harness/sandbox-client.js +81 -2
- package/dist/harness/service-tools.js +141 -0
- package/dist/harness/task-tools.js +210 -0
- package/dist/kubernetes/ensure-kubernetes-service-exists.js +2 -0
- package/dist/planner/utils/parse-work-port.js +4 -1
- package/dist/remote/remote-work-commands.js +98 -12
- package/dist/schema/work-scope-parser.js +6 -0
- package/dist/server/accounts-api.js +2 -1
- package/dist/server/approvals-api.js +1 -1
- package/dist/server/definitions-api.js +4 -2
- package/dist/server/endpoints.js +612 -0
- package/dist/server/executor.js +14 -1
- package/dist/server/kubernetes-backend.js +200 -33
- package/dist/server/plan.js +1 -0
- package/dist/server/policy.js +24 -2
- package/dist/server/repo-access.js +5 -0
- package/dist/server/run-plan.js +38 -4
- package/dist/server/run-request.js +44 -2
- package/dist/server/run-worker.js +130 -9
- package/dist/server/runs.js +317 -24
- package/dist/server/server-config.js +160 -9
- package/dist/server/server.js +210 -14
- package/dist/server/service-runs.js +82 -0
- package/package.json +1 -1
|
@@ -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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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) {
|
package/dist/server/plan.js
CHANGED
|
@@ -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] : [],
|
package/dist/server/policy.js
CHANGED
|
@@ -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);
|
package/dist/server/run-plan.js
CHANGED
|
@@ -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'],
|