hammerkit 1.27.0-next.9 → 2.0.0-next.2

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 (122) hide show
  1. package/build.schema.json +176 -6
  2. package/dist/cache/backends/local-cache-backend.js +6 -0
  3. package/dist/cache/backends/registry/registry-client.js +1 -0
  4. package/dist/cache/cache-sync.js +1 -1
  5. package/dist/cache/explain.js +17 -0
  6. package/dist/cli.js +2 -0
  7. package/dist/dashboard/assets/index-CjnKm5-3.css +2 -0
  8. package/dist/dashboard/assets/index-zPXa4klZ.js +13 -0
  9. package/dist/dashboard/favicon.svg +1 -0
  10. package/dist/dashboard/index.html +15 -0
  11. package/dist/docker/item-network.js +13 -5
  12. package/dist/executer/archive-task-entry.js +4 -2
  13. package/dist/executer/deadline.js +17 -3
  14. package/dist/executer/deferred-restore.js +36 -0
  15. package/dist/executer/docker-service.js +30 -8
  16. package/dist/executer/docker-task.js +21 -1
  17. package/dist/executer/execute-work-service.js +2 -0
  18. package/dist/executer/execute-work-task.js +11 -4
  19. package/dist/executer/execution-steps.js +25 -4
  20. package/dist/executer/reset-work-tree.js +4 -0
  21. package/dist/executer/scheduler/enqueue-next.js +57 -6
  22. package/dist/executer/scheduler/state-key.js +42 -5
  23. package/dist/executer/watch-loop.js +6 -3
  24. package/dist/harness/connect.js +20 -6
  25. package/dist/harness/mcp-command.js +22 -6
  26. package/dist/harness/mcp.js +63 -114
  27. package/dist/harness/opencode.js +158 -50
  28. package/dist/harness/pi.js +29 -0
  29. package/dist/harness/sandbox-client.js +194 -23
  30. package/dist/harness/service-tools.js +141 -0
  31. package/dist/harness/task-tools.js +221 -0
  32. package/dist/kubernetes/apply.js +49 -1
  33. package/dist/kubernetes/await-running-state.js +172 -13
  34. package/dist/kubernetes/cluster-state.js +245 -0
  35. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +133 -9
  36. package/dist/kubernetes/ensure-kubernetes-service-exists.js +2 -0
  37. package/dist/kubernetes/ensure-persistent-data.js +211 -6
  38. package/dist/kubernetes/follow-pod-logs.js +182 -0
  39. package/dist/kubernetes/job-placement.js +36 -12
  40. package/dist/kubernetes/kubernetes-instance.js +1 -0
  41. package/dist/kubernetes/network-policy.js +2 -1
  42. package/dist/kubernetes/remove-stale-items.js +46 -0
  43. package/dist/kubernetes/restore-kubernetes-data.js +9 -5
  44. package/dist/kubernetes/sandbox-runner.js +628 -0
  45. package/dist/kubernetes/service-rollouts.js +74 -0
  46. package/dist/kubernetes/store-kubernetes-data.js +5 -2
  47. package/dist/kubernetes/volumes.js +94 -9
  48. package/dist/main.js +2 -0
  49. package/dist/optimizer/calculate-checksum.js +3 -2
  50. package/dist/optimizer/check-key.js +94 -0
  51. package/dist/optimizer/check-keys.js +64 -0
  52. package/dist/optimizer/get-work-cache-stats.js +17 -3
  53. package/dist/optimizer/work-task-cache-description.js +49 -1
  54. package/dist/planner/service-definition.js +9 -4
  55. package/dist/planner/utils/append-work-dependencies.js +5 -3
  56. package/dist/planner/utils/append-work-service.js +18 -1
  57. package/dist/planner/utils/append-work-task.js +13 -3
  58. package/dist/planner/utils/get-work-runtime.js +6 -1
  59. package/dist/planner/utils/normalize-path.js +19 -1
  60. package/dist/planner/utils/parse-work-mount.js +7 -1
  61. package/dist/planner/utils/parse-work-port.js +4 -1
  62. package/dist/planner/validate.js +22 -0
  63. package/dist/planner/work-network.js +3 -1
  64. package/dist/planner/work-runtime-aggregate.js +32 -0
  65. package/dist/planner/work-runtime-kubernetes.js +136 -13
  66. package/dist/planner/work-task.js +6 -1
  67. package/dist/program.js +27 -9
  68. package/dist/remote/device-login.js +7 -2
  69. package/dist/remote/host-config.js +69 -3
  70. package/dist/remote/remote-commands.js +33 -7
  71. package/dist/remote/remote-status.js +112 -0
  72. package/dist/remote/remote-work-commands.js +562 -45
  73. package/dist/remote/server-client.js +15 -2
  74. package/dist/remote/token-source.js +64 -26
  75. package/dist/run-program.js +9 -0
  76. package/dist/schema/build-file-base-task-schema.js +3 -3
  77. package/dist/schema/build-file-container-service-schema.js +10 -0
  78. package/dist/schema/build-file-container-task-schema.js +16 -1
  79. package/dist/schema/build-file-include-schema.js +18 -4
  80. package/dist/schema/build-file-kubernetes-service-schema.js +2 -0
  81. package/dist/schema/build-file-task-schema.js +5 -1
  82. package/dist/schema/capabilities-schema.js +56 -0
  83. package/dist/schema/reference-parser.js +119 -11
  84. package/dist/schema/removed-properties.js +20 -0
  85. package/dist/schema/schema-parser.js +94 -41
  86. package/dist/schema/work-scope-parser.js +33 -10
  87. package/dist/secrets/command-secret-provider.js +3 -1
  88. package/dist/secrets/github-app-token-command.js +3 -1
  89. package/dist/secrets/secret-catalog.js +43 -19
  90. package/dist/server/accounts-api.js +4 -1
  91. package/dist/server/approvals-api.js +1 -1
  92. package/dist/server/capacity-format.js +36 -0
  93. package/dist/server/capacity.js +300 -0
  94. package/dist/server/checks-api.js +83 -0
  95. package/dist/server/checks.js +116 -0
  96. package/dist/server/dashboard.js +196 -0
  97. package/dist/server/definitions-api.js +66 -3
  98. package/dist/server/endpoints.js +794 -0
  99. package/dist/server/executor.js +164 -9
  100. package/dist/server/kubernetes-backend.js +415 -48
  101. package/dist/server/kubernetes-sandbox-storage.js +46 -2
  102. package/dist/server/overview.js +271 -0
  103. package/dist/server/plan.js +170 -121
  104. package/dist/server/policy.js +34 -2
  105. package/dist/server/proposals-api.js +17 -8
  106. package/dist/server/repo-access.js +5 -0
  107. package/dist/server/run-logs.js +4 -0
  108. package/dist/server/run-plan.js +38 -4
  109. package/dist/server/run-request.js +58 -3
  110. package/dist/server/run-worker.js +262 -13
  111. package/dist/server/runs.js +962 -36
  112. package/dist/server/sandbox-pod.js +28 -0
  113. package/dist/server/sandbox-storage.js +23 -0
  114. package/dist/server/sandboxes.js +8 -0
  115. package/dist/server/server-config.js +246 -10
  116. package/dist/server/server.js +429 -32
  117. package/dist/server/service-runs.js +82 -0
  118. package/dist/server/timings.js +132 -0
  119. package/dist/server/wait-reason.js +21 -0
  120. package/dist/utils/tar.js +28 -1
  121. package/dist/utils/terminal-prompt.js +53 -0
  122. package/package.json +8 -2
