hammerkit 1.25.0 → 1.27.0-next.1

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.
Files changed (123) hide show
  1. package/build.schema.json +53 -1
  2. package/dist/cache/backends/local-cache-backend.js +10 -2
  3. package/dist/cache/explain.js +13 -11
  4. package/dist/cli.js +3 -0
  5. package/dist/docker/item-network.js +471 -0
  6. package/dist/docker/pull.js +4 -2
  7. package/dist/docker/remove-orphaned-containers.js +13 -6
  8. package/dist/environment/replace-env-variables.js +8 -6
  9. package/dist/executer/await-completed-dependencies.js +2 -1
  10. package/dist/executer/check-readiness.js +2 -2
  11. package/dist/executer/container-task-state.js +8 -6
  12. package/dist/executer/docker-service.js +24 -13
  13. package/dist/executer/docker-task.js +18 -29
  14. package/dist/executer/event-cache.js +4 -0
  15. package/dist/executer/execute-command.js +18 -5
  16. package/dist/executer/execute-docker.js +4 -1
  17. package/dist/executer/execution-steps.js +33 -7
  18. package/dist/executer/get-docker-executor.js +14 -7
  19. package/dist/executer/scheduler/enqueue-next.js +17 -3
  20. package/dist/executer/watch-loop.js +2 -2
  21. package/dist/executer/watch-state-key.js +1 -1
  22. package/dist/file/within-root.js +42 -0
  23. package/dist/harness/connect.js +99 -0
  24. package/dist/harness/format.js +73 -0
  25. package/dist/harness/mcp-command.js +59 -0
  26. package/dist/harness/mcp.js +432 -0
  27. package/dist/harness/opencode.js +492 -0
  28. package/dist/harness/pi.js +217 -0
  29. package/dist/harness/sandbox-client.js +373 -0
  30. package/dist/index.js +33 -36
  31. package/dist/kubernetes/apply.js +31 -1
  32. package/dist/kubernetes/await-running-state.js +33 -1
  33. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +44 -22
  34. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +131 -4
  35. package/dist/kubernetes/ensure-kubernetes-service-exists.js +25 -4
  36. package/dist/kubernetes/ensure-persistent-data.js +48 -7
  37. package/dist/kubernetes/get-service-ip.js +15 -1
  38. package/dist/kubernetes/job-placement.js +99 -0
  39. package/dist/kubernetes/job-secrets.js +306 -0
  40. package/dist/kubernetes/kept-claims.js +191 -0
  41. package/dist/kubernetes/kubernetes-instance.js +10 -2
  42. package/dist/kubernetes/network-gate.js +71 -0
  43. package/dist/kubernetes/network-policy.js +526 -0
  44. package/dist/kubernetes/resources.js +13 -2
  45. package/dist/kubernetes/restore-kubernetes-data.js +19 -4
  46. package/dist/kubernetes/secrets.js +2 -0
  47. package/dist/kubernetes/store-kubernetes-data.js +1 -1
  48. package/dist/kubernetes/volumes.js +100 -11
  49. package/dist/main.js +41 -0
  50. package/dist/network/egress-proxy.js +368 -0
  51. package/dist/network/port-relay.js +35 -0
  52. package/dist/optimizer/get-work-cache-stats.js +16 -0
  53. package/dist/optimizer/work-task-cache-description.js +3 -2
  54. package/dist/planner/service-definition.js +8 -2
  55. package/dist/planner/utils/append-work-environment.js +2 -0
  56. package/dist/planner/utils/append-work-service.js +4 -0
  57. package/dist/planner/utils/append-work-task.js +17 -1
  58. package/dist/planner/utils/assert-item-paths.js +50 -0
  59. package/dist/planner/utils/hosted-volumes.js +35 -0
  60. package/dist/planner/utils/task-arguments.js +155 -0
  61. package/dist/planner/work-cache-id.js +15 -1
  62. package/dist/planner/work-network.js +193 -0
  63. package/dist/planner/work-runtime-docker.js +43 -0
  64. package/dist/planner/work-runtime-kubernetes.js +336 -102
  65. package/dist/planner/work-runtime-local.js +16 -8
  66. package/dist/planner/work-service.js +7 -3
  67. package/dist/planner/work-state-record.js +30 -0
  68. package/dist/program.js +80 -7
  69. package/dist/remote/annotations.js +103 -0
  70. package/dist/remote/bundle-text.js +24 -0
  71. package/dist/remote/bundle.js +9 -1
  72. package/dist/remote/remote-commands.js +4 -1
  73. package/dist/remote/remote-work-commands.js +182 -69
  74. package/dist/remote/server-client.js +18 -5
  75. package/dist/remote/source-manifest.js +15 -2
  76. package/dist/remote/token-source.js +67 -0
  77. package/dist/schema/build-file-base-task-schema.js +3 -0
  78. package/dist/schema/build-file-container-service-schema.js +6 -0
  79. package/dist/schema/build-file-container-task-schema.js +3 -0
  80. package/dist/schema/build-file-environment-schema.js +9 -0
  81. package/dist/schema/network-schema.js +78 -0
  82. package/dist/schema/reference-parser.js +1 -0
  83. package/dist/schema/schema-parser.js +16 -3
  84. package/dist/schema/work-scope-parser.js +13 -0
  85. package/dist/secrets/command-secret-provider.js +73 -10
  86. package/dist/secrets/github-app-token-command.js +23 -0
  87. package/dist/secrets/github-app-token.js +191 -0
  88. package/dist/server/accounts-api.js +9 -2
  89. package/dist/server/definitions-api.js +69 -47
  90. package/dist/server/definitions.js +76 -51
  91. package/dist/server/executor.js +156 -41
  92. package/dist/server/git-checkout.js +72 -8
  93. package/dist/server/kubernetes-backend.js +554 -0
  94. package/dist/server/kubernetes-sandbox-storage.js +996 -0
  95. package/dist/server/oauth.js +1 -0
  96. package/dist/server/plan.js +51 -1
  97. package/dist/server/policy.js +104 -15
  98. package/dist/server/proposals-api.js +24 -40
  99. package/dist/server/proposals.js +12 -25
  100. package/dist/server/repo-access.js +37 -6
  101. package/dist/server/run-logs.js +217 -0
  102. package/dist/server/run-plan.js +51 -2
  103. package/dist/server/run-request.js +23 -10
  104. package/dist/server/run-store.js +5 -0
  105. package/dist/server/run-worker-command.js +13 -1
  106. package/dist/server/run-worker.js +121 -24
  107. package/dist/server/runs.js +184 -10
  108. package/dist/server/sandbox-docker.js +62 -0
  109. package/dist/server/sandbox-files-api.js +269 -0
  110. package/dist/server/sandbox-files.js +1054 -0
  111. package/dist/server/sandbox-patch.js +481 -0
  112. package/dist/server/sandbox-pod-command.js +15 -0
  113. package/dist/server/sandbox-pod.js +146 -0
  114. package/dist/server/sandbox-storage.js +462 -0
  115. package/dist/server/sandboxes-api.js +210 -0
  116. package/dist/server/sandboxes.js +519 -0
  117. package/dist/server/server-config.js +412 -22
  118. package/dist/server/server.js +204 -39
  119. package/dist/utils/run-id.js +12 -0
  120. package/harness/opencode.ts +11 -0
  121. package/harness/pi.ts +7 -0
  122. package/package.json +8 -2
  123. package/dist/remote/text-diff.js +0 -89
