hammerkit 1.27.0-next.21 → 1.27.0-next.23

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.findService = exports.findTask = exports.parseReferences = void 0;
3
+ exports.findService = exports.findTask = exports.parseReferences = exports.getPublishedFiles = void 0;
4
4
  const build_file_task_schema_1 = require("./build-file-task-schema");
5
5
  const build_file_service_schema_1 = require("./build-file-service-schema");
6
6
  const split_name_1 = require("../planner/utils/split-name");
@@ -10,11 +10,59 @@ const merge_environment_variables_1 = require("../environment/merge-environment-
10
10
  const resolve_cache_1 = require("../cache/resolve-cache");
11
11
  const project_root_1 = require("../planner/project-root");
12
12
  const secret_catalog_1 = require("../secrets/secret-catalog");
13
+ const host_config_1 = require("../remote/host-config");
14
+ // The providers and accounts of the host config, see HostSecretDeclarations
15
+ async function readHostSecrets(environment) {
16
+ const config = await (0, host_config_1.readHostConfig)(environment);
17
+ return {
18
+ fileName: (0, host_config_1.getHostConfigPath)(environment),
19
+ secretProviders: config.secretProviders,
20
+ secretAccounts: config.secretAccounts,
21
+ };
22
+ }
23
+ // What a task or service names that a server left out (`publish: false`)
24
+ class UnpublishedError extends Error {
25
+ constructor(name, reason) {
26
+ super(`${name} is not published (${reason})`);
27
+ this.name = name;
28
+ this.reason = reason;
29
+ }
30
+ }
31
+ // The task or service `user` names another one by, which a server left out: the error says
32
+ // which published one still needs it, as an error elsewhere is left as it is
33
+ function findPublished(user, verb, find) {
34
+ try {
35
+ return find();
36
+ }
37
+ catch (e) {
38
+ if (e instanceof UnpublishedError) {
39
+ throw new Error(`${user} ${verb} ${e.name}, which is not published (${e.reason})`);
40
+ }
41
+ throw e;
42
+ }
43
+ }
44
+ // The build files a server publishes: the entry file and what it includes or references,
45
+ // except through an include with `publish: false`. The key of each is in ctx.files.
46
+ function getPublishedFiles(mainScope) {
47
+ const published = new Set([mainScope]);
48
+ const queue = [mainScope];
49
+ for (let scope = queue.shift(); scope; scope = queue.shift()) {
50
+ for (const reference of Object.values(scope.references)) {
51
+ if (reference.publish && !published.has(reference.scope)) {
52
+ published.add(reference.scope);
53
+ queue.push(reference.scope);
54
+ }
55
+ }
56
+ }
57
+ return published;
58
+ }
59
+ exports.getPublishedFiles = getPublishedFiles;
13
60
  function combinePath(namePrefix, name) {
14
61
  return namePrefix.length > 0 ? `${namePrefix}:${name}` : name;
15
62
  }
16
63
  async function parseReferences(ctx, mainScope, environment) {
17
64
  const declaredCaches = {};
65
+ const unpublished = {};
18
66
  const reference = {
19
67
  files: {},
20
68
  tasks: {},
@@ -24,9 +72,28 @@ async function parseReferences(ctx, mainScope, environment) {
24
72
  caches: {},
25
73
  secrets: (0, secret_catalog_1.emptySecretCatalog)(),
26
74
  projectRoot: await (0, project_root_1.findProjectRoot)(mainScope.cwd, environment),
75
+ unpublished,
76
+ };
77
+ // a server leaves out what is not published: it doesn't run, and isn't checked against
78
+ // its policy. Locally everything is there.
79
+ const publishedFiles = environment.hosted ? getPublishedFiles(mainScope) : null;
80
+ const isLeftOut = (file, name, schema) => {
81
+ if (!publishedFiles) {
82
+ return false;
83
+ }
84
+ if (!publishedFiles.has(file)) {
85
+ unpublished[name] = 'its build file is included with publish: false';
86
+ }
87
+ else if (schema.publish === false) {
88
+ unpublished[name] = 'publish: false';
89
+ }
90
+ return name in unpublished;
27
91
  };
28
92
  for (const file of Object.values(ctx.files)) {
29
- if (environment.hosted && (file.schema.secretProviders || file.schema.secretAccounts)) {
93
+ // what a server leaves out of a file it doesn't publish: its tasks and services (see
94
+ // isLeftOut), and where it would run or store things, which only a local run reads
95
+ const published = !publishedFiles || publishedFiles.has(file);
96
+ if (published && environment.hosted && (file.schema.secretProviders || file.schema.secretAccounts)) {
30
97
  throw new Error(`${file.fileName} declares secret providers or accounts, which a server defines in its own config`);
31
98
  }
32
99
  if (!reference.envFiles[file.cwd]) {
@@ -38,6 +105,9 @@ async function parseReferences(ctx, mainScope, environment) {
38
105
  for (const [taskName, task] of Object.entries(file.schema.tasks)) {
39
106
  const taskEnvs = (0, merge_environment_variables_1.mergeEnvironmentVariables)(task.envs, buildFileEnvs);
40
107
  const relativeName = combinePath(file.namePrefix, taskName);
108
+ if (isLeftOut(file, relativeName, task)) {
109
+ continue;
110
+ }
41
111
  reference.tasks[relativeName] = {
42
112
  type: 'task',
43
113
  schema: task,
@@ -51,7 +121,7 @@ async function parseReferences(ctx, mainScope, environment) {
51
121
  };
52
122
  }
53
123
  }
54
- if (file.schema.environments) {
124
+ if (file.schema.environments && published) {
55
125
  for (const [envName, env] of Object.entries(file.schema.environments)) {
56
126
  reference.environments[envName] = {
57
127
  type: 'environment',
@@ -61,7 +131,7 @@ async function parseReferences(ctx, mainScope, environment) {
61
131
  };
62
132
  }
63
133
  }
64
- if (file.schema.caches) {
134
+ if (file.schema.caches && published) {
65
135
  for (const [cacheName, cache] of Object.entries(file.schema.caches)) {
66
136
  declaredCaches[cacheName] = cache;
67
137
  }
@@ -69,6 +139,9 @@ async function parseReferences(ctx, mainScope, environment) {
69
139
  if (file.schema.services) {
70
140
  for (const [serviceName, service] of Object.entries(file.schema.services)) {
71
141
  const relativeName = combinePath(file.namePrefix, serviceName);
142
+ if (isLeftOut(file, relativeName, service)) {
143
+ continue;
144
+ }
72
145
  const serviceEnvs = (0, merge_environment_variables_1.mergeEnvironmentVariables)((0, build_file_service_schema_1.isBuildFileKubernetesServiceSchema)(service) ? {} : service.envs, buildFileEnvs);
73
146
  reference.services[relativeName] = {
74
147
  type: 'service',
@@ -98,7 +171,7 @@ async function parseReferences(ctx, mainScope, environment) {
98
171
  reference.caches = (0, resolve_cache_1.withBuiltinCaches)(declaredCaches);
99
172
  reference.secrets = environment.hosted
100
173
  ? { ...environment.hosted.catalog, explicitAccounts: true }
101
- : (0, secret_catalog_1.collectSecretCatalog)(Object.values(ctx.files), environment, reference);
174
+ : (0, secret_catalog_1.collectSecretCatalog)(Object.values(ctx.files), environment, reference, await readHostSecrets(environment));
102
175
  return reference;
103
176
  }
104
177
  exports.parseReferences = parseReferences;
@@ -122,7 +195,8 @@ function applySchemaExtension(reference, task) {
122
195
  task.extension = 'applied';
123
196
  return;
124
197
  }
125
- const extend = findTask(reference, task.scope, task.schema.extend, null).task;
198
+ const name = task.schema.extend;
199
+ const extend = findPublished(task.relativeName, 'extends', () => findTask(reference, task.scope, name, null)).task;
126
200
  applySchemaExtension(reference, extend);
127
201
  task.schema.shell = task.schema.shell ?? extend.schema.shell;
128
202
  task.schema.cache = task.schema.cache ?? extend.schema.cache;
@@ -182,7 +256,7 @@ function extendObject(base, extend) {
182
256
  }
183
257
  function resolveDeps(reference, taskOrService, scope, schema) {
184
258
  for (const depName of schema.deps || []) {
185
- const depTask = findTask(reference, scope, depName, null);
259
+ const depTask = findPublished(taskOrService.relativeName, 'depends on', () => findTask(reference, scope, depName, null));
186
260
  taskOrService.deps.push({
187
261
  task: depTask.task,
188
262
  type: depTask.type,
@@ -195,7 +269,8 @@ function resolveInit(reference, service) {
195
269
  if ((0, build_file_service_schema_1.isBuildFileKubernetesServiceSchema)(service.schema) || !service.schema.init) {
196
270
  return;
197
271
  }
198
- const initTask = findTask(reference, service.scope, service.schema.init, null);
272
+ const init = service.schema.init;
273
+ const initTask = findPublished(service.relativeName, 'runs as init', () => findTask(reference, service.scope, init, null));
199
274
  service.init = {
200
275
  task: initTask.task,
201
276
  type: initTask.type,
@@ -213,13 +288,13 @@ function resolveNeeds(reference, taskOrService, scope, schema) {
213
288
  // the host name it is reached by: a service of an include, `services:postgres`, as
214
289
  // `services-postgres`, since Docker and DNS take no colon
215
290
  relativeName: needName.replace(/:/g, '-'),
216
- service: findService(reference, scope, needName),
291
+ service: findPublished(taskOrService.relativeName, 'needs', () => findService(reference, scope, needName)),
217
292
  });
218
293
  }
219
294
  else {
220
295
  taskOrService.needs.push({
221
296
  relativeName: needName.name,
222
- service: findService(reference, scope, needName.service),
297
+ service: findPublished(taskOrService.relativeName, 'needs', () => findService(reference, scope, needName.service)),
223
298
  });
224
299
  }
225
300
  }
@@ -230,6 +305,7 @@ function resolveNeeds(reference, taskOrService, scope, schema) {
230
305
  }
231
306
  }
232
307
  function findTask(ctx, scope, name, type) {
308
+ refuseNotFetched(scope, name);
233
309
  const ref = (0, split_name_1.splitName)(name);
234
310
  if (ref.prefix) {
235
311
  if (scope.references[ref.prefix]) {
@@ -245,15 +321,25 @@ function findTask(ctx, scope, name, type) {
245
321
  throw new Error('unable to find ' + name); // TODO improve
246
322
  }
247
323
  exports.findTask = findTask;
324
+ // A name in a git source a server did not fetch, since it is left out
325
+ function refuseNotFetched(scope, name) {
326
+ if (scope.notFetched) {
327
+ throw new UnpublishedError(combinePath(scope.namePrefix, name), 'its build file is included with publish: false');
328
+ }
329
+ }
248
330
  function resolveTask(ctx, scope, name, type) {
249
331
  const fullName = combinePath(scope.namePrefix, name);
250
332
  const buildFileTask = ctx.tasks[fullName];
333
+ if (!buildFileTask && ctx.unpublished?.[fullName]) {
334
+ throw new UnpublishedError(fullName, ctx.unpublished[fullName]);
335
+ }
251
336
  if (!buildFileTask) {
252
337
  throw new Error('unable to find ' + fullName);
253
338
  }
254
339
  return { task: buildFileTask, type };
255
340
  }
256
341
  function findService(ctx, scope, name) {
342
+ refuseNotFetched(scope, name);
257
343
  const ref = (0, split_name_1.splitName)(name);
258
344
  if (ref.prefix) {
259
345
  if (scope.references[ref.prefix]) {
@@ -272,6 +358,9 @@ exports.findService = findService;
272
358
  function resolveService(ctx, scope, name) {
273
359
  const fullName = combinePath(scope.namePrefix, name);
274
360
  const service = ctx.services[fullName];
361
+ if (!service && ctx.unpublished?.[fullName]) {
362
+ throw new UnpublishedError(fullName, ctx.unpublished[fullName]);
363
+ }
275
364
  if (!service) {
276
365
  throw new Error('unable to find ' + fullName);
277
366
  }
@@ -13,60 +13,79 @@ async function createParseContext(fileName, environment, options) {
13
13
  files: {},
14
14
  refreshed: options?.refresh ? new Map() : undefined,
15
15
  directory: options?.directory,
16
+ checkGitSource: options?.checkGitSource,
16
17
  };
17
18
  const scope = await appendBuildFile(workDirectory(ctx, (0, path_1.dirname)(fileName)), ctx, environment, fileName, []);
18
19
  return { ctx, scope };
19
20
  }
20
21
  exports.createParseContext = createParseContext;
21
- async function appendBuildFile(cwd, ctx, environment, fileName, namePrefix, remote, inputs) {
22
+ async function appendBuildFile(cwd, ctx, environment, fileName, namePrefix, remote, inputs,
23
+ // on a server: the file is included with `publish: false`, or below such an include
24
+ leftOut = false) {
22
25
  const relativeName = namePrefix.join(':');
23
- // the same file included with other inputs is another set of tasks
24
- const key = inputs ? `${cwd}:${fileName}:${JSON.stringify(inputs)}` : `${cwd}:${fileName}`;
26
+ // the same file included with other inputs is another set of tasks, and on a server a
27
+ // file left out is another one than the same file published (its git sources differ)
28
+ const key = `${cwd}:${fileName}${inputs ? `:${JSON.stringify(inputs)}` : ''}${leftOut ? ':left-out' : ''}`;
25
29
  if (ctx.files[key]) {
26
30
  return ctx.files[key];
27
31
  }
28
32
  const input = await (0, read_build_file_1.read)(fileName, environment);
29
33
  const result = await build_file_schema_1.buildFileSchema.safeParseAsync(input);
30
- if (result.success) {
31
- const scope = {
32
- fileName,
33
- cwd,
34
- schema: inputs ? applyInputs(result.data, inputs, namePrefix, fileName) : result.data,
35
- namePrefix: relativeName,
36
- remote,
37
- inputs,
38
- references: {},
39
- };
40
- ctx.files[key] = scope;
41
- if (scope.schema.references) {
42
- for (const referenceName of Object.keys(scope.schema.references)) {
43
- const target = await locateInclude(ctx, scope, scope.schema.references[referenceName],
44
- // a remote file's paths resolve within its repository
45
- remote ? (0, path_1.dirname)(scope.fileName) : readDirectory(ctx, scope.cwd), environment);
46
- const referenceScope = await appendBuildFile(workDirectory(ctx, (0, path_1.dirname)(target.fileName)), ctx, environment, target.fileName, [...namePrefix, referenceName], target.remote, target.inputs);
47
- if (scope.references[referenceName]) {
48
- throw new Error(referenceName + ' already exists');
49
- }
50
- scope.references[referenceName] = { type: 'reference', scope: referenceScope };
34
+ if (!result.success) {
35
+ throw new parse_error_1.ParseError(result.error, fileName);
36
+ }
37
+ const scope = {
38
+ fileName,
39
+ cwd,
40
+ schema: inputs ? applyInputs(result.data, inputs, namePrefix, fileName) : result.data,
41
+ namePrefix: relativeName,
42
+ remote,
43
+ inputs,
44
+ references: {},
45
+ };
46
+ ctx.files[key] = scope;
47
+ for (const block of ['references', 'includes']) {
48
+ for (const [name, include] of Object.entries(scope.schema[block] ?? {})) {
49
+ if (scope.references[name]) {
50
+ throw new Error(name + ' already exists');
51
51
  }
52
- }
53
- if (scope.schema.includes) {
54
- for (const includeName of Object.keys(scope.schema.includes)) {
55
- const target = await locateInclude(ctx, scope, scope.schema.includes[includeName], (0, path_1.dirname)(scope.fileName), environment);
56
- const includeScope = await appendBuildFile(cwd, ctx, environment, target.fileName, [...namePrefix, includeName], target.remote, target.inputs);
57
- if (scope.references[includeName]) {
58
- throw new Error(includeName + ' already exists');
59
- }
60
- scope.references[includeName] = { type: 'include', scope: includeScope };
52
+ const publish = (0, build_file_include_schema_1.isPublished)(include);
53
+ const type = block === 'references' ? 'reference' : 'include';
54
+ // a server does not fetch what it leaves out: it doesn't run, and isn't checked
55
+ const leaveOut = !!environment.hosted && (leftOut || !publish);
56
+ if ((0, build_file_include_schema_1.isGitSource)(include) && leaveOut) {
57
+ scope.references[name] = { type, publish, scope: notFetched(cwd, [...namePrefix, name], include) };
58
+ continue;
59
+ }
60
+ if ((0, build_file_include_schema_1.isGitSource)(include)) {
61
+ ctx.checkGitSource?.(include, scope, block, name);
61
62
  }
63
+ const target = await locateInclude(ctx, scope, include, block === 'references'
64
+ ? // a remote file's paths resolve within its repository
65
+ remote
66
+ ? (0, path_1.dirname)(scope.fileName)
67
+ : readDirectory(ctx, scope.cwd)
68
+ : (0, path_1.dirname)(scope.fileName), environment);
69
+ const targetScope = await appendBuildFile(
70
+ // a reference works in its own directory, an include in the one of the including file
71
+ block === 'references' ? workDirectory(ctx, (0, path_1.dirname)(target.fileName)) : cwd, ctx, environment, target.fileName, [...namePrefix, name], target.remote, target.inputs, leaveOut);
72
+ scope.references[name] = { type, scope: targetScope, publish };
62
73
  }
63
- return scope;
64
- }
65
- else {
66
- throw new parse_error_1.ParseError(result.error, fileName);
67
74
  }
75
+ return scope;
68
76
  }
69
77
  exports.appendBuildFile = appendBuildFile;
78
+ // The scope of a git source a server does not fetch, see ParseScope.notFetched
79
+ function notFetched(cwd, namePrefix, include) {
80
+ return {
81
+ fileName: `${include.git}${include.path ? ` ${include.path}` : ''}`,
82
+ cwd,
83
+ schema: {},
84
+ namePrefix: namePrefix.join(':'),
85
+ references: {},
86
+ notFetched: true,
87
+ };
88
+ }
70
89
  // The directory a build file works in, for the directory it was read from: the same
71
90
  // one, unless the files were read from elsewhere (see BuildFileDirectory).
72
91
  function workDirectory(ctx, directory) {
@@ -150,9 +150,11 @@ function candidates(command, path) {
150
150
  .map((dir) => (0, path_1.resolve)(dir, command));
151
151
  return bases.flatMap((base) => extensions.map((extension) => base + extension));
152
152
  }
153
+ // bigint: a Windows file id has more digits than a number keeps, and two files whose ids
154
+ // round to the same number would be the same file
153
155
  function statOf(path) {
154
156
  try {
155
- const stat = (0, fs_1.statSync)(path);
157
+ const stat = (0, fs_1.statSync)(path, { bigint: true });
156
158
  return stat.isFile() ? stat : null;
157
159
  }
158
160
  catch {
@@ -11,10 +11,12 @@ function emptySecretCatalog() {
11
11
  exports.emptySecretCatalog = emptySecretCatalog;
12
12
  const FULL_COMMIT = /^[0-9a-f]{40}$/i;
13
13
  // Providers and accounts are global names, like caches, collected from every
14
- // loaded build file. A name declared twice is an error rather than a silent
15
- // override: which definition runs with a service account's credential must not
16
- // depend on include order.
17
- function collectSecretCatalog(files, environment, context) {
14
+ // loaded build file. A name declared twice is an error rather than a silent override:
15
+ // which definition runs with a service account's credential must not depend on include
16
+ // order. The host config then replaces what the build files declare, name by name (and
17
+ // its default account replaces theirs): the machine that runs a task says how it reads
18
+ // secrets, and a build file that still declares them loads as it is.
19
+ function collectSecretCatalog(files, environment, context, host) {
18
20
  const catalog = emptySecretCatalog();
19
21
  for (const file of files) {
20
22
  const providers = Object.entries(file.schema.secretProviders ?? {});
@@ -28,25 +30,13 @@ function collectSecretCatalog(files, environment, context) {
28
30
  if (catalog.providers[name]) {
29
31
  throw new Error(`secret provider ${name} is declared in ${catalog.providers[name].declaredIn} and in ${file.fileName}`);
30
32
  }
31
- catalog.providers[name] = {
32
- name,
33
- type: 'command',
34
- command: provider.command.map((part) => applyEnvs(part, envs)),
35
- env: provider.env ?? {},
36
- timeoutMs: (0, units_1.parseDuration)(provider.timeout ?? DEFAULT_TIMEOUT),
37
- declaredIn: file.fileName,
38
- };
33
+ catalog.providers[name] = toProvider(name, provider, envs, file.fileName);
39
34
  }
40
35
  for (const [name, account] of accounts) {
41
36
  if (catalog.accounts[name]) {
42
37
  throw new Error(`secret account ${name} is declared in ${catalog.accounts[name].declaredIn} and in ${file.fileName}`);
43
38
  }
44
- catalog.accounts[name] = {
45
- name,
46
- default: account.default ?? false,
47
- providers: account.providers,
48
- declaredIn: file.fileName,
49
- };
39
+ catalog.accounts[name] = toAccount(name, account, file.fileName);
50
40
  }
51
41
  }
52
42
  const defaults = Object.values(catalog.accounts).filter((account) => account.default);
@@ -55,14 +45,48 @@ function collectSecretCatalog(files, environment, context) {
55
45
  }
56
46
  for (const account of Object.values(catalog.accounts)) {
57
47
  for (const provider of Object.keys(account.providers)) {
58
- if (!catalog.providers[provider]) {
48
+ if (!catalog.providers[provider] && !host?.secretProviders?.[provider]) {
59
49
  throw new Error(`secret account ${account.name} (${account.declaredIn}) names provider ${provider}, which is not declared`);
60
50
  }
61
51
  }
62
52
  }
53
+ if (host) {
54
+ // the host config has no envs of its own: `${NAME}` is read from the process
55
+ for (const [name, provider] of Object.entries(host.secretProviders ?? {})) {
56
+ catalog.providers[name] = toProvider(name, provider, {}, host.fileName);
57
+ }
58
+ const accounts = Object.entries(host.secretAccounts ?? {});
59
+ const hostDefaults = accounts.filter(([, account]) => account.default);
60
+ if (hostDefaults.length > 1) {
61
+ throw new Error(`more than one default secret account: ${hostDefaults.map(([name]) => name).join(', ')} (${host.fileName})`);
62
+ }
63
+ if (hostDefaults.length > 0) {
64
+ for (const account of Object.values(catalog.accounts)) {
65
+ account.default = false;
66
+ }
67
+ }
68
+ // a provider an account of the host names is only checked when a secret uses it:
69
+ // a project that doesn't declare it, and reads no secret, loads anyway
70
+ for (const [name, account] of accounts) {
71
+ catalog.accounts[name] = toAccount(name, account, host.fileName);
72
+ }
73
+ }
63
74
  return catalog;
64
75
  }
65
76
  exports.collectSecretCatalog = collectSecretCatalog;
77
+ function toProvider(name, provider, envs, declaredIn) {
78
+ return {
79
+ name,
80
+ type: 'command',
81
+ command: provider.command.map((part) => applyEnvs(part, envs)),
82
+ env: provider.env ?? {},
83
+ timeoutMs: (0, units_1.parseDuration)(provider.timeout ?? DEFAULT_TIMEOUT),
84
+ declaredIn,
85
+ };
86
+ }
87
+ function toAccount(name, account, declaredIn) {
88
+ return { name, default: account.default ?? false, providers: account.providers, declaredIn };
89
+ }
66
90
  // A tag or branch can be moved, and a moved file runs with the credentials of a
67
91
  // service account: providers and accounts from a git include are pinned.
68
92
  function requirePinnedSource(file) {
@@ -23,10 +23,22 @@ const MAX_PUBLISH_BYTES = bundle_1.MAX_BUNDLE_BYTES * 2;
23
23
  * GET /v1/definitions/<hash> one version, with `arguments`: per task null when
24
24
  * a run of it may pass arguments, else why not
25
25
  * GET /v1/definitions/<hash>/files its files
26
+ * POST /v1/definitions/plan what publishing a bundle would do: plans it with this
27
+ * server's policy and stores nothing. For anybody with
28
+ * a `builds` level, `run` too
26
29
  * POST /v1/definitions/<hash>/yank pull it back (body: optional {reason})
27
30
  * POST /v1/definitions/<hash>/unyank let it go on
28
31
  */
29
32
  async function handleDefinitions(config, store, proposals, log, principal, req, res, url) {
33
+ if (url.pathname === '/v1/definitions/plan') {
34
+ if (req.method !== 'POST') {
35
+ (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'POST' });
36
+ }
37
+ else {
38
+ await plan(config, store, log, principal, req, res);
39
+ }
40
+ return true;
41
+ }
30
42
  const match = /^\/v1\/definitions(?:\/([^/]+)(\/files|\/yank|\/unyank)?)?$/.exec(url.pathname);
31
43
  if (!match) {
32
44
  return false;
@@ -124,6 +136,55 @@ async function yank(config, store, log, principal, hash, undo, req, res) {
124
136
  log(`${undo ? 'unyank' : 'yank'} ${hash} iss=${principal.iss} sub=${principal.sub}${body.data.reason ? ` reason=${(0, oauth_1.quote)(body.data.reason)}` : ''}`);
125
137
  (0, http_1.send)(res, 200, changed);
126
138
  }
139
+ // Answers what POST /v1/definitions would: the version with its plan, and whether the
140
+ // caller would publish or propose it. A developer checks a build file against the policy
141
+ // before the account that publishes does. Nothing is stored, nothing is logged but the call.
142
+ async function plan(config, store, log, principal, req, res) {
143
+ const grant = (0, policy_1.getGrant)(config.entitlements, principal);
144
+ if (grant.builds === null) {
145
+ (0, http_1.send)(res, 403, { error: 'you may not check build files, it needs builds: run, propose or publish' });
146
+ return;
147
+ }
148
+ const parsed = bundle_1.bundleSchema.safeParse(await (0, http_1.readJson)(req, MAX_PUBLISH_BYTES));
149
+ if (!parsed.success) {
150
+ const issue = parsed.error.issues[0];
151
+ (0, http_1.send)(res, 400, { error: `${issue.path.join('.') || 'request'}: ${issue.message}` });
152
+ return;
153
+ }
154
+ try {
155
+ // like a proposal: a caller who may not publish does not make the server fetch
156
+ const { version, existing } = await store.prepare(parsed.data, principal, {
157
+ noRemote: (0, policy_1.mayPublish)(grant) ? undefined : 'check',
158
+ });
159
+ const missing = (version.accounts ?? []).filter((account) => !(0, policy_1.canUse)(grant, account));
160
+ const outcome = existing
161
+ ? 'already published'
162
+ : (0, policy_1.mayPublish)(grant) && missing.length === 0
163
+ ? 'published'
164
+ : (0, policy_1.mayPublish)(grant) || (0, policy_1.mayPropose)(grant)
165
+ ? 'proposed'
166
+ : null;
167
+ log(`plan ${version.hash} iss=${principal.iss} sub=${principal.sub} tasks=${version.tasks.length}`);
168
+ (0, http_1.send)(res, 200, {
169
+ hash: version.hash,
170
+ tasks: version.tasks,
171
+ services: version.services,
172
+ plan: version.plan,
173
+ accounts: version.accounts,
174
+ outcome,
175
+ // a yanked version is not checked again: it is the one that was published
176
+ ...(existing?.yanked ? { yanked: true } : {}),
177
+ ...(missing.length > 0 ? { missing } : {}),
178
+ });
179
+ }
180
+ catch (e) {
181
+ if (e instanceof definitions_1.DefinitionError) {
182
+ (0, http_1.send)(res, 400, { error: `the build file does not load: ${e.message}` });
183
+ return;
184
+ }
185
+ throw e;
186
+ }
187
+ }
127
188
  async function publish(config, store, proposals, log, principal, req, res) {
128
189
  const grant = (0, policy_1.getGrant)(config.entitlements, principal);
129
190
  if (!(0, policy_1.mayPublish)(grant) && !(0, policy_1.mayPropose)(grant)) {
@@ -151,7 +212,7 @@ async function publish(config, store, proposals, log, principal, req, res) {
151
212
  // a version can't run before it is approved, but it has to load, as it would
152
213
  // have when published, so the approver is not asked about a broken one
153
214
  const { version, existing } = await store.prepare(parsed.data, principal, {
154
- noRemote: !(0, policy_1.mayPublish)(grant),
215
+ noRemote: (0, policy_1.mayPublish)(grant) ? undefined : 'proposal',
155
216
  annotations,
156
217
  });
157
218
  if (existing) {
@@ -238,6 +238,7 @@ class HostExecutor {
238
238
  }
239
239
  : {}),
240
240
  ...(placed ? { kubernetes: placed.kubernetes } : {}),
241
+ ...(!placed && this.options.capabilityRuntime ? { capabilityRuntime: this.options.capabilityRuntime } : {}),
241
242
  }, cwd, { ...this.processEnvironment(home, cache), ...placed?.env }, context, abortCtrl.signal);
242
243
  }
243
244
  finally {