@@ -118,6 +118,7 @@ function parseService(workTree, service, environment, context) {
118
118
  secrets: (0, work_secret_1.parseWorkSecrets)(service.cwd, context.projectRoot, service.schema.secrets, envs, environment, context.secrets, service.schema.account),
119
119
  resources: (0, work_resources_1.parseWorkResources)(service.schema.resources),
120
120
  volumeSize: service.schema.volumeSize ?? null,
121
+ restarts: service.schema.restarts ?? null,
121
122
  network: (0, work_network_1.parseWorkNetwork)(service.schema.network),
122
123
  networkCeiling: null,
123
124
  args: service.schema.args ?? null,
@@ -143,7 +144,11 @@ function appendServiceInit(workTree, service, item, environment, context) {
143
144
  task.needs[selfNeed] = { name: task.needs[selfNeed].name, service: view };
144
145
  }
145
146
  else {
146
- task.needs.push({ name: item.name, service: view });
147
+ const name = getInitNeedName(service);
148
+ if (task.needs.some((need) => need.name === name)) {
149
+ throw new Error(`${task.name} needs two services as ${name}, give one another name with name:`);
150
+ }
151
+ task.needs.push({ name, service: view });
147
152
  }
148
153
  removeRequirer(item, task.name);
149
154
  // explicit, so neither the default method nor `--cache` turns it back on
@@ -153,6 +158,18 @@ function appendServiceInit(workTree, service, item, environment, context) {
153
158
  }
154
159
  item.data.init = { task, state };
155
160
  }
161
+ // The host name the init reaches its service by: the name its task declares the
162
+ // need by, else the service's name in its own build file. Planning the task
163
+ // skips that need (it is the init's), and the full name of a service of an
164
+ // include, `services:db`, is no host name: Docker and DNS take no colon.
165
+ function getInitNeedName(service) {
166
+ const declared = service.init?.task.needs.find((need) => need.service.relativeName === service.relativeName);
167
+ if (declared) {
168
+ return declared.relativeName;
169
+ }
170
+ const prefix = service.scope.namePrefix;
171
+ return prefix.length > 0 ? service.relativeName.slice(prefix.length + 1) : service.relativeName;
172
+ }
156
173
  const initViews = new WeakMap();
157
174
  // The service itself for the view its init task needs, else the item.