@@ -4,6 +4,8 @@ exports.getServiceDefinitionHash = void 0;
4
4
  const crypto_1 = require("crypto");
5
5
  const work_secret_1 = require("./work-secret");
6
6
  const work_service_1 = require("./work-service");
7
+ const work_network_1 = require("./work-network");
8
+ const work_cache_id_1 = require("./work-cache-id");
7
9
  // Everything a running container of the service was created from, plus the
8
10
  // definitions of the services it needs: a dependent links to the container it
9
11
  // started with, so a recreated need means a recreated dependent too. Stored as
@@ -11,6 +13,7 @@ const work_service_1 = require("./work-service");
11
13
  // service whose definition changed instead of silently reusing it.
12
14
  function getServiceDefinitionHash(item) {
13
15
  const service = item.data;
16
+ const network = (0, work_network_1.getEffectiveNetwork)(service);
14
17
  const definition = {
15
18
  image: service.image,
16
19
  cmd: service.cmd?.cmd ?? null,
@@ -30,13 +33,16 @@ function getServiceDefinitionHash(item) {
30
33
  ? `provider:${(0, work_secret_1.getSecretName)(s)}:${(0, work_secret_1.describeSecretSource)(s)}`
31
34
  : `${s.source.type}:${(0, work_secret_1.getSecretName)(s)}`),
32
35
  // the init task's id covers its image, commands, envs and mounts: a changed
33
- // init recreates the service, so it runs again
34
- init: service.init ? service.init.task.id() : null,
36
+ // init recreates the service, so it runs again (arguments after -- don't)
37
+ init: service.init ? (0, work_cache_id_1.getDeclaredWorkTaskId)(service.init.task) : null,
35
38
  needs: item.needs
36
39
  .filter((need) => (0, work_service_1.isContainerWorkService)(need.service.data))
37
40
  .map((need) => `${need.name}=${getServiceDefinitionHash(need.service)}`),
38
41
  // only when declared, so a service without keeps its hash
39
42
  ...(service.resources ? { resources: service.resources } : {}),
43
+ // in this run, under its `--network` ceiling: a service started open is
44
+ // recreated for a run that limits it
45
+ ...((0, work_network_1.isLimitedNetwork)(network) ? { network } : {}),
40
46
  };
41
47
  return (0, crypto_1.createHash)('sha1').update(JSON.stringify(definition)).digest('hex');
42
48
  }
