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.detectPlatform = exports.getSecretCatalog = exports.readServerConfig = exports.getSourceAccounts = exports.getSandboxRuntimes = exports.serverConfigSchema = exports.sourceSchema = exports.entitlementSchema = 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;
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 sandboxes',
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 = getSourceAccounts(config);
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 = getSourceAccounts(config);
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 that read repositories: no caller uses them and no build file names them.
768
- function getSourceAccounts(config) {
769
- return [...new Set(config.sources.flatMap((source) => (source.secret ? [source.secret.account] : [])))];
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.getSourceAccounts = getSourceAccounts;
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`);
@@ -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
- const submit = (runSource) => registry.submit(principal, {
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}${approvals.length > 0 ? ` approval=[${approvals.join(',')}]` : ''}`);
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.getSourceAccounts)(config),
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
- server = (0, https_1.createServer)({ cert, key }, listener);
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.getSourceAccounts)(config);
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
  };