hammerkit 1.27.0-next.20 → 1.27.0-next.22

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/build.schema.json CHANGED
@@ -296,6 +296,9 @@
296
296
  "type": "string",
297
297
  "minLength": 1
298
298
  },
299
+ "publish": {
300
+ "type": "boolean"
301
+ },
299
302
  "image": {
300
303
  "type": "string"
301
304
  },
@@ -379,6 +382,9 @@
379
382
  "runner": {
380
383
  "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/runner"
381
384
  },
385
+ "publish": {
386
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/publish"
387
+ },
382
388
  "platform": {
383
389
  "type": "object",
384
390
  "properties": {
@@ -520,6 +526,9 @@
520
526
  },
521
527
  "args": {
522
528
  "type": "boolean"
529
+ },
530
+ "publish": {
531
+ "type": "boolean"
523
532
  }
524
533
  },
525
534
  "required": [
@@ -576,6 +585,9 @@
576
585
  "number"
577
586
  ]
578
587
  }
588
+ },
589
+ "publish": {
590
+ "type": "boolean"
579
591
  }
580
592
  },
581
593
  "required": [
@@ -595,6 +607,28 @@
595
607
  {
596
608
  "type": "string"
597
609
  },
610
+ {
611
+ "type": "object",
612
+ "properties": {
613
+ "path": {
614
+ "type": "string",
615
+ "minLength": 1,
616
+ "description": "Build file or directory, relative to the including file"
617
+ },
618
+ "with": {
619
+ "$ref": "#/definitions/hammerkit/properties/envs",
620
+ "description": "Environment values for the current build task\nhttps://hammerkit.dev/docs/build-file/environment-variables"
621
+ },
622
+ "publish": {
623
+ "type": "boolean",
624
+ "description": "false: a server leaves out what this file declares"
625
+ }
626
+ },
627
+ "required": [
628
+ "path"
629
+ ],
630
+ "additionalProperties": false
631
+ },
598
632
  {
599
633
  "type": "object",
600
634
  "properties": {
@@ -615,6 +649,10 @@
615
649
  "with": {
616
650
  "$ref": "#/definitions/hammerkit/properties/envs",
617
651
  "description": "Environment values for the current build task\nhttps://hammerkit.dev/docs/build-file/environment-variables"
652
+ },
653
+ "publish": {
654
+ "type": "boolean",
655
+ "description": "false: a server leaves out what this file declares"
618
656
  }
619
657
  },
620
658
  "required": [
@@ -9,6 +9,7 @@ const work_task_1 = require("../planner/work-task");
9
9
  const work_service_1 = require("../planner/work-service");
10
10
  const get_container_binds_1 = require("./get-container-binds");
11
11
  const assert_item_paths_1 = require("../planner/utils/assert-item-paths");
12
+ const log_1 = require("../log");
12
13
  // The files a server run works on: its checkout, or a sandbox. Somebody else wrote them.
13
14
  function hostedRoot(environment) {
14
15
  return environment.hosted ? environment.cwd : undefined;
@@ -66,10 +67,27 @@ async function prepareMounts(item, environment) {
66
67
  continue;
67
68
  }
68
69
  if ((0, path_1.extname)(mount.localPath)) {
69
- await environment.file.writeFile(mount.localPath, '');
70
+ try {
71
+ await environment.file.writeFile(mount.localPath, '');
72
+ }
73
+ catch (e) {
74
+ // Docker would make a directory there
75
+ throw new Error(`mount ${mount.localPath} does not exist and can't be created (${(0, log_1.getErrorMessage)(e)})`);
76
+ }
70
77
  }
71
78
  else {
72
- await environment.file.createDirectory(mount.localPath);
79
+ try {
80
+ await environment.file.createDirectory(mount.localPath);
81
+ }
82
+ catch (e) {
83
+ // on a server a root-owned directory in the run's files could break its cleanup
84
+ if (environment.hosted) {
85
+ throw e;
86
+ }
87
+ // hammerkit may run in a container whose file system is not the one of the Docker
88
+ // host: Docker creates the missing directory of a bind on its own
89
+ item.status.write('warn', `can't create ${mount.localPath} (${(0, log_1.getErrorMessage)(e)}), Docker creates it on its host as root`);
90
+ }
73
91
  }
74
92
  }
75
93
  if ((0, work_task_1.isContainerWorkTask)(item.data)) {
@@ -5,6 +5,10 @@ const state_1 = require("./state");
5
5
  function resetWorkTree(workTree, type) {
6
6
  return {
7
7
  tasks: Object.entries(workTree.tasks).reduce((tasks, [key, task]) => {
8
+ // down only stops services: a task it selects names the services it needs
9
+ if (type === 'down') {
10
+ return tasks;
11
+ }
8
12
  task.state = new state_1.State({
9
13
  type: 'pending',
10
14
  stateKey: null,
package/dist/program.js CHANGED
@@ -111,6 +111,18 @@ function getListJson(tasks, services) {
111
111
  })),
112
112
  };
113
113
  }
114
+ // `up` and `down` select by name or by label, not both: a name outside the labels would
115
+ // silently select nothing
116
+ function parseWorkNameScope(names, options) {
117
+ const scope = parseWorkLabelScope(options);
118
+ if (names.length === 0) {
119
+ return scope;
120
+ }
121
+ if (scope.filterLabels || scope.excludeLabels) {
122
+ throw new Error('name tasks and services or select them with --filter/--exclude, not both');
123
+ }
124
+ return { ...scope, names };
125
+ }
114
126
  function parseWorkLabelScope(options) {
115
127
  const scope = {
116
128
  environmentName: null,
@@ -587,9 +599,9 @@ async function getProgram(environment, argv) {
587
599
  }
588
600
  });
589
601
  program
590
- .command('up')
591
- .description("start services(s), with the arguments after -- appended to the selected service's cmd")
592
- .usage('[options] [-- args...]')
602
+ .command('up [names...]')
603
+ .description("start services(s), all or the named tasks and services, with the arguments after -- appended to the selected service's cmd")
604
+ .usage('[options] [names...] [-- args...]')
593
605
  .addOption(new commander_1.Option('-f, --filter <labels...>', 'filter task and services with labels'))
594
606
  .addOption(new commander_1.Option('-e, --exclude <labels...>', 'exclude task and services with labels'))
595
607
  .addOption(new commander_1.Option('-c, --concurrency <number>', 'parallel worker count (default 4, unbounded when every task and service requests cpus and memory)').argParser(parseInt))
@@ -610,10 +622,10 @@ async function getProgram(environment, argv) {
610
622
  .addOption(new commander_1.Option('--timeout <duration>', 'fail tasks without their own timeout after this long (e.g. 10m)').argParser(units_1.parseDuration))
611
623
  .addOption(new commander_1.Option('--max-timeout <duration>', 'fail any task after this long, also one with a longer timeout of its own (e.g. 2m)').argParser(units_1.parseDuration))
612
624
  .addOption(new commander_1.Option('--network <level>', 'the most any task or service reaches besides its needs: none, open or allow:<host>,<host>').argParser(parseNetworkArgument))
613
- .action(async (options, command) => {
625
+ .action(async (names, options, command) => {
614
626
  try {
615
627
  const scope = {
616
- ...parseWorkLabelScope(options),
628
+ ...parseWorkNameScope(names, options),
617
629
  args: { values: passthrough, services: true, cache: keepsCache(command) },
618
630
  };
619
631
  const cli = await createCli(fileName, environment, scope);
@@ -646,14 +658,14 @@ async function getProgram(environment, argv) {
646
658
  }
647
659
  });
648
660
  program
649
- .command('down')
650
- .description('stop services(s)')
661
+ .command('down [names...]')
662
+ .description('stop services(s), all or the named services and the services the named tasks need')
651
663
  .addOption(new commander_1.Option('-f, --filter <labels...>', 'filter task and services with labels'))
652
664
  .addOption(new commander_1.Option('-e, --exclude <labels...>', 'exclude task and services with labels'))
653
665
  .addOption(new commander_1.Option('--env <name>', 'environment'))
654
- .action(async (options) => {
666
+ .action(async (names, options) => {
655
667
  try {
656
- const scope = parseWorkLabelScope(options);
668
+ const scope = { ...parseWorkNameScope(names, options), stop: true };
657
669
  const cli = await createCli(fileName, environment, scope);
658
670
  const result = await cli.runDown();
659
671
  if (!result.success) {
@@ -5,6 +5,7 @@ const path_1 = require("path");
5
5
  const zod_1 = require("zod");
6
6
  const yaml_1 = require("yaml");
7
7
  const get_cache_directory_1 = require("../optimizer/get-cache-directory");
8
+ const build_file_secret_provider_schema_1 = require("../schema/build-file-secret-provider-schema");
8
9
  // Names with a meaning of their own in `run.on` / `--on`
9
10
  exports.RESERVED_SERVER_NAMES = ['local', 'auto'];
10
11
  const serverNamePattern = /^[a-z0-9][a-z0-9._-]*$/i;
@@ -43,6 +44,10 @@ exports.hostConfigSchema = zod_1.z
43
44
  servers: zod_1.z.record(exports.hostServerSchema).default({}),
44
45
  tokenCommands: zod_1.z.record(tokenCommandSchema).optional(),
45
46
  run: zod_1.z.object({ on: zod_1.z.string().optional() }).passthrough().default({}),
47
+ // how this machine reads secrets, beside what the build files declare (see
48
+ // collectSecretCatalog); a server reads only its own config
49
+ secretProviders: build_file_secret_provider_schema_1.buildFileSecretProvidersSchema.optional(),
50
+ secretAccounts: build_file_secret_provider_schema_1.buildFileSecretAccountsSchema.optional(),
46
51
  })
47
52
  .passthrough();
48
53
  // Names like "constructor" must not find inherited members.
@@ -17,6 +17,22 @@ const units_1 = require("../utils/units");
17
17
  const service_tools_1 = require("../harness/service-tools");
18
18
  const path_1 = require("path");
19
19
  const check_keys_1 = require("../optimizer/check-keys");
20
+ function describeOutcome(planned) {
21
+ switch (planned.outcome) {
22
+ case 'published':
23
+ return 'publishing it would publish it';
24
+ case 'already published':
25
+ return planned.yanked ? 'it is published already, and yanked' : 'it is published already';
26
+ case 'proposed':
27
+ return planned.missing?.length
28
+ ? `publishing it would propose it, it names accounts you do not use: ${planned.missing
29
+ .map(server_client_1.printable)
30
+ .join(', ')}`
31
+ : 'publishing it would propose it, somebody who may publish approves it';
32
+ default:
33
+ return 'the server accepts it, publishing it needs builds: propose or publish';
34
+ }
35
+ }
20
36
  const RUN_ID = /^run_[A-Za-z0-9_-]{16}$/;
21
37
  // how a check passed, one line
22
38
  function describePassed(passed) {
@@ -136,8 +152,24 @@ function registerWorkCommands(remote, context) {
136
152
  .command('publish [server]')
137
153
  .description('publish the build file and the files it includes to a server, runs refer to it by its hash')
138
154
  .addOption(new commander_1.Option('--annotation <key=value>', 'say more about where it comes from, or leave one out with key=; path, repo, commit, machine and user are filled in').argParser(collectOption))
155
+ .addOption(new commander_1.Option('--dry-run', "check it against the server's policy and say what publishing would do, without publishing; builds: run is enough"))
139
156
  .action(guarded('Remote publish was not successful', async (name, options) => {
140
157
  const bundle = await (0, collect_bundle_1.collectBundle)(buildFile, environment);
158
+ if (options.dryRun) {
159
+ const { name: serverName, server, token } = await connect(name);
160
+ const answer = await (0, server_client_1.callServer)(server, token, 'POST', '/v1/definitions/plan', bundle).catch((e) => {
161
+ throw e instanceof server_client_1.ServerError && e.status === 404
162
+ ? new Error(`${serverName} can't check a build file without publishing it, it needs hammerkit 1.27`)
163
+ : e;
164
+ });
165
+ const planned = answer.body;
166
+ const files = Object.keys(bundle.files).length;
167
+ environment.stdout.write(`checked with the policy of ${serverName}: ${(0, server_client_1.printable)(planned.hash)} (${files} ${files === 1 ? 'file' : 'files'}), nothing was published\n`);
168
+ const names = [...(planned.tasks ?? []), ...(planned.services ?? [])];
169
+ environment.stdout.write(`tasks and services: ${names.map(server_client_1.printable).join(', ') || 'none'}\n`);
170
+ environment.stdout.write(`${describeOutcome(planned)}\n`);
171
+ return;
172
+ }
141
173
  const annotations = (0, annotations_1.applyAnnotations)(await (0, annotations_1.collectAnnotations)(buildFile, Object.keys(bundle.files)), options.annotation ?? []);
142
174
  const { name: serverName, server, token } = await connect(name);
143
175
  const answer = await (0, server_client_1.callServer)(server, token, 'POST', '/v1/definitions', {
@@ -49,5 +49,8 @@ exports.buildFileBaseTaskSchema = (0, zod_1.object)({
49
49
  // Name of the server (see `hammerkit remote`) the task has to run on; defaults
50
50
  // to the build file's `runner`. Never a local fallback when it is unknown.
51
51
  runner: runner_schema_1.runnerSchema.optional(),
52
+ // false: a server leaves it out of a published version, for a task only this machine
53
+ // runs (a dev watcher, a task that mounts the docker socket)
54
+ publish: (0, zod_1.boolean)().optional(),
52
55
  });
53
56
  //# sourceMappingURL=build-file-base-task-schema.js.map
@@ -47,6 +47,8 @@ exports.buildFileContainerServiceSchema = (0, zod_1.object)({
47
47
  network: network_schema_1.networkSchema.optional(),
48
48
  // whether its `cmd` takes the arguments after `--` of `up`: false refuses them
49
49
  args: (0, zod_1.boolean)().optional(),
50
+ // false: a server leaves it out of a published version
51
+ publish: (0, zod_1.boolean)().optional(),
50
52
  })
51
53
  .strict()
52
54
  .describe('container service');
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isGitSource = exports.buildFileIncludeSchema = exports.buildFileGitSourceSchema = void 0;
3
+ exports.isPublished = exports.isGitSource = exports.buildFileIncludeSchema = exports.buildFileLocalSourceSchema = exports.buildFileGitSourceSchema = void 0;
4
4
  const zod_1 = require("zod");
5
5
  const envs_schema_1 = require("./envs-schema");
6
6
  // A repo URL or ref starting with `-` would be read by git as an option
@@ -21,11 +21,25 @@ exports.buildFileGitSourceSchema = (0, zod_1.object)({
21
21
  path: (0, zod_1.string)().optional().describe('Build file or directory within the repository'),
22
22
  // overrides for the top-level `envs` the included file declares as its inputs
23
23
  with: envs_schema_1.envsSchema.optional(),
24
+ publish: (0, zod_1.boolean)().optional().describe('false: a server leaves out what this file declares'),
24
25
  }).strict();
25
- // A string is a local path; an object is a git source (ADR-0004).
26
- exports.buildFileIncludeSchema = (0, zod_1.union)([(0, zod_1.string)(), exports.buildFileGitSourceSchema]);
26
+ // A local path, with values for the inputs of the file it names like `with` of a git
27
+ // source (a value may pass on an `envs` value of the including file as `${NAME}`), and
28
+ // whether a server publishes what it declares.
29
+ exports.buildFileLocalSourceSchema = (0, zod_1.object)({
30
+ path: (0, zod_1.string)().min(1).describe('Build file or directory, relative to the including file'),
31
+ with: envs_schema_1.envsSchema.optional(),
32
+ publish: (0, zod_1.boolean)().optional().describe('false: a server leaves out what this file declares'),
33
+ }).strict();
34
+ // A string is a local path; an object is a local path with options, or a git source (ADR-0004).
35
+ exports.buildFileIncludeSchema = (0, zod_1.union)([(0, zod_1.string)(), exports.buildFileLocalSourceSchema, exports.buildFileGitSourceSchema]);
27
36
  function isGitSource(include) {
28
- return typeof include !== 'string';
37
+ return typeof include !== 'string' && 'git' in include;
29
38
  }
30
39
  exports.isGitSource = isGitSource;
40
+ // `publish: false`: what the file declares is left out of a version a server publishes
41
+ function isPublished(include) {
42
+ return typeof include === 'string' || include.publish !== false;
43
+ }
44
+ exports.isPublished = isPublished;
31
45
  //# sourceMappingURL=build-file-include-schema.js.map
@@ -13,5 +13,7 @@ exports.buildFileKubernetesServiceSchema = (0, zod_1.object)({
13
13
  selector: build_file_kubernetes_service_selector_schema_1.buildFileKubernetesServiceSelectorSchema,
14
14
  labels: labels_schema_1.labelsSchema.optional(),
15
15
  ports: (0, zod_1.array)((0, zod_1.union)([(0, zod_1.string)(), (0, zod_1.number)()])),
16
+ // false: a server leaves it out of a published version
17
+ publish: (0, zod_1.boolean)().optional(),
16
18
  }).strict();
17
19
  //# sourceMappingURL=build-file-kubernetes-service-schema.js.map
@@ -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;
61
59
  }
60
+ if ((0, build_file_include_schema_1.isGitSource)(include)) {
61
+ ctx.checkGitSource?.(include, scope, block, name);
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) {
@@ -89,13 +108,45 @@ async function locateInclude(ctx, scope, include, base, environment) {
89
108
  if (!(0, resolve_git_source_1.isWithin)(remote.root, path)) {
90
109
  throw new Error(`${include.path} in ${include.git} points outside the repository`);
91
110
  }
92
- return { fileName: await (0, default_build_file_1.getBuildFilename)(path, environment), remote, inputs: include.with };
111
+ return { fileName: await (0, default_build_file_1.getBuildFilename)(path, environment), remote, inputs: passEnvs(include.with, scope) };
93
112
  }
94
- const path = (0, path_1.join)(base, include);
113
+ const localPath = typeof include === 'string' ? include : include.path;
114
+ const path = (0, path_1.join)(base, localPath);
95
115
  if (scope.remote && !(0, resolve_git_source_1.isWithin)(scope.remote.root, path)) {
96
- throw new Error(`${include} in ${scope.fileName} points outside ${scope.remote.git}`);
116
+ throw new Error(`${localPath} in ${scope.fileName} points outside ${scope.remote.git}`);
117
+ }
118
+ const inputs = typeof include === 'string' ? undefined : passEnvs(include.with, scope);
119
+ return { fileName: await (0, default_build_file_1.getBuildFilename)(path, environment), remote: scope.remote, inputs };
120
+ }
121
+ // `${NAME}` (or `${NAME:-default}`) in a `with` value reads as it does in the including
122
+ // file: the environment or a `.env` file first, then that file's own `envs` value. So one
123
+ // file holds a value such as NODE_VERSION for all it includes, and CI can still set it. A
124
+ // name the file does not declare is left to the included file, which reads it the same way.
125
+ function passEnvs(inputs, scope) {
126
+ if (!inputs) {
127
+ return undefined;
128
+ }
129
+ const envs = scope.schema.envs ?? {};
130
+ const result = {};
131
+ for (const [key, value] of Object.entries(inputs)) {
132
+ result[key] =
133
+ typeof value === 'string'
134
+ ? value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (match, name, fallback) => name in envs ? passEnv(name, `${envs[name]}`, fallback) : match)
135
+ : value;
136
+ }
137
+ return result;
138
+ }
139
+ // The including file's value as an interpolation the included file resolves in the same
140
+ // order. A value built from other variables is passed on as it is written.
141
+ function passEnv(name, value, fallback) {
142
+ const reference = /^\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(value);
143
+ if (reference) {
144
+ return fallback === undefined ? `\${${reference[1]}}` : `\${${reference[1]}:-${fallback}}`;
145
+ }
146
+ if (value.includes('$') || value.includes('}')) {
147
+ return value;
97
148
  }
98
- return { fileName: await (0, default_build_file_1.getBuildFilename)(path, environment), remote: scope.remote };
149
+ return `\${${name}:-${value}}`;
99
150
  }
100
151
  // `includes pull` fetches each source once, however often the build files name it.
101
152
  async function resolveRemote(ctx, source, environment) {
@@ -47,19 +47,27 @@ function getWorkContext(context, scope, environment) {
47
47
  // its init task's need on it, which narrowing follows
48
48
  const tasks = Object.values(context.tasks).map((task) => (0, append_work_task_1.appendWorkTask)(workTree, task.cwd, task, environment, context));
49
49
  const services = Object.values(context.services).map((service) => (0, append_work_service_1.appendWorkService)(workTree, service, environment, context));
50
+ const apply = { servicesAlone: !(0, work_scope_1.isContextTaskFilter)(scope) && !!scope.names && !!scope.stop };
50
51
  const selectedTasks = [];
51
52
  const selectedServices = [];
52
53
  for (const item of tasks) {
53
54
  if (matchesTaskWorkScope(item, scope)) {
54
55
  requested.push(item.name);
55
56
  selectedTasks.push(item.data);
56
- applyTask(filteredWorkTree, item);
57
+ applyTask(filteredWorkTree, item, apply);
57
58
  }
58
59
  }
59
60
  for (const item of services) {
60
61
  if (matchesServiceWorkScope(item.data, scope)) {
61
62
  selectedServices.push(item.data);
62
- applyService(filteredWorkTree, item);
63
+ applyService(filteredWorkTree, item, apply);
64
+ }
65
+ }
66
+ if (!(0, work_scope_1.isContextTaskFilter)(scope) && scope.names) {
67
+ const known = new Set([...tasks.map((task) => task.name), ...services.map((service) => service.data.name)]);
68
+ const unknown = scope.names.filter((name) => !known.has(name));
69
+ if (unknown.length > 0) {
70
+ throw new Error(`no task or service named ${unknown.join(', ')}, see hammerkit ls`);
63
71
  }
64
72
  }
65
73
  if (scope.args) {
@@ -86,31 +94,34 @@ function keepRequirersInScope(workTree) {
86
94
  }
87
95
  }
88
96
  }
89
- function applyTask(workTree, item) {
97
+ function applyTask(workTree, item, options) {
90
98
  if (workTree.tasks[item.name]) {
91
99
  return;
92
100
  }
93
101
  workTree.tasks[item.name] = item;
94
- applyNeedsAndDeps(workTree, item);
102
+ applyNeedsAndDeps(workTree, item, options);
95
103
  }
96
- function applyService(workTree, need) {
104
+ function applyService(workTree, need, options) {
97
105
  const item = (0, append_work_service_1.getPlannedService)(need);
98
106
  if (workTree.services[item.name]) {
99
107
  return;
100
108
  }
101
109
  workTree.services[item.name] = item;
102
- applyNeedsAndDeps(workTree, item);
110
+ if (options.servicesAlone) {
111
+ return;
112
+ }
113
+ applyNeedsAndDeps(workTree, item, options);
103
114
  // a service in the run brings its init task, which runs once it is healthy
104
115
  if (item.data.type === 'container-service' && item.data.init) {
105
- applyTask(workTree, item.data.init.task);
116
+ applyTask(workTree, item.data.init.task, options);
106
117
  }
107
118
  }
108
- function applyNeedsAndDeps(workTree, item) {
119
+ function applyNeedsAndDeps(workTree, item, options) {
109
120
  for (const dep of item.deps) {
110
- applyTask(workTree, dep);
121
+ applyTask(workTree, dep, options);
111
122
  }
112
123
  for (const need of item.needs) {
113
- applyService(workTree, need.service);
124
+ applyService(workTree, need.service, options);
114
125
  }
115
126
  }
116
127
  function matchesTaskWorkScope(task, scope) {
@@ -120,6 +131,9 @@ function matchesTaskWorkScope(task, scope) {
120
131
  if (scope.serviceNames) {
121
132
  return false;
122
133
  }
134
+ if (scope.names) {
135
+ return scope.names.includes(task.name);
136
+ }
123
137
  return (0, label_values_1.appliesToLabels)(task.data.labels, scope);
124
138
  }
125
139
  function matchesServiceWorkScope(service, scope) {
@@ -129,6 +143,9 @@ function matchesServiceWorkScope(service, scope) {
129
143
  if (scope.serviceNames) {
130
144
  return scope.serviceNames.includes(service.name);
131
145
  }
146
+ if (scope.names) {
147
+ return scope.names.includes(service.name);
148
+ }
132
149
  return (0, label_values_1.appliesToLabels)(service.labels, scope);
133
150
  }
134
151
  //# sourceMappingURL=work-scope-parser.js.map
@@ -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) {
@@ -40,50 +40,33 @@ const COMMIT_PATTERN = /^[0-9a-f]{40}([0-9a-f]{24})?$/i;
40
40
  // A hash alone can only name what is inside the bundle, so everything a build
41
41
  // file pulls in from elsewhere has to be pinned: a git include needs a commit
42
42
  // and a remote that is not a path on the server.
43
- function findLooseIncludes(bundle, noRemote = false) {
43
+ function checkGitSource(path, where, source, noRemote) {
44
+ if (noRemote === 'proposal') {
45
+ throw new DefinitionError(`${path}: ${where}: a proposal may not include from git, the server would fetch it before anybody approved it`);
46
+ }
47
+ if (noRemote === 'check') {
48
+ throw new DefinitionError(`${path}: ${where}: checking a build file that includes from git needs builds: publish, the server would fetch it`);
49
+ }
44
50
  const problems = [];
45
- for (const [path, content] of Object.entries(bundle.files)) {
46
- if (!/\.ya?ml$/i.test(path)) {
47
- continue;
48
- }
49
- let document;
50
- try {
51
- document = (0, yaml_1.parse)(content);
52
- }
53
- catch {
54
- continue; // a file that isn't valid yaml is reported when the bundle is parsed
55
- }
56
- for (const block of ['includes', 'references']) {
57
- for (const [name, include] of Object.entries(document?.[block] ?? {})) {
58
- const source = include;
59
- if (!source || typeof source !== 'object' || source.git === undefined) {
60
- continue;
61
- }
62
- if (noRemote) {
63
- problems.push(`${path}: ${block}.${name}: a proposal may not include from git, the server would fetch it before anybody approved it`);
64
- continue;
65
- }
66
- if (typeof source.git !== 'string' || !/^(https:\/\/|ssh:\/\/|git@[\w.-]+:)/.test(source.git)) {
67
- problems.push(`${path}: ${block}.${name}: git has to be an https:// or ssh:// address`);
68
- }
69
- if (typeof source.ref !== 'string' || !COMMIT_PATTERN.test(source.ref)) {
70
- problems.push(`${path}: ${block}.${name}: pin ref to a commit, a branch or tag can move`);
71
- }
72
- }
73
- }
51
+ if (!/^(https:\/\/|ssh:\/\/|git@[\w.-]+:)/.test(source.git)) {
52
+ problems.push(`${path}: ${where}: git has to be an https:// or ssh:// address`);
53
+ }
54
+ if (typeof source.ref !== 'string' || !COMMIT_PATTERN.test(source.ref)) {
55
+ problems.push(`${path}: ${where}: pin ref to a commit, a branch or tag can move`);
56
+ }
57
+ if (problems.length > 0) {
58
+ throw new DefinitionError(problems.join('\n'));
74
59
  }
75
- return problems;
76
60
  }
77
61
  // What a build file may not name on a server: the places it would run or store things
78
62
  // are the operator's. The docker host, a Kubernetes cluster and a remote cache are
79
63
  // credentials of the machine, not of the build file, and a project file that could carry
80
64
  // them could send runs, or the cache, anywhere.
81
- function findHostProblems(bundle) {
65
+ // A file a server leaves out (`publish: false`) is not checked, and neither is a service
66
+ // with `publish: false`: what is left out is never run there.
67
+ function findHostProblems(files) {
82
68
  const problems = [];
83
- for (const [path, content] of Object.entries(bundle.files)) {
84
- if (!/\.ya?ml$/i.test(path)) {
85
- continue;
86
- }
69
+ for (const [path, content] of Object.entries(files)) {
87
70
  let document;
88
71
  try {
89
72
  document = (0, yaml_1.parse)(content);
@@ -105,6 +88,7 @@ function findHostProblems(bundle) {
105
88
  for (const [name, service] of Object.entries(document.services ?? {})) {
106
89
  if (service &&
107
90
  typeof service === 'object' &&
91
+ service.publish !== false &&
108
92
  ('selector' in service || 'kubeconfig' in service || 'context' in service)) {
109
93
  problems.push(`${path}: services.${name}: Kubernetes services are not supported on a server`);
110
94
  }
@@ -112,6 +96,17 @@ function findHostProblems(bundle) {
112
96
  }
113
97
  return problems;
114
98
  }
99
+ // The files of the bundle a server publishes, see getPublishedFiles
100
+ function publishedBundleFiles(bundle, directory, entry) {
101
+ const files = {};
102
+ for (const scope of (0, reference_parser_1.getPublishedFiles)(entry)) {
103
+ const path = (0, path_1.relative)(directory, scope.fileName).split(path_1.sep).join('/');
104
+ if (!scope.remote && path in bundle.files) {
105
+ files[path] = bundle.files[path];
106
+ }
107
+ }
108
+ return files;
109
+ }
115
110
  // What a build file may read while it is inspected: its own files. The rest of
116
111
  // the server is not its business (a missing file is "not there", not an error).
117
112
  function confined(files, root) {
@@ -342,10 +337,6 @@ function describeFailure(e) {
342
337
  // when somebody wants to run it. The same function answers what a run will do,
343
338
  // with the scope of the run: which tasks, services and accounts it involves.
344
339
  async function planBundle(bundle, environment, options = {}) {
345
- const problems = [...findLooseIncludes(bundle, options.noRemote), ...(options.policy ? findHostProblems(bundle) : [])];
346
- if (problems.length > 0) {
347
- throw new DefinitionError(problems.join('\n'));
348
- }
349
340
  const directory = await (0, promises_1.mkdtemp)((0, path_1.join)((0, os_1.tmpdir)(), 'hammerkit-definition-'));
350
341
  try {
351
342
  for (const [path, content] of Object.entries(bundle.files)) {
@@ -358,7 +349,20 @@ async function planBundle(bundle, environment, options = {}) {
358
349
  file: confined((0, get_file_context_1.getFileContext)(directory), directory),
359
350
  };
360
351
  try {
361
- const { ctx, scope } = await (0, schema_parser_1.createParseContext)((0, path_1.join)(directory, bundle.entry), inspected);
352
+ const { ctx, scope } = await (0, schema_parser_1.createParseContext)((0, path_1.join)(directory, bundle.entry), inspected, {
353
+ // a hash alone can only name what is inside the bundle, see checkGitSource
354
+ checkGitSource: (source, from, block, name) => checkGitSource(
355
+ // a file of a git include is named by its repository
356
+ from.remote
357
+ ? `${from.remote.git} ${(0, path_1.relative)(from.remote.root, from.fileName).split(path_1.sep).join('/')}`
358
+ : (0, path_1.relative)(directory, from.fileName), `${block}.${name}`, source, options.noRemote),
359
+ });
360
+ if (options.policy) {
361
+ const problems = findHostProblems(publishedBundleFiles(bundle, directory, scope));
362
+ if (problems.length > 0) {
363
+ throw new DefinitionError(problems.join('\n'));
364
+ }
365
+ }
362
366
  const context = await (0, reference_parser_1.parseReferences)(ctx, scope, inspected);
363
367
  const workTree = (0, work_scope_parser_1.getWorkContext)(context, options.scope ?? { environmentName: null }, inspected);
364
368
  const items = itemsOf(workTree);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hammerkit",
3
3
  "description": "Containerized build tool with incremental caching that runs the same build file locally, in Docker and on Kubernetes.",
4
- "version": "1.27.0-next.20",
4
+ "version": "1.27.0-next.22",
5
5
  "keywords": [
6
6
  "build",
7
7
  "build-tool",