@@ -15,6 +15,8 @@ function appendWorkEnvironment(referenced) {
15
15
  kubeConfig: referenced.schema.kubernetes.kubeconfig,
16
16
  httpRoutes: referenced.schema.kubernetes.httpRoutes || [],
17
17
  namespace: referenced.schema.kubernetes.namespace ?? 'default',
18
+ networkPolicyEnforced: referenced.schema.kubernetes.networkPolicy === 'enforced',
19
+ ...(referenced.schema.kubernetes.volumeSize ? { volumeSize: referenced.schema.kubernetes.volumeSize } : {}),
18
20
  };
19
21
  }
20
22
  }
@@ -23,6 +23,7 @@ const resolve_cache_1 = require("../../cache/resolve-cache");
23
23
  const units_1 = require("../../utils/units");
24
24
  const log_1 = require("../../log");
25
25
  const work_resources_1 = require("../work-resources");
26
+ const work_network_1 = require("../work-network");
26
27
  function appendWorkService(workTree, service, environment, context) {
27
28
  const workService = parseService(workTree, service, environment, context);
28
29
  if (!workTree.services[workService.name]) {
@@ -116,6 +117,9 @@ function parseService(workTree, service, environment, context) {
116
117
  init: null,
117
118
  secrets: (0, work_secret_1.parseWorkSecrets)(service.cwd, context.projectRoot, service.schema.secrets, envs, environment, context.secrets, service.schema.account),
118
119
  resources: (0, work_resources_1.parseWorkResources)(service.schema.resources),
120
+ volumeSize: service.schema.volumeSize ?? null,
121
+ network: (0, work_network_1.parseWorkNetwork)(service.schema.network),
122
+ networkCeiling: null,
119
123
  };
120
124
  }
121
125
  }
@@ -19,8 +19,17 @@ const get_work_runtime_1 = require("./get-work-runtime");
19
19
  const resolve_cache_1 = require("../../cache/resolve-cache");
20
20
  const units_1 = require("../../utils/units");
21
21
  const work_resources_1 = require("../work-resources");
