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.detectPlatform = exports.getSecretCatalog = exports.readServerConfig = exports.
|
|
3
|
+
exports.detectPlatform = exports.getSecretCatalog = exports.readServerConfig = exports.getServerAccounts = exports.getSandboxRuntimes = exports.serverConfigSchema = exports.sourceSchema = exports.entitlementSchema = exports.endpointGrantSchema = exports.endpointLevelSchema = exports.sandboxGrantSchema = exports.sandboxBranchTypeSchema = exports.buildsLevelSchema = exports.accountLevelSchema = exports.getMaxVolumeSize = exports.DEFAULT_MAX_VOLUME_SIZE = exports.DEFAULT_DENIED_EGRESS = exports.kubernetesPoolSchema = exports.accountSchema = exports.issuerSchema = exports.serverPlatformSchema = void 0;
|
|
4
4
|
const net_1 = require("net");
|
|
5
5
|
const zod_1 = require("zod");
|
|
6
6
|
const yaml_1 = require("yaml");
|
|
@@ -327,6 +327,11 @@ exports.sandboxGrantSchema = zod_1.z
|
|
|
327
327
|
.refine((branches) => Object.keys(branches).length > 0, 'name the branches a sandbox may start from'),
|
|
328
328
|
})
|
|
329
329
|
.strict();
|
|
330
|
+
// `open`: open the endpoints of the service runs of a repository, whoever started them, and
|
|
331
|
+
// extend those runs; `publish`: also name endpoints in one's own service runs.
|
|
332
|
+
exports.endpointLevelSchema = zod_1.z.enum(['open', 'publish']);
|
|
333
|
+
// One repository (`host/path` pattern) whose endpoints an entitlement opens or publishes.
|
|
334
|
+
exports.endpointGrantSchema = zod_1.z.object({ repo: repoPatternSchema, level: exports.endpointLevelSchema }).strict();
|
|
330
335
|
exports.entitlementSchema = zod_1.z
|
|
331
336
|
.object({
|
|
332
337
|
...whoShape,
|
|
@@ -339,16 +344,19 @@ exports.entitlementSchema = zod_1.z
|
|
|
339
344
|
// the repositories the caller may keep sandboxes of, checkouts on the server that runs
|
|
340
345
|
// work on: a list of its own, the `repos` above are for runs from a ref
|
|
341
346
|
sandboxes: zod_1.z.array(exports.sandboxGrantSchema).optional(),
|
|
347
|
+
// the repositories whose service runs' endpoints the caller may open, or publish
|
|
348
|
+
endpoints: zod_1.z.array(exports.endpointGrantSchema).optional(),
|
|
342
349
|
})
|
|
343
350
|
.strict()
|
|
344
351
|
.superRefine((entitlement, ctx) => {
|
|
345
352
|
checkWho(entitlement, ctx);
|
|
346
353
|
if (Object.keys(entitlement.accounts).length === 0 &&
|
|
347
354
|
entitlement.builds === undefined &&
|
|
348
|
-
!entitlement.sandboxes?.length
|
|
355
|
+
!entitlement.sandboxes?.length &&
|
|
356
|
+
!entitlement.endpoints?.length) {
|
|
349
357
|
ctx.addIssue({
|
|
350
358
|
code: zod_1.z.ZodIssueCode.custom,
|
|
351
|
-
message: 'name accounts to use or request, a builds level or
|
|
359
|
+
message: 'name accounts to use or request, a builds level, sandboxes or endpoints',
|
|
352
360
|
});
|
|
353
361
|
}
|
|
354
362
|
});
|
|
@@ -476,6 +484,31 @@ exports.serverConfigSchema = zod_1.z
|
|
|
476
484
|
// which tasks take arguments: `any` but those that say `args: false`, or
|
|
477
485
|
// `declared`, only those that say `args: true`
|
|
478
486
|
args: zod_1.z.enum(['any', 'declared']).default('any'),
|
|
487
|
+
// runs of services (`services` in a run), kept up until their ttl ends
|
|
488
|
+
up: zod_1.z
|
|
489
|
+
.object({
|
|
490
|
+
// how long the services stay once ready, when the run names no ttl; then they are removed
|
|
491
|
+
ttl: duration.default('8h'),
|
|
492
|
+
// how long the services stay up without a request to an endpoint before they sleep,
|
|
493
|
+
// when the run names none: the next request wakes them. Unset, they never sleep.
|
|
494
|
+
sleepTtl: duration.optional(),
|
|
495
|
+
// the furthest a run, or an extend, sets the end from now
|
|
496
|
+
maxTtl: duration.default('72h'),
|
|
497
|
+
limits: zod_1.z
|
|
498
|
+
.object({
|
|
499
|
+
// service runs one caller has starting or up at a time; one more is 429
|
|
500
|
+
up: zod_1.z.number().int().min(1).default(3),
|
|
501
|
+
})
|
|
502
|
+
.strict()
|
|
503
|
+
.default({}),
|
|
504
|
+
// service runs starting or up on the whole server; one more is 503
|
|
505
|
+
total: zod_1.z.number().int().min(1).default(20),
|
|
506
|
+
// on a Kubernetes backend, the ResourceQuota of the namespace of each service
|
|
507
|
+
// run; backend.quota when left out
|
|
508
|
+
quota: zod_1.z.record(zod_1.z.union([zod_1.z.string().min(1), zod_1.z.number()]).transform(String)).optional(),
|
|
509
|
+
})
|
|
510
|
+
.strict()
|
|
511
|
+
.default({}),
|
|
479
512
|
})
|
|
480
513
|
.strict()
|
|
481
514
|
.default({}),
|
|
@@ -513,6 +546,45 @@ exports.serverConfigSchema = zod_1.z
|
|
|
513
546
|
})
|
|
514
547
|
.strict()
|
|
515
548
|
.default({}),
|
|
549
|
+
// ports of service runs published at hosts of their own below `domain`, behind the
|
|
550
|
+
// server's sign-in. Absent, a run naming endpoints is refused.
|
|
551
|
+
endpoints: zod_1.z
|
|
552
|
+
.object({
|
|
553
|
+
// `run.example.com`: an endpoint is https://<label>.run.example.com, all of them
|
|
554
|
+
// pointing at this server with one wildcard DNS record
|
|
555
|
+
domain: zod_1.z
|
|
556
|
+
.string()
|
|
557
|
+
.regex(/^(?=.{1,200}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/, 'expected a domain name in lower case, such as run.example.com'),
|
|
558
|
+
// a wildcard certificate for *.<domain>, served for the hosts of endpoints; left out
|
|
559
|
+
// behind a TLS proxy, which holds it
|
|
560
|
+
tls: zod_1.z
|
|
561
|
+
.object({ cert: zod_1.z.string().min(1), key: zod_1.z.string().min(1) })
|
|
562
|
+
.strict()
|
|
563
|
+
.optional(),
|
|
564
|
+
// how a person signs in with a browser: the authorization code flow of an issuer of
|
|
565
|
+
// `issuers`, with a client of its own; without it only tokens open endpoints
|
|
566
|
+
login: zod_1.z
|
|
567
|
+
.object({
|
|
568
|
+
issuer: zod_1.z.string().min(1),
|
|
569
|
+
clientId: zod_1.z.string().min(1),
|
|
570
|
+
// for providers whose web clients need one (Google): read as an account no
|
|
571
|
+
// entitlement may name, as the secret of a source is
|
|
572
|
+
clientSecret: zod_1.z
|
|
573
|
+
.object({
|
|
574
|
+
from: zod_1.z.string().regex(/^[A-Za-z][A-Za-z0-9_-]*:.+$/, 'expected provider:reference'),
|
|
575
|
+
account: accountNameSchema,
|
|
576
|
+
})
|
|
577
|
+
.strict()
|
|
578
|
+
.optional(),
|
|
579
|
+
scope: zod_1.z.string().min(1).default('openid'),
|
|
580
|
+
})
|
|
581
|
+
.strict()
|
|
582
|
+
.optional(),
|
|
583
|
+
// how long a sign-in at one endpoint lasts, at most until its run ends
|
|
584
|
+
session: duration.default('8h'),
|
|
585
|
+
})
|
|
586
|
+
.strict()
|
|
587
|
+
.optional(),
|
|
516
588
|
backend: zod_1.z.discriminatedUnion('type', [hostBackendSchema, kubernetesBackendSchema]).default({ type: 'host' }),
|
|
517
589
|
})
|
|
518
590
|
.strict()
|
|
@@ -580,7 +652,7 @@ exports.serverConfigSchema = zod_1.z
|
|
|
580
652
|
});
|
|
581
653
|
}
|
|
582
654
|
});
|
|
583
|
-
const readers =
|
|
655
|
+
const readers = getServerAccounts(config);
|
|
584
656
|
config.entitlements.forEach((entitlement, index) => {
|
|
585
657
|
for (const account of Object.keys(entitlement.accounts)) {
|
|
586
658
|
if (readers.includes(account)) {
|
|
@@ -663,7 +735,7 @@ exports.serverConfigSchema = zod_1.z
|
|
|
663
735
|
});
|
|
664
736
|
// the secrets of tasks are read in their Job, with the image of the provider; the
|
|
665
737
|
// ones of the sources are read by the server, which checks the code out itself
|
|
666
|
-
const readers =
|
|
738
|
+
const readers = getServerAccounts(config);
|
|
667
739
|
for (const [name, account] of Object.entries(config.accounts)) {
|
|
668
740
|
if (readers.includes(name)) {
|
|
669
741
|
continue;
|
|
@@ -749,6 +821,78 @@ exports.serverConfigSchema = zod_1.z
|
|
|
749
821
|
message: 'the network of a local task can not be limited, allow only the docker runtime with runs.network',
|
|
750
822
|
});
|
|
751
823
|
}
|
|
824
|
+
if ((0, units_1.parseDuration)(config.runs.up.ttl) > (0, units_1.parseDuration)(config.runs.up.maxTtl)) {
|
|
825
|
+
ctx.addIssue({
|
|
826
|
+
code: zod_1.z.ZodIssueCode.custom,
|
|
827
|
+
path: ['runs', 'up', 'ttl'],
|
|
828
|
+
message: `a ttl of ${config.runs.up.ttl} is more than maxTtl of ${config.runs.up.maxTtl}`,
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
const endpoints = config.endpoints;
|
|
832
|
+
if (endpoints) {
|
|
833
|
+
if (endpoints.tls && config.behindTlsProxy) {
|
|
834
|
+
ctx.addIssue({
|
|
835
|
+
code: zod_1.z.ZodIssueCode.custom,
|
|
836
|
+
path: ['endpoints', 'tls'],
|
|
837
|
+
message: 'behind a TLS proxy the proxy holds the certificate of the endpoints, leave out endpoints.tls',
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
if (endpoints.tls && !config.tls) {
|
|
841
|
+
ctx.addIssue({
|
|
842
|
+
code: zod_1.z.ZodIssueCode.custom,
|
|
843
|
+
path: ['endpoints', 'tls'],
|
|
844
|
+
message: 'the certificate of the endpoints is served next to the one of the server, set tls as well',
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
if (!endpoints.tls && !config.behindTlsProxy && (config.tls || !LOOPBACK_HOSTS.includes(config.listen.host))) {
|
|
848
|
+
ctx.addIssue({
|
|
849
|
+
code: zod_1.z.ZodIssueCode.custom,
|
|
850
|
+
path: ['endpoints', 'tls'],
|
|
851
|
+
message: `name the certificate of *.${endpoints.domain} in endpoints.tls, or set behindTlsProxy: true when a proxy in front holds it`,
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
const login = endpoints.login;
|
|
855
|
+
if (login && !config.issuers.some((issuer) => issuer.issuer === login.issuer)) {
|
|
856
|
+
ctx.addIssue({
|
|
857
|
+
code: zod_1.z.ZodIssueCode.custom,
|
|
858
|
+
path: ['endpoints', 'login', 'issuer'],
|
|
859
|
+
message: `issuer ${login.issuer} is not one of the accepted issuers`,
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
if (login?.clientSecret) {
|
|
863
|
+
const { from, account } = login.clientSecret;
|
|
864
|
+
const kind = from.substring(0, from.indexOf(':'));
|
|
865
|
+
const where = ['endpoints', 'login', 'clientSecret'];
|
|
866
|
+
if (!config.accounts[account]) {
|
|
867
|
+
ctx.addIssue({
|
|
868
|
+
code: zod_1.z.ZodIssueCode.custom,
|
|
869
|
+
path: [...where, 'account'],
|
|
870
|
+
message: `unknown account ${account}`,
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
else if (!config.accounts[account].providers[kind]) {
|
|
874
|
+
ctx.addIssue({
|
|
875
|
+
code: zod_1.z.ZodIssueCode.custom,
|
|
876
|
+
path: [...where, 'from'],
|
|
877
|
+
message: `account ${account} has no credentials for the provider ${kind}`,
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
if (!config.secretProviders[kind]) {
|
|
881
|
+
ctx.addIssue({
|
|
882
|
+
code: zod_1.z.ZodIssueCode.custom,
|
|
883
|
+
path: [...where, 'from'],
|
|
884
|
+
message: `secret provider ${kind} is not declared in secretProviders`,
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (backend.type === 'kubernetes' && backend.kubeconfig) {
|
|
889
|
+
ctx.addIssue({
|
|
890
|
+
code: zod_1.z.ZodIssueCode.custom,
|
|
891
|
+
path: ['endpoints'],
|
|
892
|
+
message: 'endpoints reach the services of runs at their Service in the cluster, run the server in the cluster rather than with a kubeconfig',
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
}
|
|
752
896
|
if (!config.tls && !config.behindTlsProxy && !LOOPBACK_HOSTS.includes(config.listen.host)) {
|
|
753
897
|
ctx.addIssue({
|
|
754
898
|
code: zod_1.z.ZodIssueCode.custom,
|
|
@@ -764,11 +908,18 @@ function getSandboxRuntimes(config) {
|
|
|
764
908
|
return config.sandboxes.runtimes ?? config.runtimes.filter((runtime) => runtime === 'docker');
|
|
765
909
|
}
|
|
766
910
|
exports.getSandboxRuntimes = getSandboxRuntimes;
|
|
767
|
-
// The accounts
|
|
768
|
-
|
|
769
|
-
|
|
911
|
+
// The accounts only the server reads secrets as: those that read the repositories of
|
|
912
|
+
// `sources`, and the one of the client secret of `endpoints.login`. No caller uses them and
|
|
913
|
+
// no build file names them.
|
|
914
|
+
function getServerAccounts(config) {
|
|
915
|
+
return [
|
|
916
|
+
...new Set([
|
|
917
|
+
...config.sources.flatMap((source) => (source.secret ? [source.secret.account] : [])),
|
|
918
|
+
...(config.endpoints?.login?.clientSecret ? [config.endpoints.login.clientSecret.account] : []),
|
|
919
|
+
]),
|
|
920
|
+
];
|
|
770
921
|
}
|
|
771
|
-
exports.
|
|
922
|
+
exports.getServerAccounts = getServerAccounts;
|
|
772
923
|
async function readServerConfig(environment, path) {
|
|
773
924
|
if (!(await environment.file.exists(path))) {
|
|
774
925
|
throw new Error(`unable to read ${path}: no such file`);
|
package/dist/server/server.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.startServer = exports.getDiscoveryDocument = exports.SERVER_PROTOCOL = exports.DISCOVERY_PATH = void 0;
|
|
3
|
+
exports.startServer = exports.refuseWake = exports.getDiscoveryDocument = exports.SERVER_PROTOCOL = exports.DISCOVERY_PATH = void 0;
|
|
4
4
|
const http_1 = require("http");
|
|
5
5
|
const https_1 = require("https");
|
|
6
|
+
const tls_1 = require("tls");
|
|
6
7
|
const version_1 = require("../version");
|
|
7
8
|
const server_config_1 = require("./server-config");
|
|
8
9
|
const discovery_1 = require("./discovery");
|
|
@@ -32,6 +33,8 @@ const sandbox_storage_1 = require("./sandbox-storage");
|
|
|
32
33
|
const kubernetes_sandbox_storage_1 = require("./kubernetes-sandbox-storage");
|
|
33
34
|
const sandbox_docker_1 = require("./sandbox-docker");
|
|
34
35
|
const sandboxes_api_1 = require("./sandboxes-api");
|
|
36
|
+
const endpoints_1 = require("./endpoints");
|
|
37
|
+
const service_runs_1 = require("./service-runs");
|
|
35
38
|
const units_1 = require("../utils/units");
|
|
36
39
|
const run_logs_1 = require("./run-logs");
|
|
37
40
|
const get_cache_directory_1 = require("../optimizer/get-cache-directory");
|
|
@@ -96,8 +99,8 @@ function streamEvents(registry, config, principal, id, req, res, url) {
|
|
|
96
99
|
watch.stop();
|
|
97
100
|
});
|
|
98
101
|
}
|
|
99
|
-
async function handleRuns(config, registry, definitions, sources, sandboxes, planning, repos, log, principal, req, res, url) {
|
|
100
|
-
const match = /^\/v1\/runs(?:\/([^/]+)(?:\/(cancel|events|logs))?)?$/.exec(url.pathname);
|
|
102
|
+
async function handleRuns(config, registry, definitions, sources, sandboxes, planning, repos, endpointUrl, log, principal, req, res, url) {
|
|
103
|
+
const match = /^\/v1\/runs(?:\/([^/]+)(?:\/(cancel|events|logs|extend))?)?$/.exec(url.pathname);
|
|
101
104
|
if (!match) {
|
|
102
105
|
return false;
|
|
103
106
|
}
|
|
@@ -115,7 +118,7 @@ async function handleRuns(config, registry, definitions, sources, sandboxes, pla
|
|
|
115
118
|
});
|
|
116
119
|
}
|
|
117
120
|
else if (method === 'POST') {
|
|
118
|
-
await submitRun(config, registry, definitions, sources, sandboxes, planning, repos, log, principal, req, res);
|
|
121
|
+
await submitRun(config, registry, definitions, sources, sandboxes, planning, repos, endpointUrl, log, principal, req, res);
|
|
119
122
|
}
|
|
120
123
|
else {
|
|
121
124
|
(0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET, POST' });
|
|
@@ -134,6 +137,13 @@ async function handleRuns(config, registry, definitions, sources, sandboxes, pla
|
|
|
134
137
|
log(`cancel iss=${principal.iss} sub=${principal.sub} run=${id} found=${Boolean(run)}`);
|
|
135
138
|
run ? (0, http_2.send)(res, 200, toRunResponse(run)) : (0, http_2.send)(res, 404, { error: 'not found' });
|
|
136
139
|
}
|
|
140
|
+
else if (action === 'extend') {
|
|
141
|
+
if (method !== 'POST') {
|
|
142
|
+
(0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'POST' });
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
await extendRun(config, registry, log, principal, id, req, res);
|
|
146
|
+
}
|
|
137
147
|
else if (method !== 'GET') {
|
|
138
148
|
(0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
|
|
139
149
|
}
|
|
@@ -181,12 +191,75 @@ function accountsOf(config, principal) {
|
|
|
181
191
|
const grant = (0, policy_1.getGrant)(config.entitlements, principal);
|
|
182
192
|
return (account) => (0, policy_1.canUse)(grant, account);
|
|
183
193
|
}
|
|
194
|
+
async function extendRun(config, registry, log, principal, id, req, res) {
|
|
195
|
+
const body = (await (0, http_2.readJson)(req));
|
|
196
|
+
let ttl;
|
|
197
|
+
try {
|
|
198
|
+
if (typeof body !== 'object' || body === null || typeof body.ttl !== 'string') {
|
|
199
|
+
throw new Error();
|
|
200
|
+
}
|
|
201
|
+
ttl = (0, units_1.parseDuration)(body.ttl);
|
|
202
|
+
if (!(ttl > 0)) {
|
|
203
|
+
throw new Error();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
(0, http_2.send)(res, 400, { error: 'ttl: expected a duration like 30m, 8h or 1d' });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const grant = (0, policy_1.getGrant)(config.entitlements, principal);
|
|
211
|
+
const result = registry.extend(principal, id, ttl, (run) => (0, service_runs_1.mayOpenEndpoints)(grant, run));
|
|
212
|
+
log(`extend iss=${principal.iss} sub=${principal.sub} run=${id} result=${result.status}`);
|
|
213
|
+
if (result.status === 'not-found') {
|
|
214
|
+
(0, http_2.send)(res, 404, { error: 'not found' });
|
|
215
|
+
}
|
|
216
|
+
else if (result.status === 'not-up') {
|
|
217
|
+
(0, http_2.send)(res, 409, { error: `the run is ${result.state}, only a service run that is up can be extended` });
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
(0, http_2.send)(res, 200, toRunResponse(result.run));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Why the owner of a service run that sleeps may no longer run it, with the entitlements of
|
|
224
|
+
// the moment: a wake is started by who opens an endpoint, but runs as the owner, as a new
|
|
225
|
+
// run of theirs would.
|
|
226
|
+
function refuseWake(config, repos, run) {
|
|
227
|
+
if (run.source.type !== 'ref') {
|
|
228
|
+
return 'a run from a sandbox does not wake';
|
|
229
|
+
}
|
|
230
|
+
const grant = (0, policy_1.getGrant)(config.entitlements, {
|
|
231
|
+
iss: run.owner.iss,
|
|
232
|
+
sub: run.owner.sub,
|
|
233
|
+
groups: run.owner.groups ?? [],
|
|
234
|
+
claims: run.owner.claims ?? {},
|
|
235
|
+
});
|
|
236
|
+
const decision = (0, policy_1.decideRun)({
|
|
237
|
+
grant,
|
|
238
|
+
accounts: run.accounts,
|
|
239
|
+
catalog: config.accounts,
|
|
240
|
+
source: { type: 'ref', repo: run.source.repo, ref: run.source.ref },
|
|
241
|
+
// an account it may only request is refused, the wake has nobody to approve it
|
|
242
|
+
approvable: () => true,
|
|
243
|
+
readable: (repo) => repos.readable(repo),
|
|
244
|
+
});
|
|
245
|
+
if (decision.type === 'refuse') {
|
|
246
|
+
return `its owner ${run.owner.sub} may no longer run it: ${decision.error.replace(/^you /, 'they ')}`;
|
|
247
|
+
}
|
|
248
|
+
if (decision.type === 'approval') {
|
|
249
|
+
return `its owner ${run.owner.sub} may only request ${decision.accounts.join(', ')} now, start it again`;
|
|
250
|
+
}
|
|
251
|
+
if (run.up?.endpoints.length && (0, policy_1.endpointLevel)(grant, run.source.repo) !== 'publish') {
|
|
252
|
+
return `its owner ${run.owner.sub} may no longer publish endpoints of ${run.source.repo}`;
|
|
253
|
+
}
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
exports.refuseWake = refuseWake;
|
|
184
257
|
function toRunResponse(run) {
|
|
185
258
|
const { owner, ...visible } = run;
|
|
186
259
|
void owner;
|
|
187
260
|
return { ...visible, results: run.results ?? {} };
|
|
188
261
|
}
|
|
189
|
-
async function submitRun(config, registry, definitions, sources, sandboxes, planning, repos, log, principal, req, res) {
|
|
262
|
+
async function submitRun(config, registry, definitions, sources, sandboxes, planning, repos, endpointUrl, log, principal, req, res) {
|
|
190
263
|
const parsed = run_request_1.runRequestSchema.safeParse(await (0, http_2.readJson)(req));
|
|
191
264
|
if (!parsed.success) {
|
|
192
265
|
const issue = parsed.error.issues[0];
|
|
@@ -218,6 +291,27 @@ async function submitRun(config, registry, definitions, sources, sandboxes, plan
|
|
|
218
291
|
(0, http_2.send)(res, 400, { error: `${version.hash} has no task ${unknown.map(oauth_1.quote).join(', ')}` });
|
|
219
292
|
return;
|
|
220
293
|
}
|
|
294
|
+
const notServices = request.services.filter((service) => !version.services.includes(service));
|
|
295
|
+
if (notServices.length > 0) {
|
|
296
|
+
(0, http_2.send)(res, 400, { error: `${version.hash} has no service ${notServices.map(oauth_1.quote).join(', ')}` });
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (request.endpoints.length > 0 && !config.endpoints) {
|
|
300
|
+
(0, http_2.send)(res, 400, { error: 'this server publishes no endpoints (endpoints is not configured)' });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const ttl = request.services.length > 0 ? (0, units_1.parseDuration)(request.ttl ?? config.runs.up.ttl) : 0;
|
|
304
|
+
if (ttl > (0, units_1.parseDuration)(config.runs.up.maxTtl)) {
|
|
305
|
+
(0, http_2.send)(res, 400, { error: `ttl: at most ${config.runs.up.maxTtl} on this server (runs.up.maxTtl)` });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
// the files of a sandbox change under a run that sleeps, it would wake to others
|
|
309
|
+
if (request.sleepTtl !== undefined && request.source.type === 'sandbox') {
|
|
310
|
+
(0, http_2.send)(res, 400, { error: 'sleepTtl: a service run from a sandbox does not sleep, start it from a branch' });
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const sleepTtlText = request.source.type === 'ref' ? request.sleepTtl ?? config.runs.up.sleepTtl : undefined;
|
|
314
|
+
const sleepTtl = request.services.length > 0 && sleepTtlText ? (0, units_1.parseDuration)(sleepTtlText) : undefined;
|
|
221
315
|
const argued = (request.options.args ?? []).length > 0 ? request.tasks[0] : undefined;
|
|
222
316
|
if (argued !== undefined && version.services.includes(argued)) {
|
|
223
317
|
(0, http_2.send)(res, 400, { error: `${argued} is a service, arguments go to a task` });
|
|
@@ -239,6 +333,12 @@ async function submitRun(config, registry, definitions, sources, sandboxes, plan
|
|
|
239
333
|
return;
|
|
240
334
|
}
|
|
241
335
|
}
|
|
336
|
+
if (request.services.length > 0 && request.source.type === 'sandbox' && config.backend.type === 'kubernetes') {
|
|
337
|
+
(0, http_2.send)(res, 400, {
|
|
338
|
+
error: 'on a cluster a service run starts from a branch, push the sandbox and start it from there',
|
|
339
|
+
});
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
242
342
|
const sandbox = request.source.type === 'sandbox' ? sandboxes.registry.get(principal, request.source.id) : undefined;
|
|
243
343
|
if (request.source.type === 'sandbox') {
|
|
244
344
|
if (!sandbox) {
|
|
@@ -275,12 +375,20 @@ async function submitRun(config, registry, definitions, sources, sandboxes, plan
|
|
|
275
375
|
(0, http_2.send)(res, 400, { error: `the run does not load: ${e instanceof Error ? e.message : String(e)}` });
|
|
276
376
|
return;
|
|
277
377
|
}
|
|
278
|
-
const refused = (0, run_plan_1.refuseArguments)(request, planned.plan, sandbox ? sandboxes.policy : planning.policy)
|
|
378
|
+
const refused = (0, run_plan_1.refuseArguments)(request, planned.plan, sandbox ? sandboxes.policy : planning.policy) ??
|
|
379
|
+
(0, service_runs_1.refuseEndpoints)(request, planned.plan, config.runs.secretNetwork === 'open');
|
|
279
380
|
if (refused) {
|
|
280
381
|
log(`refuse run iss=${principal.iss} sub=${principal.sub}: ${refused}`);
|
|
281
382
|
(0, http_2.send)(res, 400, { error: refused });
|
|
282
383
|
return;
|
|
283
384
|
}
|
|
385
|
+
const runRepo = request.source.type === 'ref' ? request.source.repo : sandbox?.repo;
|
|
386
|
+
if (request.endpoints.length > 0 && (!runRepo || (0, policy_1.endpointLevel)(grant, runRepo) !== 'publish')) {
|
|
387
|
+
const error = `you may not publish endpoints of ${runRepo ?? 'this source'}, an entitlement needs endpoints with level publish for it`;
|
|
388
|
+
log(`refuse run iss=${principal.iss} sub=${principal.sub}: ${error}`);
|
|
389
|
+
(0, http_2.send)(res, 403, { error });
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
284
392
|
const decision = (0, policy_1.decideRun)({
|
|
285
393
|
grant,
|
|
286
394
|
accounts: planned.accounts,
|
|
@@ -324,13 +432,34 @@ async function submitRun(config, registry, definitions, sources, sandboxes, plan
|
|
|
324
432
|
else if (request.source.type === 'upload') {
|
|
325
433
|
source = request.source;
|
|
326
434
|
}
|
|
327
|
-
|
|
435
|
+
// a service run is named after its branch unless it says, and its endpoints after that
|
|
436
|
+
const serviceName = request.services.length > 0
|
|
437
|
+
? request.name ??
|
|
438
|
+
(0, service_runs_1.getServiceRunName)(request.source.type === 'ref' ? request.source.ref : sandbox?.ref)
|
|
439
|
+
: '';
|
|
440
|
+
const up = request.services.length > 0
|
|
441
|
+
? {
|
|
442
|
+
services: request.services,
|
|
443
|
+
ttl,
|
|
444
|
+
...(sleepTtl ? { sleepTtl } : {}),
|
|
445
|
+
name: serviceName,
|
|
446
|
+
endpoints: (0, service_runs_1.toRunEndpoints)(request.endpoints, (0, service_runs_1.getEndpointHosts)(serviceName, principal, request.endpoints), endpointUrl),
|
|
447
|
+
}
|
|
448
|
+
: undefined;
|
|
449
|
+
// a run that may sleep keeps what its owner is entitled by, for the check of a wake
|
|
450
|
+
const claimNames = new Set(config.entitlements.flatMap((entitlement) => Object.keys(entitlement.claims?.match ?? {})));
|
|
451
|
+
const owner = {
|
|
452
|
+
...principal,
|
|
453
|
+
claims: Object.fromEntries(Object.entries(principal.claims).filter(([name]) => claimNames.has(name))),
|
|
454
|
+
};
|
|
455
|
+
const submit = (runSource) => registry.submit(owner, {
|
|
328
456
|
definition: version.hash,
|
|
329
457
|
tasks: request.tasks,
|
|
330
458
|
accounts: planned.accounts,
|
|
331
459
|
plan: planned.plan,
|
|
332
460
|
source: runSource,
|
|
333
461
|
options: request.options,
|
|
462
|
+
...(up ? { up } : {}),
|
|
334
463
|
}, approvals);
|
|
335
464
|
try {
|
|
336
465
|
// a sandbox is read and the run submitted while nothing else changes it: the run
|
|
@@ -348,7 +477,9 @@ async function submitRun(config, registry, definitions, sources, sandboxes, plan
|
|
|
348
477
|
clean: state.clean,
|
|
349
478
|
});
|
|
350
479
|
});
|
|
351
|
-
log(`run ${run.id} iss=${principal.iss} sub=${principal.sub} accounts=[${planned.accounts.join(',')}] source=${run.source.type}${run.source.type === 'sandbox' ? ` sandbox=${run.source.id}` : ''} tasks=${run.tasks.length}${
|
|
480
|
+
log(`run ${run.id} iss=${principal.iss} sub=${principal.sub} accounts=[${planned.accounts.join(',')}] source=${run.source.type}${run.source.type === 'sandbox' ? ` sandbox=${run.source.id}` : ''} tasks=${run.tasks.length}${run.up
|
|
481
|
+
? ` services=[${run.up.services.join(',')}] name=${run.up.name} endpoints=${run.up.endpoints.length}`
|
|
482
|
+
: ''}${approvals.length > 0 ? ` approval=[${approvals.join(',')}]` : ''}`);
|
|
352
483
|
(0, http_2.send)(res, 202, toRunResponse(run));
|
|
353
484
|
}
|
|
354
485
|
catch (e) {
|
|
@@ -385,7 +516,13 @@ async function authenticate(verifier, log, req, res) {
|
|
|
385
516
|
return null;
|
|
386
517
|
}
|
|
387
518
|
}
|
|
388
|
-
async function handle(config, registry, definitions, proposals, sources, sandboxes, planning, repos, verifier, log, req, res) {
|
|
519
|
+
async function handle(config, registry, gateway, definitions, proposals, sources, sandboxes, planning, repos, verifier, log, req, res) {
|
|
520
|
+
// the hosts of endpoints are the apps of service runs, not the API
|
|
521
|
+
const label = gateway?.hostOf(req);
|
|
522
|
+
if (gateway && label) {
|
|
523
|
+
await gateway.handle(label, req, res);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
389
526
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
390
527
|
const path = url.pathname;
|
|
391
528
|
if (path === discovery_1.DISCOVERY_PATH || path === '/healthz') {
|
|
@@ -412,7 +549,7 @@ async function handle(config, registry, definitions, proposals, sources, sandbox
|
|
|
412
549
|
if (await (0, sandboxes_api_1.handleSandboxes)(config, sandboxes.registry, repos, log, principal, req, res, url)) {
|
|
413
550
|
return;
|
|
414
551
|
}
|
|
415
|
-
if (await handleRuns(config, registry, definitions, sources, sandboxes, planning, repos, log, principal, req, res, url)) {
|
|
552
|
+
if (await handleRuns(config, registry, definitions, sources, sandboxes, planning, repos, (host) => gateway?.url(host) ?? host, log, principal, req, res, url)) {
|
|
416
553
|
return;
|
|
417
554
|
}
|
|
418
555
|
if (await (0, approvals_api_1.handleApprovals)(config, registry, log, principal, req, res, url)) {
|
|
@@ -447,7 +584,7 @@ async function startServer(config, environment, options = {}) {
|
|
|
447
584
|
},
|
|
448
585
|
policy: {
|
|
449
586
|
envs: config.runs.env.allow,
|
|
450
|
-
reserved: (0, server_config_1.
|
|
587
|
+
reserved: (0, server_config_1.getServerAccounts)(config),
|
|
451
588
|
runtimes: config.runtimes,
|
|
452
589
|
backend: config.backend.type,
|
|
453
590
|
network: (0, work_network_1.parseWorkNetwork)(config.runs.network),
|
|
@@ -529,10 +666,34 @@ async function startServer(config, environment, options = {}) {
|
|
|
529
666
|
limits: config.runs.limits,
|
|
530
667
|
maxDuration: (0, units_1.parseDuration)(config.runs.maxDuration),
|
|
531
668
|
maxLogBytes: config.runs.maxLogBytes,
|
|
669
|
+
up: {
|
|
670
|
+
maxTtl: (0, units_1.parseDuration)(config.runs.up.maxTtl),
|
|
671
|
+
perCaller: config.runs.up.limits.up,
|
|
672
|
+
total: config.runs.up.total,
|
|
673
|
+
},
|
|
532
674
|
store: new run_store_1.FileRunStore((0, path_1.join)(storage, 'records')),
|
|
533
675
|
log,
|
|
534
676
|
});
|
|
535
677
|
await registry.restore();
|
|
678
|
+
if (executor instanceof kubernetes_backend_1.KubernetesExecutor) {
|
|
679
|
+
// the services of the runs of a server that stopped are gone with it
|
|
680
|
+
executor.sweepServiceRuns();
|
|
681
|
+
}
|
|
682
|
+
const login = config.endpoints?.login;
|
|
683
|
+
const gateway = config.endpoints
|
|
684
|
+
? new endpoints_1.EndpointGateway(config, registry, {
|
|
685
|
+
keyFile: (0, path_1.join)(storage, 'endpoints', 'session.key'),
|
|
686
|
+
verifier,
|
|
687
|
+
...(login?.clientSecret
|
|
688
|
+
? { clientSecret: () => repos.serverSecret(login.clientSecret) }
|
|
689
|
+
: {}),
|
|
690
|
+
log,
|
|
691
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
692
|
+
...(options.now ? { now: options.now } : {}),
|
|
693
|
+
mayWake: (run) => refuseWake(config, repos, run),
|
|
694
|
+
})
|
|
695
|
+
: undefined;
|
|
696
|
+
await gateway?.load();
|
|
536
697
|
sandboxes.registry.useRuns({
|
|
537
698
|
holder: (id) => registry.holder(id),
|
|
538
699
|
stop: (id) => registry.stopSandbox(id, 'the sandbox was deleted'),
|
|
@@ -543,7 +704,7 @@ async function startServer(config, environment, options = {}) {
|
|
|
543
704
|
cooldown: (0, units_1.parseDuration)(config.approvals.cooldown),
|
|
544
705
|
});
|
|
545
706
|
const listener = (req, res) => {
|
|
546
|
-
handle(config, registry, definitions, proposals, sources, sandboxes, planning, repos, verifier, log, req, res).catch((e) => {
|
|
707
|
+
handle(config, registry, gateway, definitions, proposals, sources, sandboxes, planning, repos, verifier, log, req, res).catch((e) => {
|
|
547
708
|
if (e instanceof http_2.HttpError) {
|
|
548
709
|
(0, http_2.send)(res, e.status, { error: e.message });
|
|
549
710
|
return;
|
|
@@ -563,11 +724,44 @@ async function startServer(config, environment, options = {}) {
|
|
|
563
724
|
environment.file.read(config.tls.cert),
|
|
564
725
|
environment.file.read(config.tls.key),
|
|
565
726
|
]);
|
|
566
|
-
|
|
727
|
+
// the hosts of endpoints have a wildcard certificate of their own
|
|
728
|
+
const endpointsTls = config.endpoints?.tls;
|
|
729
|
+
const endpointContext = endpointsTls
|
|
730
|
+
? (0, tls_1.createSecureContext)({
|
|
731
|
+
cert: await environment.file.read(endpointsTls.cert),
|
|
732
|
+
key: await environment.file.read(endpointsTls.key),
|
|
733
|
+
})
|
|
734
|
+
: undefined;
|
|
735
|
+
const domain = config.endpoints?.domain;
|
|
736
|
+
server = (0, https_1.createServer)({
|
|
737
|
+
cert,
|
|
738
|
+
key,
|
|
739
|
+
...(endpointContext && domain
|
|
740
|
+
? {
|
|
741
|
+
SNICallback: (servername, done) => done(null, servername.toLowerCase().endsWith(`.${domain}`) ? endpointContext : undefined),
|
|
742
|
+
}
|
|
743
|
+
: {}),
|
|
744
|
+
}, listener);
|
|
567
745
|
}
|
|
568
746
|
else {
|
|
569
747
|
server = (0, http_1.createServer)(listener);
|
|
570
748
|
}
|
|
749
|
+
// WebSockets of the apps behind endpoints; the API has none. An upgraded connection is
|
|
750
|
+
// no longer the HTTP server's, it goes when the server closes.
|
|
751
|
+
const upgraded = new Set();
|
|
752
|
+
server.on('upgrade', (req, socket, head) => {
|
|
753
|
+
upgraded.add(socket);
|
|
754
|
+
socket.once('close', () => upgraded.delete(socket));
|
|
755
|
+
const label = gateway?.hostOf(req);
|
|
756
|
+
if (!gateway || !label) {
|
|
757
|
+
socket.end('HTTP/1.1 404 Not Found\r\nconnection: close\r\ncontent-length: 0\r\n\r\n');
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
gateway.upgrade(label, req, socket, head).catch((e) => {
|
|
761
|
+
log(`error upgrade ${label}: ${e instanceof Error ? e.message : String(e)}`);
|
|
762
|
+
socket.destroy();
|
|
763
|
+
});
|
|
764
|
+
});
|
|
571
765
|
await new Promise((resolve, reject) => {
|
|
572
766
|
server.once('error', reject);
|
|
573
767
|
server.listen(config.listen.port, config.listen.host, () => {
|
|
@@ -576,11 +770,12 @@ async function startServer(config, environment, options = {}) {
|
|
|
576
770
|
});
|
|
577
771
|
});
|
|
578
772
|
const address = server.address();
|
|
773
|
+
gateway?.listening(address.port, Boolean(config.tls));
|
|
579
774
|
const host = address.family === 'IPv6' ? `[${address.address}]` : address.address;
|
|
580
775
|
return {
|
|
581
776
|
url: `${config.tls ? 'https' : 'http'}://${host}:${address.port}`,
|
|
582
777
|
reload: (next) => {
|
|
583
|
-
const readers = (0, server_config_1.
|
|
778
|
+
const readers = (0, server_config_1.getServerAccounts)(config);
|
|
584
779
|
next.entitlements.forEach((entitlement, index) => {
|
|
585
780
|
const iss = (entitlement.subject ?? entitlement.group ?? entitlement.claims)?.iss;
|
|
586
781
|
if (iss && !config.issuers.some((issuer) => issuer.issuer === iss)) {
|
|
@@ -605,6 +800,7 @@ async function startServer(config, environment, options = {}) {
|
|
|
605
800
|
await new Promise((resolve, reject) => {
|
|
606
801
|
server.close((err) => (err ? reject(err) : resolve()));
|
|
607
802
|
server.closeAllConnections();
|
|
803
|
+
upgraded.forEach((socket) => socket.destroy());
|
|
608
804
|
});
|
|
609
805
|
},
|
|
610
806
|
};
|