158
175
  function getPlannedService(item) {
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.appendWorkTask = void 0;
4
4
  const work_secret_1 = require("../work-secret");
5
+ const work_task_1 = require("../work-task");
5
6
  const parse_work_generate_1 = require("./parse-work-generate");
6
7
  const template_value_1 = require("./template-value");
7
8
  const parse_work_mounts_1 = require("./parse-work-mounts");
@@ -25,7 +26,8 @@ function appendWorkTask(workTree, cwd, referenceTask, environment, context) {
25
26
  const parsed = parseTask(cwd, referenceTask, environment, context);
26
27
  // on a server's cluster a local task runs in a tool image like a container task
27
28
  const server = workTree.environment.type === 'kubernetes' ? workTree.environment.server : undefined;
28
- const task = !server
29
+ // an aggregate runs nothing, so it needs no pod either
30
+ const task = !server || (0, work_task_1.isAggregateTask)(parsed)
29
31
  ? parsed
30
32
  : parsed.type === 'local-task'
31
33
  ? (0, job_placement_1.runInToolImage)(parsed, server, referenceTask.schema.platform)
@@ -77,7 +79,6 @@ function parseTask(cwd, task, environment, context) {
77
79
  labels: task.labels,
78
80
  caching: (0, resolve_cache_1.resolveCache)(task.schema.cache ?? null, context.caches, task.relativeName),
79
81
  shell: task.schema.shell ? (0, template_value_1.templateValue)(task.schema.shell, envs) : '/bin/sh',
80
- continuous: task.schema.continuous ?? false,
81
82
  timeout: task.schema.timeout ? (0, units_1.parseDuration)(task.schema.timeout) : null,
82
83
  secrets: (0, work_secret_1.parseWorkSecrets)(cwd, context.projectRoot, task.schema.secrets, envs, environment, context.secrets, task.schema.account),
83
84
  resources: (0, work_resources_1.parseWorkResources)(task.schema.resources),
@@ -90,10 +91,12 @@ function parseTask(cwd, task, environment, context) {
90
91
  return {
91
92
  ...baseWorkNode,
92
93
  type: 'container-task',
93
- user: (0, get_container_user_1.getContainerUser)(),
94
+ // a capability holds for root only: a task that adds some runs as its image's user
95
+ user: task.schema.capAdd?.length ? null : (0, get_container_user_1.getContainerUser)(),
94
96
  image: (0, template_value_1.templateValue)(task.schema.image, envs),
95
97
  mounts: (0, parse_work_mounts_1.parseWorkMounts)(cwd, task.schema, envs),
96
98
  volumeSize: task.schema.volumeSize ?? null,
99
+ capAdd: task.schema.capAdd ?? [],
97
100
  };
98
101
  }
99
102
  else {
@@ -107,9 +110,16 @@ function parseTask(cwd, task, environment, context) {
107
110
  throw new Error(`task ${task.relativeName}: network ${(0, work_network_1.formatNetwork)(baseWorkNode.network)} needs a container, ` +
108
111
  `hammerkit can't limit the network of a local task`);
109
112
  }
113
+ const aggregate = task.deps.length > 0 &&
114
+ task.needs.length === 0 &&
115
+ baseWorkNode.src.length === 0 &&
116
+ baseWorkNode.cmds.length === 0 &&
117
+ baseWorkNode.generates.length === 0 &&
118
+ baseWorkNode.secrets.length === 0;
110
119
  return {
111
120
  ...baseWorkNode,
112
121
  type: 'local-task',
122
+ ...(aggregate ? { aggregate } : {}),
113
123
  };
114
124
  }
115
125
  }
@@ -4,6 +4,8 @@ exports.getWorkTaskRuntime = exports.getWorkServiceRuntime = void 0;
4
4
  const work_runtime_docker_1 = require("../work-runtime-docker");
5
5
  const work_runtime_kubernetes_1 = require("../work-runtime-kubernetes");
6
6
  const work_runtime_local_1 = require("../work-runtime-local");
7
+ const work_runtime_aggregate_1 = require("../work-runtime-aggregate");
8
+ const work_task_1 = require("../work-task");
7
9
  const work_item_1 = require("../work-item");
8
10
  function getWorkServiceRuntime(workTree, service) {
9
11
  if ((0, work_item_1.isContainerWorkServiceItem)(service)) {
@@ -24,7 +26,10 @@ function getWorkServiceRuntime(workTree, service) {
24
26
  }
25
27
  exports.getWorkServiceRuntime = getWorkServiceRuntime;
26
28
  function getWorkTaskRuntime(workTree, task) {
27
- if ((0, work_item_1.isLocalWorkTaskItem)(task)) {
29
+ if ((0, work_task_1.isAggregateTask)(task.data)) {
30
+ return (0, work_runtime_aggregate_1.aggregateWorkRuntime)();
31
+ }
32
+ else if ((0, work_item_1.isLocalWorkTaskItem)(task)) {
28
33
  return (0, work_runtime_local_1.getLocalWorkRuntime)(task);
29
34
  }
30
35
  else if ((0, work_item_1.isContainerWorkTaskItem)(task)) {
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.normalizePath = void 0;
3
+ exports.normalizeHostPath = exports.getHomeReference = exports.normalizePath = void 0;
4
4
  const path_1 = require("path");
5
5
  function normalizePath(cwd, pwd, path) {
6
6
  if (path.startsWith('/')) {
@@ -14,4 +14,22 @@ function normalizePath(cwd, pwd, path) {
14
14
  }
15
15
  }
16
16
  exports.normalizePath = normalizePath;
17
+ // What a path of the host starts with to name the home directory: `~` and `$HOME`, and
18
+ // `$PWD`, which in a mount has always meant the home directory, not the project it reads like
19
+ function getHomeReference(path) {
20
+ for (const reference of ['~', '$HOME']) {
21
+ if (path === reference || path.startsWith(`${reference}/`) || path.startsWith(`${reference}\\`)) {
22
+ return reference;
23
+ }
24
+ }
25
+ return path.startsWith('$PWD') ? '$PWD' : null;
26
+ }
27
+ exports.getHomeReference = getHomeReference;
28
+ // A path of the host in a mount: in the home directory when it starts with one of the
29
+ // references above, else in the directory of the build file unless it is absolute
30
+ function normalizeHostPath(cwd, home, path) {
31
+ const reference = getHomeReference(path);
32
+ return reference ? (0, path_1.join)(home, path.substring(reference.length)) : normalizePath(cwd, home, path);
33
+ }
34
+ exports.normalizeHostPath = normalizeHostPath;
17
35
  //# sourceMappingURL=normalize-path.js.map
@@ -7,6 +7,12 @@ const path_1 = require("path");
7
7
  function parseWorkMount(cwd, dir) {
8
8
  const parts = dir.split(':');
9
9
  if (parts.length === 1) {
10
+ const home = (0, normalize_path_1.getHomeReference)(dir);
11
+ if (home === '~' || home === '$HOME') {
12
+ // the container has no such directory: the same path on both sides would be one of the
13
+ // project there (`$PWD` has always been, so it stays)
14
+ throw new Error(`mount ${dir} is in the home directory, name where it goes in the container (${dir}:/path)`);
15
+ }
10
16
  return parseLocalMount(cwd, dir, dir, dir, false);
11
17
  }
12
18
  else if (parts.length === 2) {
@@ -22,7 +28,7 @@ function parseWorkMount(cwd, dir) {
22
28
  exports.parseWorkMount = parseWorkMount;
23
29
  function parseLocalMount(cwd, localPath, containerPath, mount, readOnly) {
24
30
  return {
25
- localPath: (0, normalize_path_1.normalizePath)(cwd, (0, os_1.homedir)(), localPath),
31
+ localPath: (0, normalize_path_1.normalizeHostPath)(cwd, (0, os_1.homedir)(), localPath),
26
32
  containerPath: (0, normalize_path_1.normalizePath)(cwd, cwd, containerPath),
27
33
  isFile: (0, path_1.extname)(localPath).length > 1,
28
34
  mount,
@@ -8,7 +8,10 @@ function parseWorkPort(port) {
8
8
  return { containerPort: port, hostPort: port };
9
9
  }
10
10
  else if (parts.length === 2) {
11
- return { containerPort: parsePort(parts[1], false), hostPort: parsePort(parts[0], true) };
11
+ // `0:80` is not published, as an empty host port: 0 (a free port) is for the endpoints
12
+ // of a server only
13
+ const hostPort = parsePort(parts[0], true);
14
+ return { containerPort: parsePort(parts[1], false), hostPort: hostPort === 0 ? null : hostPort };
12
15
  }
13
16
  else {
14
17
  throw new Error(`invalid port ${port}`);
@@ -4,6 +4,7 @@ exports.checkIfContextExists = exports.hasDependencyCycle = exports.hasNeedCycle
4
4
  const read_build_file_1 = require("../parser/read-build-file");
5
5
  const plan_work_tasks_1 = require("./utils/plan-work-tasks");
6
6
  const unreferenced_build_file_envs_1 = require("./unreferenced-build-file-envs");
7
+ const normalize_path_1 = require("./utils/normalize-path");
7
8
  async function* validate(workTree, context) {
8
9
  const cycleItems = [];
9
10
  for (const item of (0, plan_work_tasks_1.iterateWorkServices)(workTree)) {
@@ -16,6 +17,10 @@ async function* validate(workTree, context) {
16
17
  yield { type: 'warn', message: 'missing healthcheck', item: service };
17
18
  }
18
19
  for (const mount of service.mounts) {
20
+ const pwd = getPwdMountWarning(mount);
21
+ if (pwd) {
22
+ yield { type: 'warn', message: pwd, item: service };
23
+ }
19
24
  if (!(await context.file.exists(mount.localPath))) {
20
25
  yield {
21
26
  type: 'warn',
@@ -62,6 +67,12 @@ async function* validate(workTree, context) {
62
67
  item: task,
63
68
  };
64
69
  }
70
+ for (const mount of task.type === 'container-task' ? task.mounts : []) {
71
+ const pwd = getPwdMountWarning(mount);
72
+ if (pwd) {
73
+ yield { type: 'warn', message: pwd, item: task };
74
+ }
75
+ }
65
76
  for (const src of task.src) {
66
77
  if (!(await context.file.exists(src.absolutePath))) {
67
78
  yield {
@@ -146,4 +157,15 @@ async function checkIfContextExists(service, context) {
146
157
  return false;
147
158
  }
148
159
  exports.checkIfContextExists = checkIfContextExists;
160
+ // `$PWD` in a mount is the home directory, which reads like a path of the project
161
+ function getPwdMountWarning(mount) {
162
+ const source = mount.mount.split(':')[0];
163
+ if ((0, normalize_path_1.getHomeReference)(source) !== '$PWD') {
164
+ return null;
165
+ }
166
+ const rest = source.substring('$PWD'.length);
167
+ const home = rest === '' || rest.startsWith('/') || rest.startsWith('\\') ? `~${rest}` : '~/…';
168
+ return (`mount ${source} is ${mount.localPath}: $PWD in a mount is the home directory, not the project. ` +
169
+ `Write ${home} for the home directory, or a relative path for the project`);
170
+ }
149
171
  //# sourceMappingURL=validate.js.map
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
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
4
  const network_schema_1 = require("../schema/network-schema");
5
+ const work_task_1 = require("./work-task");
5
6
  exports.OPEN_NETWORK = { type: 'open' };
6
7
  function parseWorkNetwork(schema) {
7
8
  if (!schema || schema === 'open') {
@@ -153,7 +154,8 @@ exports.formatNetwork = formatNetwork;
153
154
  function applyNetworkCeiling(workTree, ceiling) {
154
155
  const limit = ceiling && isLimitedNetwork(ceiling) ? ceiling : null;
155
156
  if (limit) {
156
- const local = Object.values(workTree.tasks).find((task) => task.data.type === 'local-task');
157
+ // an aggregate runs nothing, so there is nothing of it to limit
158
+ const local = Object.values(workTree.tasks).find((task) => task.data.type === 'local-task' && !(0, work_task_1.isAggregateTask)(task.data));
157
159
  if (local) {
158
160
  throw new Error(`--network ${formatNetwork(limit)} needs every task in a container, ` +
159
161
  `hammerkit can't limit the network of the local task ${local.name}`);
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.aggregateWorkRuntime = void 0;
4
+ // An aggregate runs nothing, so it touches nothing: no pid or state file in its
5
+ // directory (on a server that is a checkout it doesn't trust), no outputs.
6
+ function aggregateWorkRuntime() {
7
+ return {
8
+ async initialize() {
9
+ // nothing to prepare
10
+ },
11
+ async remove() {
12
+ // it has no outputs of its own
13
+ },
14
+ async execute() {
15
+ // its state stays running, and executeWorkTask completes it
16
+ },
17
+ async stop() {
18
+ // nothing is running
19
+ },
20
+ async archive() {
21
+ // never pushed, see pushToBackend
22
+ },
23
+ async restore() {
24
+ // never a hit, see checkCacheState
25
+ },
26
+ async currentStateKey() {
27
+ return null;
28
+ },
29
+ };
30
+ }
31
+ exports.aggregateWorkRuntime = aggregateWorkRuntime;
32
+ //# sourceMappingURL=work-runtime-aggregate.js.map
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.kubernetesForwardRuntime = exports.kubernetesServiceRuntime = exports.kubernetesTaskRuntime = exports.getTaskJobContainers = exports.describeFailedJob = void 0;
6
+ exports.kubernetesForwardRuntime = exports.isRolledOut = exports.kubernetesServiceRuntime = exports.kubernetesTaskRuntime = exports.getTaskJobContainers = exports.describeFailedJob = void 0;
7
7
  const abort_1 = require("../executer/abort");
8
8
  const kubernetes_service_1 = require("../executer/kubernetes-service");
9
9
  const kubernetes_instance_1 = require("../kubernetes/kubernetes-instance");
@@ -11,12 +11,14 @@ const replace_env_variables_1 = require("../environment/replace-env-variables");
11
11
  const apply_1 = require("../kubernetes/apply");
12
12
  const ensure_kubernetes_service_exists_1 = require("../kubernetes/ensure-kubernetes-service-exists");
13
13
  const ensure_kubernetes_deployment_exists_1 = require("../kubernetes/ensure-kubernetes-deployment-exists");
14
+ const follow_pod_logs_1 = require("../kubernetes/follow-pod-logs");
14
15
  const service_definition_1 = require("./service-definition");
15
16
  const ensure_namespace_1 = require("../kubernetes/ensure-namespace");
16
17
  const ensure_persistent_data_1 = require("../kubernetes/ensure-persistent-data");
17
18
  const network_gate_1 = require("../kubernetes/network-gate");
18
19
  const await_running_state_1 = require("../kubernetes/await-running-state");
19
20
  const volumes_1 = require("../kubernetes/volumes");
21
+ const service_rollouts_1 = require("../kubernetes/service-rollouts");
20
22
  const resources_1 = require("../kubernetes/resources");
21
23
  const ensure_http_route_1 = require("../kubernetes/ensure-http-route");
22
24
  const find_process_1 = __importDefault(require("find-process"));
@@ -34,6 +36,7 @@ const job_placement_1 = require("../kubernetes/job-placement");
34
36
  const network_policy_1 = require("../kubernetes/network-policy");
35
37
  const run_id_1 = require("../utils/run-id");
36
38
  const work_network_1 = require("./work-network");
39
+ const sandbox_runner_1 = require("../kubernetes/sandbox-runner");
37
40
  const work_secret_1 = require("./work-secret");
38
41
  async function listJobNames(instance, kubernetes, task) {
39
42
  const jobs = await instance.batchApi.listNamespacedJob({
@@ -129,7 +132,8 @@ exports.describeFailedJob = describeFailedJob;
129
132
  * The containers of the Job of a command: the network gate first, nothing runs before the
130
133
  * item's policy holds, then the init containers that read its secrets, then the command. The
131
134
  * proxy's token (`network.secretEnv`) reaches the init containers through `secretSpec` only:
132
- * the command's container gets the proxy variables of a limited network without it.
135
+ * the command's container gets the proxy variables of a limited network without it. A task that
136
+ * adds capabilities gets them in the sandbox's RuntimeClass only.
133
137
  */
134
138
  function getTaskJobContainers(options) {
135
139
  const { task, cmd, envs, secrets, network, secretSpec, kubernetes } = options;
@@ -154,9 +158,10 @@ function getTaskJobContainers(options) {
154
158
  name: options.container,
155
159
  volumeMounts: [...options.mounts, ...secrets.mounts, ...secretSpec.mounts],
156
160
  ...(0, container_resources_1.getKubernetesResources)(task.data.resources),
157
- ...(0, job_placement_1.getContainerSecurity)(kubernetes),
161
+ ...(0, job_placement_1.getContainerSecurity)(kubernetes, task.data.capAdd),
158
162
  },
159
163
  ],
164
+ ...(0, job_placement_1.getPodRuntimeClass)({ name: task.name, capAdd: task.data.capAdd }, kubernetes),
160
165
  };
161
166
  }
162
167
  exports.getTaskJobContainers = getTaskJobContainers;
@@ -171,29 +176,67 @@ function kubernetesTaskRuntime(task, kubernetes) {
171
176
  return Promise.resolve();
172
177
  },
173
178
  async restore(environment, path, stateKey) {
179
+ // a cache hit of a service run at the state that services of the run mount (a move whose
180
+ // dep did not change): its outputs are there, and stay as they are while pods read them
181
+ if (kubernetes.server?.serviceRun &&
182
+ stateKey &&
183
+ (await (0, ensure_persistent_data_1.getStateDirsInUse)(instance, kubernetes.namespace, claim)).has((0, volumes_1.getStateFilesDir)(stateKey))) {
184
+ task.status.write('info', `${task.name}: services of the run use its outputs of this state, they stay`);
185
+ return;
186
+ }
174
187
  if (!kept) {
175
- await (0, restore_kubernetes_data_1.restoreKubernetesData)(task, kubernetes, instance, environment, path);
188
+ await (0, restore_kubernetes_data_1.restoreKubernetesData)(task, kubernetes, instance, environment, path, { stateKey });
176
189
  return;
177
190
  }
178
191
  const reset = await (0, kept_claims_1.forgetClaimState)(instance, kubernetes, claim);
179
- await (0, restore_kubernetes_data_1.restoreKubernetesData)(task, kubernetes, instance, environment, path, { reset });
192
+ await (0, restore_kubernetes_data_1.restoreKubernetesData)(task, kubernetes, instance, environment, path, { reset, stateKey });
180
193
  if (stateKey) {
181
194
  await (0, kept_claims_1.recordClaimState)(instance, kubernetes, claim, stateKey);
182
195
  }
183
196
  },
184
- async archive(environment, path) {
185
- await (0, store_kubernetes_data_1.storeKubernetesData)(task, kubernetes, instance, environment, path);
197
+ async archive(environment, path, stateKey) {
198
+ await (0, store_kubernetes_data_1.storeKubernetesData)(task, kubernetes, instance, environment, path, stateKey);
186
199
  },
187
200
  async execute(environment, options) {
188
201
  const envs = (0, replace_env_variables_1.getEnvironmentVariables)(task.data.envs);
189
- const persistence = await (0, volumes_1.getKubernetesPersistence)(task, kubernetes);
202
+ const persistence = await (0, volumes_1.getKubernetesPersistence)(task, kubernetes, options.stateKey);
190
203
  const workspace = (0, volumes_1.getWorkspacePodSpec)(kubernetes, persistence, { name: task.name, pool: task.data.pool });
204
+ // a shell of a sandbox: its commands go into the sandbox's runner, when it has one now
205
+ if ((0, sandbox_runner_1.canUseRunner)(task, kubernetes, persistence)) {
206
+ try {
207
+ const result = await (0, sandbox_runner_1.executeInRunner)(instance, task, kubernetes, persistence, options.abort);
208
+ if (result.type === 'crash') {
209
+ options.state.set({ stateKey: options.stateKey, type: 'crash', exitCode: result.exitCode });
210
+ return;
211
+ }
212
+ // stopped while the runner started: no Job for it either
213
+ if (result.type === 'canceled' || options.abort.aborted) {
214
+ options.state.set({ stateKey: options.stateKey, type: 'canceled' });
215
+ return;
216
+ }
217
+ if (result.type === 'done') {
218
+ return;
219
+ }
220
+ }
221
+ catch (e) {
222
+ options.state.set({ stateKey: options.stateKey, type: 'error', errorMessage: (0, log_1.getErrorMessage)(e) });
223
+ return;
224
+ }
225
+ }
191
226
  await (0, ensure_namespace_1.ensureNamespace)(instance, kubernetes.namespace);
227
+ // a service run writes the outputs of each state into a folder of their own (see
228
+ // getStateFilesDir), which pods of its services may mount: of the commit before during
229
+ // a move, or of this state after a move that went back
230
+ const files = kubernetes.server?.serviceRun ? { dir: (0, volumes_1.getStateFilesDir)(options.stateKey) } : undefined;
192
231
  try {
232
+ if (files && (await (0, ensure_persistent_data_1.getStateDirsInUse)(instance, kubernetes.namespace, claim)).has(files.dir)) {
233
+ task.status.write('info', `${task.name}: services of the run use its outputs of this state, they stay`);
234
+ return;
235
+ }
193
236
  // the task writes to it now: what it holds stops being the outputs of a state, and
194
237
  // goes, as a new claim would hold nothing
195
238
  const reset = kept && (await (0, kept_claims_1.forgetClaimState)(instance, kubernetes, claim));
196
- await (0, ensure_persistent_data_1.ensurePersistentData)(instance, kubernetes, environment, task, persistence, { reset });
239
+ await (0, ensure_persistent_data_1.ensurePersistentData)(instance, kubernetes, environment, task, persistence, { reset, files });
197
240
  }
198
241
  catch (e) {
199
242
  options.state.set({ stateKey: options.stateKey, type: 'error', errorMessage: (0, log_1.getErrorMessage)(e) });
@@ -465,6 +508,10 @@ function kubernetesServiceRuntime(service, kubernetes) {
465
508
  // stops it and a run reuses it, as with a container on Docker. One whose
466
509
  // definition changed goes, with its limits, and is created anew.
467
510
  async initialize(state) {
511
+ if (kubernetes.server?.serviceRun) {
512
+ // a service run starts every service: one that did not change stays as it is (execute)
513
+ return;
514
+ }
468
515
  const deployments = await instance.appsApi.listNamespacedDeployment({
469
516
  namespace: kubernetes.namespace,
470
517
  labelSelector: (0, resources_1.getItemSelector)(service, kubernetes),
@@ -509,18 +556,70 @@ function kubernetesServiceRuntime(service, kubernetes) {
509
556
  await (0, network_policy_1.removeAllKubernetesItemNetworks)(instance, kubernetes, service);
510
557
  },
511
558
  async execute(environment, options) {
512
- const persistence = await (0, volumes_1.getKubernetesPersistence)(service, kubernetes);
559
+ const name = (0, resources_1.getResourceName)(service, kubernetes);
560
+ // a service run that moves to another commit: a service of the same definition and
561
+ // state is left running, another one is rolled from the Deployment it has now
562
+ let previous;
563
+ if (kubernetes.server?.serviceRun) {
564
+ previous = await readDeployment(instance, kubernetes.namespace, name);
565
+ if (previous && isRolledOut(previous, (0, service_definition_1.getServiceDefinitionHash)(service), options.stateKey)) {
566
+ service.status.write('info', `${service.name} did not change, it stays up`);
567
+ options.state.set({ type: 'running', dns: { containerId: name }, stateKey: options.stateKey, remote: null });
568
+ return;
569
+ }
570
+ }
571
+ const persistence = await (0, volumes_1.getKubernetesPersistence)(service, kubernetes, options.stateKey);
572
+ // the files of a service run's service go into a folder of their state, so the pod that
573
+ // runs keeps its own until the new one is up. One that watches its files (`continuous`)
574
+ // gets them synced into the folder its pod mounts: the pod that runs sees the changes.
575
+ const live = (0, volumes_1.isLiveService)(service, kubernetes);
576
+ // the files of the state it runs at now, to put back when the move does not get up
577
+ const before = live ? previous?.metadata?.labels?.['hammerkit.dev/state'] : undefined;
578
+ const files = kubernetes.server?.serviceRun
579
+ ? {
580
+ dir: (0, volumes_1.getStateFilesDir)(options.stateKey),
581
+ live,
582
+ keep: before ? [(0, volumes_1.getStateFilesDir)(before)] : [],
583
+ }
584
+ : undefined;
585
+ if (live && previous && before) {
586
+ const from = (0, volumes_1.getStateFilesDir)(before);
587
+ service_rollouts_1.serviceRollouts.add(kubernetes.namespace, name, previous, () => (0, ensure_persistent_data_1.restoreLiveFiles)(instance, kubernetes, service, persistence, from));
588
+ }
513
589
  await (0, ensure_namespace_1.ensureNamespace)(instance, kubernetes.namespace);
514
590
  await (0, ensure_kubernetes_service_exists_1.ensureKubernetesServiceExists)(instance, kubernetes, service);
515
- await (0, ensure_persistent_data_1.ensurePersistentData)(instance, kubernetes, environment, service, persistence);
591
+ await (0, ensure_persistent_data_1.ensurePersistentData)(instance, kubernetes, environment, service, persistence, { files });
516
592
  await (0, secrets_1.ensureKubernetesSecret)(instance, kubernetes, service, service.data.secrets, environment);
517
593
  // on a server, the run's: its runs share the namespace, and the service goes with the run
518
594
  const network = await (0, network_policy_1.ensureKubernetesItemNetwork)(instance, kubernetes, service, {
519
595
  followLog: false,
520
596
  run: kubernetes.server ? (0, job_placement_1.getRunLabelValue)(kubernetes.server.run) : null,
521
597
  });
522
- await (0, ensure_kubernetes_deployment_exists_1.ensureKubernetesDeploymentExists)(instance, kubernetes, service, persistence, (0, secrets_1.getKubernetesSecretRefs)(service, kubernetes, service.data.secrets), options.stateKey, network);
523
- const name = (0, resources_1.getResourceName)(service, kubernetes);
598
+ // its output, as on Docker: while it starts, and while it runs unless it runs on
599
+ // without hammerkit (`up --daemon`)
600
+ const logs = (0, follow_pod_logs_1.followPodLogs)(instance, kubernetes.namespace, (0, resources_1.getItemSelector)(service, kubernetes), (0, ensure_kubernetes_deployment_exists_1.getServiceContainerName)(service), (line) => service.status.console('stdout', line), { onError: (text) => service.status.write('debug', text) });
601
+ try {
602
+ await (0, ensure_kubernetes_deployment_exists_1.ensureKubernetesDeploymentExists)(instance, kubernetes, service, persistence, (0, secrets_1.getKubernetesSecretRefs)(service, kubernetes, service.data.secrets), options.stateKey, network, { previous, signal: options.abort });
603
+ }
604
+ catch (e) {
605
+ // the last words of a container that crashed, before why it failed
606
+ await logs.settle().catch(() => undefined);
607
+ logs.stop();
608
+ throw e;
609
+ }
610
+ if (options.daemon || options.abort.aborted || environment.abortCtrl.signal.aborted) {
611
+ logs.stop();
612
+ }
613
+ else {
614
+ // either ends it, and takes the other listener with it
615
+ const listening = new AbortController();
616
+ const stop = () => {
617
+ logs.stop();
618
+ listening.abort();
619
+ };
620
+ options.abort.addEventListener('abort', stop, { once: true, signal: listening.signal });
621
+ environment.abortCtrl.signal.addEventListener('abort', stop, { once: true, signal: listening.signal });
622
+ }
524
623
  options.state.set({
525
624
  type: 'running',
526
625
  dns: { containerId: name },
@@ -576,6 +675,30 @@ function kubernetesServiceRuntime(service, kubernetes) {
576
675
  };
577
676
  }
578
677
  exports.kubernetesServiceRuntime = kubernetesServiceRuntime;
678
+ async function readDeployment(instance, namespace, name) {
679
+ try {
680
+ return await instance.appsApi.readNamespacedDeployment({ name, namespace });
681
+ }
682
+ catch (e) {
683
+ if ((0, apply_1.statusCodeOf)(e) === 404) {
684
+ return undefined;
685
+ }
686
+ throw e;
687
+ }
688
+ }
689
+ // A Deployment of this definition and state whose pods are all of it and ready
690
+ function isRolledOut(deployment, definition, stateKey) {
691
+ const labels = deployment.metadata?.labels ?? {};
692
+ const status = deployment.status;
693
+ return (!deployment.metadata?.deletionTimestamp &&
694
+ labels[ensure_kubernetes_deployment_exists_1.DEFINITION_LABEL] === definition &&
695
+ labels['hammerkit.dev/state'] === stateKey &&
696
+ (status?.observedGeneration ?? 0) >= (deployment.metadata?.generation ?? 0) &&
697
+ (status?.updatedReplicas ?? 0) >= 1 &&
698
+ (status?.availableReplicas ?? 0) >= 1 &&
699
+ (status?.replicas ?? 0) === (status?.updatedReplicas ?? 0));
700
+ }
701
+ exports.isRolledOut = isRolledOut;
579
702
  function kubernetesForwardRuntime(service) {
580
703
  return {
581
704
  async initialize(item) {
@@ -1,8 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isWorkTask = exports.isContainerWorkTask = void 0;
3
+ exports.isAggregateTask = exports.isWorkTask = exports.isContainerWorkTask = void 0;
4
4
  const isContainerWorkTask = (val) => val.type === 'container-task';
5
5
  exports.isContainerWorkTask = isContainerWorkTask;
6
6
  const isWorkTask = (val) => val.type === 'container-task' || val.type === 'local-task';
7
7
  exports.isWorkTask = isWorkTask;
8
+ // A task that only groups its dependencies: `deps`, and no `image`, `cmds`, `src`,
9
+ // `generates`, `secrets` or `needs`. It needs no runtime, hands on what its dependencies generate to what
10
+ // depends on it, and is up to date (or verified) when all its dependencies are.
11
+ const isAggregateTask = (val) => val.type === 'local-task' && val.aggregate === true;
12
+ exports.isAggregateTask = isAggregateTask;
8
13
  //# sourceMappingURL=work-task.js.map
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))
@@ -608,11 +620,12 @@ async function getProgram(environment, argv) {
608
620
  .choices(['checksum', 'modify-date', 'none']))
609
621
  .addOption(new commander_1.Option('--cache-read-only', `restore from cache backends but never push to them (or set ${read_only_1.CACHE_READ_ONLY_ENV}=1)`).default(false))
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))
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))
611
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))
612
- .action(async (options, command) => {
625
+ .action(async (names, options, command) => {
613
626
  try {
614
627
  const scope = {
615
- ...parseWorkLabelScope(options),
628
+ ...parseWorkNameScope(names, options),
616
629
  args: { values: passthrough, services: true, cache: keepsCache(command) },
617
630
  };
618
631
  const cli = await createCli(fileName, environment, scope);
@@ -626,6 +639,7 @@ async function getProgram(environment, argv) {
626
639
  wait: options.wait,
627
640
  cacheReadOnly: (0, read_only_1.isCacheReadOnly)(options.cacheReadOnly, environment.processEnvs),
628
641
  timeout: options.timeout ?? null,
642
+ maxTimeout: options.maxTimeout ?? null,
629
643
  network: options.network ?? null,
630
644
  });
631
645
  if (!result.success) {
@@ -644,14 +658,14 @@ async function getProgram(environment, argv) {
644
658
  }
645
659
  });
646
660
  program
647
- .command('down')
648
- .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')
649
663
  .addOption(new commander_1.Option('-f, --filter <labels...>', 'filter task and services with labels'))
650
664
  .addOption(new commander_1.Option('-e, --exclude <labels...>', 'exclude task and services with labels'))
651
665
  .addOption(new commander_1.Option('--env <name>', 'environment'))
652
- .action(async (options) => {
666
+ .action(async (names, options) => {
653
667
  try {
654
- const scope = parseWorkLabelScope(options);
668
+ const scope = { ...parseWorkNameScope(names, options), stop: true };
655
669
  const cli = await createCli(fileName, environment, scope);
656
670
  const result = await cli.runDown();
657
671
  if (!result.success) {
@@ -688,8 +702,10 @@ async function getProgram(environment, argv) {
688
702
  .addOption(new commander_1.Option('--dry-run', 'print the execution plan with predicted cache hits/misses without running').default(false))
689
703
  .addOption(new commander_1.Option('--cache-read-only', `restore from cache backends but never push to them (or set ${read_only_1.CACHE_READ_ONLY_ENV}=1)`).default(false))
690
704
  .addOption(new commander_1.Option('--timeout <duration>', 'fail tasks without their own timeout after this long (e.g. 10m)').argParser(units_1.parseDuration))
705
+ .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))
691
706
  .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))
692
707
  .addOption(new commander_1.Option('--no-skip-deps', 'run dependencies even when nothing needing them has to run'))
708
+ .addOption(new commander_1.Option('--no-restore', 'only look cache hits up, download their outputs only when a task that runs needs them'))
693
709
  .action(async (task, options, command) => {
694
710
  try {
695
711
  // For machine-readable JSON, suppress the human progress logger (which
@@ -722,8 +738,10 @@ async function getProgram(environment, argv) {
722
738
  explain: options.explain,
723
739
  cacheReadOnly: (0, read_only_1.isCacheReadOnly)(options.cacheReadOnly, environment.processEnvs),
724
740
  timeout: options.timeout ?? null,
741
+ maxTimeout: options.maxTimeout ?? null,
725
742
  network: options.network ?? null,
726
743
  skipDeps: options.skipDeps !== false,
744
+ restore: options.restore !== false,
727
745
  });
728
746
  // Reporting only: the summary never changes the exit code or behavior.
729
747
  if (!options.watch) {