22
+ const job_placement_1 = require("../../kubernetes/job-placement");
23
+ const work_network_1 = require("../work-network");
22
24
  function appendWorkTask(workTree, cwd, referenceTask, environment, context) {
23
- const task = parseTask(cwd, referenceTask, environment, context);
25
+ const parsed = parseTask(cwd, referenceTask, environment, context);
26
+ // on a server's cluster a local task runs in a tool image like a container task
27
+ const server = workTree.environment.type === 'kubernetes' ? workTree.environment.server : undefined;
28
+ const task = !server
29
+ ? parsed
30
+ : parsed.type === 'local-task'
31
+ ? (0, job_placement_1.runInToolImage)(parsed, server, referenceTask.schema.platform)
32
+ : (0, job_placement_1.placeOnPool)(parsed, server);
24
33
  if (!workTree.tasks[task.name]) {
25
34
  const workItem = {
26
35
  // A dependency cycle is rejected before anything runs (checkForLoop), but
@@ -72,6 +81,8 @@ function parseTask(cwd, task, environment, context) {
72
81
  timeout: task.schema.timeout ? (0, units_1.parseDuration)(task.schema.timeout) : null,
73
82
  secrets: (0, work_secret_1.parseWorkSecrets)(cwd, context.projectRoot, task.schema.secrets, envs, environment, context.secrets, task.schema.account),
74
83
  resources: (0, work_resources_1.parseWorkResources)(task.schema.resources),
84
+ network: (0, work_network_1.parseWorkNetwork)(task.schema.network),
85
+ networkCeiling: null,
75
86
  runner: task.schema.runner ?? task.scope.schema.runner ?? null,
76
87
  };
77
88
  if ((0, build_file_task_schema_1.isBuildFileContainerTaskSchema)(task.schema)) {
@@ -81,6 +92,7 @@ function parseTask(cwd, task, environment, context) {
81
92
  user: (0, get_container_user_1.getContainerUser)(),
82
93
  image: (0, template_value_1.templateValue)(task.schema.image, envs),
83
94
  mounts: (0, parse_work_mounts_1.parseWorkMounts)(cwd, task.schema, envs),
95
+ volumeSize: task.schema.volumeSize ?? null,
84
96
  };
85
97
  }
86
98
  else {
@@ -88,6 +100,10 @@ function parseTask(cwd, task, environment, context) {
88
100
  if (fileSecret) {
89
101
  throw new Error(`task ${task.relativeName}: a local task takes secrets as env only (${(0, work_secret_1.getSecretName)(fileSecret)})`);
90
102
  }
103
+ if (baseWorkNode.network.type !== 'open') {
104
+ throw new Error(`task ${task.relativeName}: network ${(0, work_network_1.formatNetwork)(baseWorkNode.network)} needs a container, ` +
105
+ `hammerkit can't limit the network of a local task`);
106
+ }
91
107
  return {
92
108
  ...baseWorkNode,
93
109
  type: 'local-task',
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.assertWorkTreePathsWithinRoot = exports.assertItemPathsWithinRoot = void 0;
4
+ const within_root_1 = require("../../file/within-root");
5
+ const work_service_1 = require("../work-service");
6
+ const work_task_1 = require("../work-task");
7
+ const hosted_volumes_1 = require("./hosted-volumes");
8
+ /**
9
+ * Refuses an item whose sources, outputs or mounts leave the root of a run, or go
10
+ * through a symbolic link in it. The server runs build files it did not write, in a
11
+ * checkout it did not write either: a `src: ../..` or a link in the checkout would
12
+ * have the server mount or upload its own files (its credentials, other callers'
13
+ * caches) into a task. Checked when the run is planned and again before each item
14
+ * runs, since an earlier task may have left a link behind.
15
+ */
16
+ async function assertItemPathsWithinRoot(item, root) {
17
+ const data = item.data;
18
+ for (const source of 'src' in data ? data.src : []) {
19
+ if (!source.inherited) {
20
+ await (0, within_root_1.assertWithinRoot)(root, source.absolutePath, `${item.name}: src ${source.source}`);
21
+ }
22
+ }
23
+ if ((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data)) {
24
+ for (const mount of data.mounts) {
25
+ await (0, within_root_1.assertWithinRoot)(root, mount.localPath, `${item.name}: mount ${mount.localPath}`);
26
+ }
27
+ }
28
+ if ((0, work_service_1.isContainerWorkService)(data)) {
29
+ for (const volume of data.volumes) {
30
+ if (!(0, hosted_volumes_1.isVolumeName)(volume.name)) {
31
+ throw new Error(`${item.name}: volume ${volume.name} is not a volume name, a path of the server can't be mounted`);
32
+ }
33
+ }
34
+ }
35
+ if ((0, work_task_1.isWorkTask)(data)) {
36
+ for (const generate of data.generates) {
37
+ if (!generate.inherited) {
38
+ await (0, within_root_1.assertWithinRoot)(root, generate.path, `${item.name}: generates ${generate.path}`);
39
+ }
40
+ }
41
+ }
42
+ }
43
+ exports.assertItemPathsWithinRoot = assertItemPathsWithinRoot;
44
+ async function assertWorkTreePathsWithinRoot(workTree, root) {
45
+ for (const item of [...Object.values(workTree.tasks), ...Object.values(workTree.services)]) {
46
+ await assertItemPathsWithinRoot(item, root);
47
+ }
48
+ }
49
+ exports.assertWorkTreePathsWithinRoot = assertWorkTreePathsWithinRoot;
50
+ //# sourceMappingURL=assert-item-paths.js.map
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.scopeServiceVolumes = exports.isVolumeName = void 0;
4
+ const work_service_1 = require("../work-service");
5
+ // What Docker takes as the name of a volume. Anything else in a service's `volumes` is a
6
+ // path of the host to Docker (`/var/run/docker.sock:/run/docker.sock` binds the socket).
7
+ const VOLUME_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]+$/;
8
+ function isVolumeName(name) {
9
+ return VOLUME_NAME.test(name);
10
+ }
11
+ exports.isVolumeName = isVolumeName;
12
+ /**
13
+ * On a server, the docker volumes of a run's services are its caller's: each name gets the
14
+ * run's scope (its caller and source trust) in front, so a build file can neither open nor,
15
+ * with `resetOnChange`, remove a volume of another caller, of a sandbox or of the host.
16
+ * The outputs a service inherits from its deps keep their names: those are made from paths
17
+ * in the run's own files, and the task that writes them uses the same name. A Kubernetes
18
+ * backend keeps claims in the caller's namespace and is left as it is.
19
+ */
20
+ function scopeServiceVolumes(workTree, scope) {
21
+ const scoped = new Set();
22
+ for (const service of Object.values(workTree.services)) {
23
+ if (!(0, work_service_1.isContainerWorkService)(service.data)) {
24
+ continue;
25
+ }
26
+ for (const volume of service.data.volumes) {
27
+ if (!volume.inherited && !scoped.has(volume)) {
28
+ scoped.add(volume);
29
+ volume.name = `hammerkit-${scope}-${volume.name}`;
30
+ }
31
+ }
32
+ }
33
+ }
34
+ exports.scopeServiceVolumes = scopeServiceVolumes;
35
+ //# sourceMappingURL=hosted-volumes.js.map
@@ -0,0 +1,155 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isRefusedByWindowsPowerShell = exports.applyArguments = exports.getArgumentsTarget = exports.getKubernetesCommandArgs = exports.getKubernetesCommandText = exports.escapeKubernetesText = exports.appendArguments = exports.quoteArgument = exports.getShellQuoting = void 0;
4
+ const os_1 = require("os");
5
+ const POSIX_SHELLS = ['sh', 'bash', 'zsh', 'dash', 'ash', 'ksh', 'mksh'];
6
+ const POWERSHELLS = ['powershell', 'pwsh'];
7
+ // The quoting of a shell, by the name of its program; null for a shell whose
8
+ // quoting isn't known (fish, cmd.exe, …). Guessing would let shell syntax in an
9
+ // argument run as a second command, so such a shell takes no arguments.
10
+ function getShellQuoting(shell) {
11
+ const program = shell.trim().split(/\s+/)[0] ?? '';
12
+ const name = (program.split(/[\\/]/).pop() ?? '').toLowerCase().replace(/\.exe$/, '');
13
+ if (POSIX_SHELLS.includes(name)) {
14
+ return 'posix';
15
+ }
16
+ if (POWERSHELLS.includes(name)) {
17
+ return 'powershell';
18
+ }
19
+ return null;
20
+ }
21
+ exports.getShellQuoting = getShellQuoting;
22
+ // Single quotes take everything literally in both shells. In sh a quote ends the
23
+ // string, so it is closed, escaped and reopened; PowerShell doubles it, and reads
24
+ // the typographic single quotes as quotes too.
25
+ function quoteArgument(arg, quoting) {
26
+ if (quoting === 'powershell') {
27
+ return `'${arg.replace(/['‘’‚‛]/g, (quote) => quote + quote)}'`;
28
+ }
29
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
30
+ }
31
+ exports.quoteArgument = quoteArgument;
32
+ function appendArguments(command, args, quoting) {
33
+ // an exec-form command is shown as sh would read it; it runs from `parsed`
34
+ const display = quoting === 'exec' ? 'posix' : quoting;
35
+ return {
36
+ cwd: command.cwd,
37
+ cmd: [command.cmd, ...args.map((arg) => quoteArgument(arg, display))].join(' '),
38
+ parsed: {
39
+ command: command.parsed.command,
40
+ args: [...command.parsed.args, ...args],
41
+ },
42
+ arguments: { cmd: command.cmd.length, args: command.parsed.args.length },
43
+ };
44
+ }
45
+ exports.appendArguments = appendArguments;
46
+ // Kubernetes expands `$(NAME)` with the container's env and turns `$$` into `$`
47
+ // in a container's command and args, before any shell sees them, so a quoted
48
+ // argument isn't text there unless its `$` is doubled. The part of the command
49
+ // the build file declares keeps its meaning.
50
+ function escapeKubernetesText(text) {
51
+ return text.replace(/\$/g, '$$$$');
52
+ }
53
+ exports.escapeKubernetesText = escapeKubernetesText;
54
+ function getKubernetesCommandText(command) {
55
+ if (!command.arguments) {
56
+ return command.cmd;
57
+ }
58
+ const from = command.arguments.cmd;
59
+ return command.cmd.slice(0, from) + escapeKubernetesText(command.cmd.slice(from));
60
+ }
61
+ exports.getKubernetesCommandText = getKubernetesCommandText;
62
+ function getKubernetesCommandArgs(command) {
63
+ const from = command.arguments?.args ?? command.parsed.args.length;
64
+ return command.parsed.args.map((arg, index) => (index >= from ? escapeKubernetesText(arg) : arg));
65
+ }
66
+ exports.getKubernetesCommandArgs = getKubernetesCommandArgs;
67
+ // The command arguments after `--` land in: the last command of a task, the
68
+ // `cmd` of a container service. Null when there is none.
69
+ function getArgumentsTarget(item) {
70
+ if (item.type === 'local-task' || item.type === 'container-task') {
71
+ return item.cmds[item.cmds.length - 1] ?? null;
72
+ }
73
+ if (item.type === 'container-service') {
74
+ return item.cmd;
75
+ }
76
+ return null;
77
+ }
78
+ exports.getArgumentsTarget = getArgumentsTarget;
79
+ // Appends the arguments to the one task or service a run selects, before
80
+ // anything asks for its id: the last command is part of the task id, so the
81
+ // arguments are part of its cache key. A run with arguments is a one-off (one
82
+ // test, one flag), so the task skips the cache unless `keepCache`; even then the
83
+ // key keeps runs with other arguments apart. Dependencies and needed services
84
+ // are planned items of their own and keep their commands and caching. No
85
+ // arguments change nothing, so a task run without them keeps its cache entries.
86
+ function applyArguments(selection, args, keepCache = false) {
87
+ if (args.length === 0) {
88
+ return;
89
+ }
90
+ const selected = [...selection.tasks, ...selection.services];
91
+ if (selected.length === 0) {
92
+ return;
93
+ }
94
+ if (selected.length > 1) {
95
+ throw new Error(`arguments after -- go to one task or service, but this run selects ${selected.map((s) => s.name).join(', ')}`);
96
+ }
97
+ if (args.some((arg) => arg.includes('\0'))) {
98
+ throw new Error('an argument after -- contains a NUL character');
99
+ }
100
+ const item = selected[0];
101
+ if (item.type === 'kubernetes-service') {
102
+ throw new Error(`service ${item.name} is a kubernetes service and has no cmd for the arguments after --`);
103
+ }
104
+ const target = getArgumentsTarget(item);
105
+ if (!target) {
106
+ throw new Error(item.type === 'container-service'
107
+ ? `service ${item.name} has no cmd for the arguments after --`
108
+ : `task ${item.name} has no command for the arguments after --`);
109
+ }
110
+ const quoting = getItemQuoting(item);
111
+ if (item.type === 'local-task' && quoting === 'powershell') {
112
+ const refused = args.find(isRefusedByWindowsPowerShell);
113
+ if (refused !== undefined) {
114
+ throw new Error(`task ${item.name} runs in Windows PowerShell, which would split or run the argument ${JSON.stringify(refused)}, so it is refused: an empty argument, a ", the characters & | < > ^ % !, line breaks, and a space with a trailing \\`);
115
+ }
116
+ }
117
+ if (item.type === 'container-service') {
118
+ item.cmd = appendArguments(target, args, quoting);
119
+ }
120
+ else {
121
+ item.cmds = [...item.cmds.slice(0, -1), appendArguments(target, args, quoting)];
122
+ if (!keepCache) {
123
+ // not implicit, so the run's --cache default doesn't bring the method back
124
+ item.caching = { ...item.caching, method: 'none', implicit: false };
125
+ }
126
+ }
127
+ }
128
+ exports.applyArguments = applyArguments;
129
+ // Windows PowerShell 5.1 builds a new command line for a native program, and
130
+ // starts a .cmd or .bat shim (yarn.cmd, most of node_modules/.bin) through
131
+ // cmd.exe. There an empty argument is dropped, a " splits or joins arguments,
132
+ // an argument with a space that ends in \ is quoted as "a b\" and so escapes
133
+ // its own closing quote, and cmd reads & | < > ^ % ! and a line break as
134
+ // syntax, so such an argument can't be passed as text.
135
+ function isRefusedByWindowsPowerShell(arg) {
136
+ return arg === '' || /["&|<>^%!\r\n]/.test(arg) || (/\s/.test(arg) && arg.endsWith('\\'));
137
+ }
138
+ exports.isRefusedByWindowsPowerShell = isRefusedByWindowsPowerShell;
139
+ function getItemQuoting(item) {
140
+ if (item.type === 'local-task') {
141
+ // what executeCommand runs a local command in
142
+ return (0, os_1.platform)() === 'win32' ? 'powershell' : 'posix';
143
+ }
144
+ const shell = item.type === 'kubernetes-service' ? null : item.shell;
145
+ if (shell === null) {
146
+ return 'exec';
147
+ }
148
+ const quoting = getShellQuoting(shell);
149
+ if (!quoting) {
150
+ throw new Error(`${item.type === 'container-task' ? 'task' : 'service'} ${item.name} runs in ${shell}, whose quoting hammerkit ` +
151
+ `doesn't know, so it takes no arguments after --; use sh, bash, zsh, dash, ash, ksh, mksh or PowerShell`);
152
+ }
153
+ return quoting;
154
+ }
155
+ //# sourceMappingURL=task-arguments.js.map
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getWorkTaskId = void 0;
3
+ exports.getDeclaredWorkTaskId = exports.getWorkTaskId = void 0;
4
4
  const crypto_1 = require("crypto");
5
5
  const work_task_cache_description_1 = require("../optimizer/work-task-cache-description");
6
6
  function getWorkTaskId(item) {
@@ -9,4 +9,18 @@ function getWorkTaskId(item) {
9
9
  return (0, crypto_1.createHash)('sha1').update(jsonData).digest('hex');
10
10
  }
11
11
  exports.getWorkTaskId = getWorkTaskId;
12
+ // The id of the task as the build file declares it: without the arguments
13
+ // after -- a run appended to its last command. A service's definition takes its
14
+ // init task by this id, so `run <init> -- ...` leaves a running service as it is.
15
+ function getDeclaredWorkTaskId(item) {
16
+ const description = (0, work_task_cache_description_1.getWorkTaskCacheDescription)(item);
17
+ const cmds = description.cmds?.map((cmd, index) => {
18
+ const declared = item.data.cmds[index];
19
+ return declared.arguments ? { ...cmd, cmd: declared.cmd.slice(0, declared.arguments.cmd) } : cmd;
20
+ });
21
+ return (0, crypto_1.createHash)('sha1')
22
+ .update(JSON.stringify({ ...description, cmds }))
23
+ .digest('hex');
24
+ }
25
+ exports.getDeclaredWorkTaskId = getDeclaredWorkTaskId;
12
26
  //# sourceMappingURL=work-cache-id.js.map
@@ -0,0 +1,193 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.applyNetworkCeiling = exports.formatNetwork = exports.getTcpForwards = exports.matchesHost = exports.isLimitedNetwork = exports.toNetworkSchema = exports.exceedsNetwork = exports.getEffectiveNetwork = exports.limitNetwork = exports.parseNetworkOption = exports.parseWorkNetwork = exports.OPEN_NETWORK = void 0;
4
+ const network_schema_1 = require("../schema/network-schema");
5
+ exports.OPEN_NETWORK = { type: 'open' };
6
+ function parseWorkNetwork(schema) {
7
+ if (!schema || schema === 'open') {
8
+ return exports.OPEN_NETWORK;
9
+ }
10
+ if (schema === 'none') {
11
+ return { type: 'none' };
12
+ }
13
+ return { type: 'allow', hosts: normalizeHosts(schema.allow) };
14
+ }
15
+ exports.parseWorkNetwork = parseWorkNetwork;
16
+ // `--network none`, `--network open` or `--network allow:<host>,<host:port>`
17
+ function parseNetworkOption(value) {
18
+ if (value === 'open' || value === 'none') {
19
+ return { type: value };
20
+ }
21
+ if (value.startsWith('allow:')) {
22
+ const hosts = value
23
+ .substring('allow:'.length)
24
+ .split(',')
25
+ .map((host) => host.trim())
26
+ .filter((host) => host.length > 0);
27
+ const invalid = hosts.find((host) => !(0, network_schema_1.isAllowEntry)(host));
28
+ if (invalid) {
29
+ throw new Error(`invalid host "${invalid}", expected a name such as registry.npmjs.org or *.example.com, ` +
30
+ 'or a name and port such as db.example.com:5432');
31
+ }
32
+ const error = (0, network_schema_1.getAllowListError)(hosts);
33
+ if (error) {
34
+ throw new Error(error);
35
+ }
36
+ return { type: 'allow', hosts: normalizeHosts(hosts) };
37
+ }
38
+ throw new Error(`invalid network "${value}", expected none, open or allow:<host>,<host>`);
39
+ }
40
+ exports.parseNetworkOption = parseNetworkOption;
41
+ // The setting under a ceiling: a more open setting is clamped down to it, and
42
+ // an allow list under an allow ceiling keeps the hosts both lists allow. A
43
+ // name one list allows only through a pattern reaches no private address.
44
+ function limitNetwork(network, ceiling) {
45
+ if (!ceiling || ceiling.type === 'open' || network.type === 'none') {
46
+ return network;
47
+ }
48
+ if (ceiling.type === 'none') {
49
+ return ceiling;
50
+ }
51
+ if (network.type === 'open') {
52
+ return ceiling;
53
+ }
54
+ const hosts = [
55
+ ...network.hosts.filter((host) => ceiling.hosts.some((limit) => coversHost(limit, host))),
56
+ ...ceiling.hosts.filter((limit) => network.hosts.some((host) => coversHost(host, limit))),
57
+ ];
58
+ const normalized = normalizeHosts(hosts);
59
+ const publicOnly = normalized.filter((host) => !host.startsWith('*.') && !(coversByName(network, host) && coversByName(ceiling, host)));
60
+ return publicOnly.length > 0 ? { type: 'allow', hosts: normalized, publicOnly } : { type: 'allow', hosts: normalized };
61
+ }
62
+ exports.limitNetwork = limitNetwork;
63
+ // Whether a name (not a pattern) of the list covers `host` and may reach
64
+ // private addresses.
65
+ function coversByName(network, host) {
66
+ return network.hosts.some((entry) => !entry.startsWith('*.') && !network.publicOnly?.includes(entry) && coversHost(entry, host));
67
+ }
68
+ // The network of a task or service in this run: its own setting under the
69
+ // run's ceiling (`--network`).
70
+ function getEffectiveNetwork(item) {
71
+ return limitNetwork(item.network, item.networkCeiling);
72
+ }
73
+ exports.getEffectiveNetwork = getEffectiveNetwork;
74
+ // Whether `network` reaches more than `ceiling` allows: a host it allows that no
75
+ // host of the ceiling covers. Redundant hosts of the ceiling don't count.
76
+ function exceedsNetwork(network, ceiling) {
77
+ if (ceiling.type === 'open' || network.type === 'none') {
78
+ return false;
79
+ }
80
+ if (network.type === 'open') {
81
+ return true;
82
+ }
83
+ if (ceiling.type === 'none') {
84
+ return network.hosts.length > 0;
85
+ }
86
+ return network.hosts.some((host) => !ceiling.hosts.some((limit) => coversHost(limit, host)));
87
+ }
88
+ exports.exceedsNetwork = exceedsNetwork;
89
+ // The setting as a build file writes it.
90
+ function toNetworkSchema(network) {
91
+ return network.type === 'allow' ? { allow: [...network.hosts] } : network.type;
92
+ }
93
+ exports.toNetworkSchema = toNetworkSchema;
94
+ function isLimitedNetwork(network) {
95
+ return network.type !== 'open';
96
+ }
97
+ exports.isLimitedNetwork = isLimitedNetwork;
98
+ // Whether a request to `host` passes `pattern`: the same name, or a name under
99
+ // the domain of a `*.` pattern (the domain itself is not included).
100
+ function matchesHost(pattern, host) {
101
+ const name = host.toLowerCase().replace(/\.$/, '');
102
+ const rule = pattern.toLowerCase();
103
+ return rule.startsWith('*.') ? name.endsWith(rule.substring(1)) : name === rule;
104
+ }
105
+ exports.matchesHost = matchesHost;
106
+ // Whether everything `host` (a name, a `*.` pattern or a name and port)
107
+ // allows, `limit` allows too. A host without a port reaches every port of it
108
+ // through the proxy, so it covers a forward to one of them; a forward covers
109
+ // only itself.
110
+ function coversHost(limit, host) {
111
+ const limitEntry = (0, network_schema_1.parseAllowEntry)(limit);
112
+ const hostEntry = (0, network_schema_1.parseAllowEntry)(host);
113
+ if (!limitEntry || !hostEntry) {
114
+ return false;
115
+ }
116
+ if (limitEntry.port !== null) {
117
+ return hostEntry.port === limitEntry.port && matchesHost(limitEntry.host, hostEntry.host);
118
+ }
119
+ if (hostEntry.host.startsWith('*.')) {
120
+ // `*.b.example.com` is within `*.example.com`
121
+ return (limitEntry.host.startsWith('*.') &&
122
+ hostEntry.host.toLowerCase().endsWith(limitEntry.host.toLowerCase().substring(1)));
123
+ }
124
+ return matchesHost(limitEntry.host, hostEntry.host);
125
+ }
126
+ // The raw TCP forwards of a network: the entries of `allow` with a port.
127
+ function getTcpForwards(network) {
128
+ if (network.type !== 'allow') {
129
+ return [];
130
+ }
131
+ return network.hosts
132
+ .map(network_schema_1.parseAllowEntry)
133
+ .filter((entry) => entry !== null && entry.port !== null);
134
+ }
135
+ exports.getTcpForwards = getTcpForwards;
136
+ function normalizeHosts(hosts) {
137
+ return [...new Set(hosts.map((host) => host.toLowerCase()))].sort();
138
+ }
139
+ function formatNetwork(network) {
140
+ if (network.type === 'allow') {
141
+ return network.hosts.length > 0 ? `allow ${network.hosts.join(', ')}` : 'allow (no hosts)';
142
+ }
143
+ return network.type;
144
+ }
145
+ exports.formatNetwork = formatNetwork;
146
+ // Sets the run's ceiling on every task and service of the work tree. A local
147
+ // task can't be limited, so a ceiling below open with one in the run is an
148
+ // error before anything runs. So is a limited item that needs a service under
149
+ // a name that is no host name: the proxy passes the names of the needed
150
+ // services, and `*.com` would pass every .com host. A TCP forward under the
151
+ // name of a needed service would hide that service, so it is an error too, as
152
+ // are two forwards on one port, which the ceiling can leave from two lists.
153
+ function applyNetworkCeiling(workTree, ceiling) {
154
+ const limit = ceiling && isLimitedNetwork(ceiling) ? ceiling : null;
155
+ if (limit) {
156
+ const local = Object.values(workTree.tasks).find((task) => task.data.type === 'local-task');
157
+ if (local) {
158
+ throw new Error(`--network ${formatNetwork(limit)} needs every task in a container, ` +
159
+ `hammerkit can't limit the network of the local task ${local.name}`);
160
+ }
161
+ }
162
+ for (const task of Object.values(workTree.tasks)) {
163
+ task.data.networkCeiling = limit;
164
+ }
165
+ for (const service of Object.values(workTree.services)) {
166
+ if (service.data.type === 'container-service') {
167
+ service.data.networkCeiling = limit;
168
+ }
169
+ }
170
+ for (const item of [...Object.values(workTree.tasks), ...Object.values(workTree.services)]) {
171
+ if (item.data.type === 'local-task' || item.data.type === 'kubernetes-service') {
172
+ continue;
173
+ }
174
+ const network = getEffectiveNetwork(item.data);
175
+ const invalid = item.needs.find((need) => !(0, network_schema_1.isNeedName)(need.name));
176
+ if (isLimitedNetwork(network) && invalid) {
177
+ throw new Error(`${item.name}: network ${formatNetwork(network)} reaches a needed service by a host name, ` +
178
+ `"${invalid.name}" is none (name it like db or api-v2)`);
179
+ }
180
+ const shared = network.type === 'allow' ? (0, network_schema_1.getAllowListError)(network.hosts) : null;
181
+ if (shared) {
182
+ const under = limit ? ` under --network ${formatNetwork(limit)}` : '';
183
+ throw new Error(`${item.name}: network ${formatNetwork(network)}${under}, ${shared}`);
184
+ }
185
+ const hidden = getTcpForwards(network).find((forward) => item.needs.some((need) => need.name.toLowerCase() === forward.host.toLowerCase()));
186
+ if (hidden) {
187
+ throw new Error(`${item.name}: network ${formatNetwork(network)} forwards ${hidden.host}:${hidden.port}, ` +
188
+ `which is the name of a service it needs`);
189
+ }
190
+ }
191
+ }
192
+ exports.applyNetworkCeiling = applyNetworkCeiling;
193
+ //# sourceMappingURL=work-network.js.map