hammerkit 1.27.0-next.1 → 1.27.0-next.10

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 (53) hide show
  1. package/build.schema.json +9 -0
  2. package/dist/cache/backends/registry-cache-backend.js +2 -2
  3. package/dist/executer/event-cache.js +20 -2
  4. package/dist/harness/sandbox-client.js +104 -66
  5. package/dist/kubernetes/apply.js +27 -16
  6. package/dist/kubernetes/claim-layout.js +71 -0
  7. package/dist/kubernetes/ensure-http-route.js +1 -1
  8. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +5 -9
  9. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +1 -1
  10. package/dist/kubernetes/ensure-kubernetes-service-exists.js +4 -9
  11. package/dist/kubernetes/ensure-persistent-data.js +30 -20
  12. package/dist/kubernetes/get-service-ip.js +2 -2
  13. package/dist/kubernetes/job-secrets.js +33 -19
  14. package/dist/kubernetes/kept-claims.js +22 -10
  15. package/dist/kubernetes/network-policy.js +83 -26
  16. package/dist/kubernetes/remove-persistent-data.js +3 -1
  17. package/dist/kubernetes/resources.js +25 -3
  18. package/dist/kubernetes/restore-kubernetes-data.js +9 -3
  19. package/dist/kubernetes/secrets.js +7 -11
  20. package/dist/kubernetes/volumes.js +6 -3
  21. package/dist/log.js +1 -1
  22. package/dist/network/egress-proxy.js +45 -6
  23. package/dist/optimizer/work-task-cache-description.js +2 -0
  24. package/dist/planner/utils/append-work-dependencies.js +5 -2
  25. package/dist/planner/utils/append-work-service.js +1 -0
  26. package/dist/planner/utils/append-work-task.js +4 -1
  27. package/dist/planner/utils/task-arguments.js +3 -0
  28. package/dist/planner/work-runtime-docker.js +2 -1
  29. package/dist/planner/work-runtime-kubernetes.js +74 -48
  30. package/dist/planner/work-runtime-local.js +2 -1
  31. package/dist/program.js +12 -5
  32. package/dist/remote/annotations.js +13 -1
  33. package/dist/remote/remote-commands.js +11 -2
  34. package/dist/remote/remote-work-commands.js +89 -1
  35. package/dist/schema/build-file-base-task-schema.js +5 -1
  36. package/dist/schema/build-file-container-service-schema.js +2 -0
  37. package/dist/schema/reference-parser.js +1 -0
  38. package/dist/secrets/github-app-token-command.js +4 -1
  39. package/dist/server/executor.js +1 -1
  40. package/dist/server/kubernetes-backend.js +80 -31
  41. package/dist/server/kubernetes-sandbox-storage.js +2 -1
  42. package/dist/server/oauth.js +12 -1
  43. package/dist/server/plan.js +46 -10
  44. package/dist/server/run-plan.js +12 -15
  45. package/dist/server/run-request.js +6 -0
  46. package/dist/server/runs.js +10 -3
  47. package/dist/server/sandboxes-api.js +10 -4
  48. package/dist/server/server-command.js +39 -20
  49. package/dist/server/server-config.js +35 -4
  50. package/dist/server/server.js +16 -2
  51. package/dist/utils/abort-on-signals.js +46 -6
  52. package/dist/utils/tar.js +25 -0
  53. package/package.json +1 -1
package/build.schema.json CHANGED
@@ -289,6 +289,9 @@
289
289
  ],
290
290
  "description": "network the task or service reaches besides its needs: open (default), none or { allow: [hosts or host:port] }"
291
291
  },
292
+ "args": {
293
+ "type": "boolean"
294
+ },
292
295
  "runner": {
293
296
  "type": "string",
294
297
  "minLength": 1
@@ -370,6 +373,9 @@
370
373
  "network": {
371
374
  "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/network"
372
375
  },
376
+ "args": {
377
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/args"
378
+ },
373
379
  "runner": {
374
380
  "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/runner"
375
381
  },
@@ -511,6 +517,9 @@
511
517
  "network": {
512
518
  "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/network",
513
519
  "description": "network the task or service reaches besides its needs: open (default), none or { allow: [hosts or host:port] }"
520
+ },
521
+ "args": {
522
+ "type": "boolean"
514
523
  }
515
524
  },
516
525
  "required": [
@@ -5,6 +5,7 @@ const crypto_1 = require("crypto");
5
5
  const os_1 = require("os");
6
6
  const path_1 = require("path");
7
7
  const tar_1 = require("tar");
8
+ const tar_2 = require("../../utils/tar");
8
9
  const registry_reference_1 = require("./registry/registry-reference");
9
10
  const registry_client_1 = require("./registry/registry-client");
10
11
  const MAX_TAG_LENGTH = 128;
@@ -74,8 +75,7 @@ function createRegistryCacheBackend(spec) {
74
75
  const layerFile = (0, path_1.join)(dir, 'layer.tar');
75
76
  const files = (await environment.file.listFiles(from)).sort();
76
77
  // portable + mtime-free so identical entries produce identical digests
77
- // `noMtime` is supported by tar 6 but missing from its type definitions
78
- await (0, tar_1.create)({ file: layerFile, cwd: from, portable: true, noMtime: true }, files);
78
+ await (0, tar_2.packArchive)({ file: layerFile, cwd: from, portable: true, noMtime: true }, files);
79
79
  const layer = await (0, registry_client_1.digestFile)(layerFile);
80
80
  const config = Buffer.from(JSON.stringify({
81
81
  architecture: 'amd64',
@@ -8,6 +8,8 @@ const execute_docker_1 = require("./execute-docker");
8
8
  const plan_work_tasks_1 = require("../planner/utils/plan-work-tasks");
9
9
  const get_cache_directory_1 = require("../optimizer/get-cache-directory");
10
10
  const path_1 = require("path");
11
+ const crypto_1 = require("crypto");
12
+ const claim_layout_1 = require("../kubernetes/claim-layout");
11
13
  const move_files_1 = require("../file/move-files");
12
14
  const work_instance_id_1 = require("../planner/work-instance-id");
13
15
  async function restoreCache(environment, path, workTree) {
@@ -43,8 +45,24 @@ function* getArchivePaths(task, path) {
43
45
  }
44
46
  }
45
47
  else {
46
- for (const generate of task.generates.filter((g) => !g.inherited)) {
47
- const generateId = (0, path_1.relative)(task.cwd, generate.path).split(path_1.sep).join('-');
48
+ // `a-b` and `a/b` flatten alike: of those, the least deep (then the first in sorted
49
+ // order) keeps the name, the others' names carry a hash of their relative path, so a
50
+ // store and a restore in another directory or order find the same archive
51
+ const generates = task.generates
52
+ .filter((g) => !g.inherited)
53
+ .map((generate) => ({ generate, rel: (0, path_1.relative)(task.cwd, generate.path).split(path_1.sep).join(path_1.posix.sep) }));
54
+ const holders = new Map();
55
+ for (const { rel } of [...generates].sort((a, b) => (0, claim_layout_1.byDepth)(a.rel, b.rel))) {
56
+ const id = rel.split(path_1.posix.sep).join('-');
57
+ if (!holders.has(id)) {
58
+ holders.set(id, rel);
59
+ }
60
+ }
61
+ for (const { generate, rel } of generates) {
62
+ let generateId = rel.split(path_1.posix.sep).join('-');
63
+ if (holders.get(generateId) !== rel) {
64
+ generateId += '-' + (0, crypto_1.createHash)('sha256').update(rel).digest('hex').slice(0, 12);
65
+ }
48
66
  yield {
49
67
  filename: (0, path_1.join)(path, generateId + '-generates.tgz'),
50
68
  path: generate.path,
@@ -1,12 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.quotePosix = exports.fetchFailure = exports.toSandboxPath = exports.checkSandboxPath = exports.exitCodeOf = exports.oneAtATime = exports.SandboxClient = exports.SandboxClientError = void 0;
3
+ exports.quotePosix = exports.fetchFailure = exports.toSandboxPath = exports.checkSandboxPath = exports.exitCodeOf = exports.followRun = exports.submitRun = exports.FINAL_RUN_STATES = exports.oneAtATime = exports.SandboxClient = exports.SandboxClientError = void 0;
4
4
  const path_1 = require("path");
5
5
  const token_source_1 = require("../remote/token-source");
6
6
  // a write carries up to 32 MiB
7
7
  const TIMEOUT_MS = 60000;
8
8
  // a run is asked for this long at a time, the log is read in between
9
9
  const POLL_WAIT = '1s';
10
+ // a cancelled run waits this long for the server to take the cancel, before it gives up
11
+ const CANCEL_WAIT_MS = 10000;
10
12
  class SandboxClientError extends Error {
11
13
  constructor(message, status) {
12
14
  super(message);
@@ -115,72 +117,26 @@ class SandboxClient {
115
117
  if (options.timeoutSeconds) {
116
118
  runOptions.timeout = `${Math.max(1, Math.ceil(options.timeoutSeconds))}s`;
117
119
  }
118
- const submitted = await json(await call(this.connection, 'POST', '/v1/runs', {
119
- body: {
120
- definition: this.build.hash,
121
- tasks: [task],
122
- source: { type: 'sandbox', id: this.id },
123
- options: runOptions,
124
- },
125
- }));
126
- const path = `/v1/runs/${encodeURIComponent(submitted.id)}`;
127
- const cancel = () => {
128
- call(this.connection, 'POST', `${path}/cancel`).catch(() => undefined);
129
- };
130
- options.signal?.addEventListener('abort', cancel, { once: true });
131
- const lines = [];
132
- let from = 1;
133
- let truncated = false;
134
- const readLog = async () => {
135
- for (;;) {
136
- const page = await json(await call(this.connection, 'GET', `${path}/logs`, { query: { task, from, limit: 1000 } }));
137
- truncated = page.truncated;
138
- for (const line of page.lines) {
139
- lines.push(line.text);
140
- options.onLine?.(line.text);
141
- from = line.line + 1;
142
- }
143
- if (!page.next) {
144
- return;
145
- }
146
- }
120
+ const submitted = await submitRun(this.connection, {
121
+ definition: this.build.hash,
122
+ tasks: [task],
123
+ source: { type: 'sandbox', id: this.id },
124
+ options: runOptions,
125
+ });
126
+ const followed = await followRun(this.connection, submitted, {
127
+ task,
128
+ signal: options.signal,
129
+ onLine: options.onLine ? (line) => options.onLine?.(line.text) : undefined,
130
+ onWaiting: options.onWaiting,
131
+ });
132
+ const result = followed.run.results?.[task];
133
+ return {
134
+ run: followed.run,
135
+ result,
136
+ output: followed.lines.map((line) => line.text).join('\n'),
137
+ truncated: followed.truncated,
138
+ exitCode: exitCodeOf(result),
147
139
  };
148
- try {
149
- let run = submitted;
150
- let waitingSaid = false;
151
- for (;;) {
152
- if (options.signal?.aborted) {
153
- throw new SandboxClientError('the run was cancelled');
154
- }
155
- if (run.state === 'awaiting-approval' && !waitingSaid) {
156
- waitingSaid = true;
157
- options.onWaiting?.(run);
158
- }
159
- await readLog();
160
- if (['succeeded', 'failed', 'cancelled', 'rejected'].includes(run.state)) {
161
- break;
162
- }
163
- run = await json(await call(this.connection, 'GET', path, { query: { wait: POLL_WAIT } }));
164
- }
165
- const result = run.results?.[task];
166
- if (truncated && result) {
167
- // the end of a long log is kept in the result
168
- for (const line of ['… the server cut the log off at its limit, the last lines were:', ...result.lastLog]) {
169
- lines.push(line);
170
- options.onLine?.(line);
171
- }
172
- }
173
- return {
174
- run,
175
- result,
176
- output: lines.join('\n'),
177
- truncated,
178
- exitCode: exitCodeOf(result),
179
- };
180
- }
181
- finally {
182
- options.signal?.removeEventListener('abort', cancel);
183
- }
184
140
  }
185
141
  /**
186
142
  * A shell command on the sandbox: the shell task of the build file with the command as
@@ -245,6 +201,88 @@ function oneAtATime() {
245
201
  };
246
202
  }
247
203
  exports.oneAtATime = oneAtATime;
204
+ // a run that is over, its state does not change any more
205
+ exports.FINAL_RUN_STATES = ['succeeded', 'failed', 'cancelled', 'rejected'];
206
+ // Submits a run, as `POST /v1/runs` takes it, and answers it as the server queued it or
207
+ // holds it for an approval.
208
+ async function submitRun(connection, request) {
209
+ return json(await call(connection, 'POST', '/v1/runs', { body: request }));
210
+ }
211
+ exports.submitRun = submitRun;
212
+ /**
213
+ * Follows a run to its end: its log as it grows, and its state. A signal cancels the run
214
+ * on the server and fails the follow.
215
+ */
216
+ async function followRun(connection, submitted, options = {}) {
217
+ const path = `/v1/runs/${encodeURIComponent(submitted.id)}`;
218
+ let cancelling;
219
+ const cancel = () => {
220
+ cancelling ?? (cancelling = call(connection, 'POST', `${path}/cancel`).catch(() => undefined));
221
+ };
222
+ options.signal?.addEventListener('abort', cancel, { once: true });
223
+ const lines = [];
224
+ let from = 1;
225
+ let truncated = false;
226
+ const add = (line) => {
227
+ lines.push(line);
228
+ options.onLine?.(line);
229
+ };
230
+ const readLog = async () => {
231
+ for (;;) {
232
+ const page = await json(await call(connection, 'GET', `${path}/logs`, { query: { task: options.task, from, limit: 1000 } }));
233
+ truncated = page.truncated;
234
+ for (const line of page.lines) {
235
+ add(line);
236
+ from = line.line + 1;
237
+ }
238
+ if (!page.next) {
239
+ return;
240
+ }
241
+ }
242
+ };
243
+ try {
244
+ let run = submitted;
245
+ let waitingSaid = false;
246
+ for (;;) {
247
+ if (options.signal?.aborted) {
248
+ // the process may exit after this, so the cancel has to reach the server first
249
+ cancel();
250
+ let timer;
251
+ await Promise.race([cancelling, new Promise((resolve) => (timer = setTimeout(resolve, CANCEL_WAIT_MS)))]);
252
+ clearTimeout(timer);
253
+ throw new SandboxClientError('the run was cancelled');
254
+ }
255
+ if (run.state === 'awaiting-approval' && !waitingSaid) {
256
+ waitingSaid = true;
257
+ options.onWaiting?.(run);
258
+ }
259
+ await readLog();
260
+ if (exports.FINAL_RUN_STATES.includes(run.state)) {
261
+ break;
262
+ }
263
+ run = await json(await call(connection, 'GET', path, { query: { wait: POLL_WAIT } }));
264
+ }
265
+ if (truncated) {
266
+ // the end of a long log is kept in the results
267
+ const tasks = options.task !== undefined ? [options.task] : Object.keys(run.results ?? {});
268
+ for (const task of tasks) {
269
+ const result = run.results?.[task];
270
+ if (!result) {
271
+ continue;
272
+ }
273
+ const said = options.task !== undefined ? 'the last lines were' : `the last lines of ${task} were`;
274
+ for (const text of [`… the server cut the log off at its limit, ${said}:`, ...result.lastLog]) {
275
+ add({ line: 0, task, stream: 'stdout', text });
276
+ }
277
+ }
278
+ }
279
+ return { run, lines, truncated };
280
+ }
281
+ finally {
282
+ options.signal?.removeEventListener('abort', cancel);
283
+ }
284
+ }
285
+ exports.followRun = followRun;
248
286
  function exitCodeOf(result) {
249
287
  if (!result) {
250
288
  return null;
@@ -36,28 +36,39 @@ exports.apply = apply;
36
36
  * of a policy).
37
37
  */
38
38
  async function replace(instance, spec) {
39
- let current;
40
- try {
41
- current = await instance.objectApi.read(spec);
42
- }
43
- catch (e) {
44
- if (statusCodeOf(e) !== 404) {
45
- throw e;
39
+ for (let attempt = 1;; attempt++) {
40
+ let current;
41
+ try {
42
+ current = await instance.objectApi.read(spec);
43
+ }
44
+ catch (e) {
45
+ if (statusCodeOf(e) !== 404) {
46
+ throw e;
47
+ }
48
+ try {
49
+ return await instance.objectApi.create(spec);
50
+ }
51
+ catch (createError) {
52
+ if (statusCodeOf(createError) !== 409) {
53
+ throw createError;
54
+ }
55
+ current = await instance.objectApi.read(spec);
56
+ }
46
57
  }
47
58
  try {
48
- return await instance.objectApi.create(spec);
59
+ return await instance.objectApi.replace({
60
+ ...spec,
61
+ metadata: { ...spec.metadata, resourceVersion: current.metadata?.resourceVersion },
62
+ });
49
63
  }
50
- catch (createError) {
51
- if (statusCodeOf(createError) !== 409) {
52
- throw createError;
64
+ catch (e) {
65
+ // replaced by someone else since it was read (the server sets up a caller's
66
+ // namespace for a run and for a sandbox at the same time): read it again
67
+ if (statusCodeOf(e) !== 409 || attempt >= 3) {
68
+ throw e;
53
69
  }
54
- current = await instance.objectApi.read(spec);
55
70
  }
56
71
  }
57
- return await instance.objectApi.replace({
58
- ...spec,
59
- metadata: { ...spec.metadata, resourceVersion: current.metadata?.resourceVersion },
60
- });
61
72
  }
62
73
  exports.replace = replace;
63
74
  //# sourceMappingURL=apply.js.map
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hasOutputsFlatteningAlike = exports.hasPathsEndingAlike = exports.byDepth = exports.getClaimSubPath = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const path_1 = require("path");
6
+ const portable_path_1 = require("../planner/utils/portable-path");
7
+ // The paths a work item keeps in its claim. Its consumers mount its outputs by the same
8
+ // paths, so they find the same folders.
9
+ function getClaimPaths(work) {
10
+ const data = work.data;
11
+ const paths = [];
12
+ if ('mounts' in data) {
13
+ paths.push(...data.mounts.map((mount) => mount.containerPath));
14
+ }
15
+ if ('src' in data) {
16
+ paths.push(...data.src.filter((src) => !src.inherited).map((src) => src.absolutePath));
17
+ }
18
+ if ('generates' in data) {
19
+ paths.push(...data.generates.filter((generate) => !generate.inherited).map((generate) => generate.path));
20
+ }
21
+ if ('volumes' in data) {
22
+ paths.push(...data.volumes.filter((volume) => !volume.inherited).map((volume) => volume.containerPath));
23
+ }
24
+ return paths;
25
+ }
26
+ // Where `path` is in the claim of `work`, relative to its root. A path is named after its
27
+ // last segment, so claims without two such paths are laid out as they always were (a
28
+ // service's data among them). Of the paths with the same last segment, the least deep one
29
+ // (then the first in sorted order) keeps the name, and the others also carry a hash of
30
+ // their path. Both go by the path
31
+ // relative to the project, as the task's id does, so a run in another checkout directory
32
+ // (each run of a server has its own) or with its `generates` in another order finds the
33
+ // same folders in a kept claim.
34
+ function getClaimSubPath(work, path) {
35
+ const name = (0, path_1.basename)(path) || 'root';
36
+ const alike = getClaimPaths(work).filter((other) => other !== path && (0, path_1.basename)(other) === (0, path_1.basename)(path));
37
+ if (alike.length === 0) {
38
+ return name;
39
+ }
40
+ const own = (0, portable_path_1.portablePath)(work.data.projectRoot, path);
41
+ const first = [own, ...alike.map((other) => (0, portable_path_1.portablePath)(work.data.projectRoot, other))].sort(byDepth)[0];
42
+ if (first === own) {
43
+ return name;
44
+ }
45
+ return `${name}-${(0, crypto_1.createHash)('sha256').update(own).digest('hex').slice(0, 12)}`;
46
+ }
47
+ exports.getClaimSubPath = getClaimSubPath;
48
+ function byDepth(a, b) {
49
+ return a.split('/').length - b.split('/').length || (a < b ? -1 : a > b ? 1 : 0);
50
+ }
51
+ exports.byDepth = byDepth;
52
+ // Whether two different paths of the claim of `work` end alike. Before getClaimSubPath
53
+ // named them apart, they shared one folder, and outputs made then are mixed.
54
+ function hasPathsEndingAlike(work) {
55
+ const paths = new Set(getClaimPaths(work));
56
+ return new Set([...paths].map((path) => (0, path_1.basename)(path))).size < paths.size;
57
+ }
58
+ exports.hasPathsEndingAlike = hasPathsEndingAlike;
59
+ // Whether two outputs of `work` flatten to the same name of a cache archive (`a-b` and
60
+ // `a/b`, see getArchivePaths). Before they were named apart, one archive held both.
61
+ function hasOutputsFlatteningAlike(work) {
62
+ const data = work.data;
63
+ if (!('generates' in data) || !('cwd' in data)) {
64
+ return false;
65
+ }
66
+ const paths = new Set(data.generates.filter((generate) => !generate.inherited).map((generate) => generate.path));
67
+ const names = new Set([...paths].map((path) => (0, path_1.relative)(data.cwd, path).split(path_1.sep).join('-')));
68
+ return names.size < paths.size;
69
+ }
70
+ exports.hasOutputsFlatteningAlike = hasOutputsFlatteningAlike;
71
+ //# sourceMappingURL=claim-layout.js.map
@@ -36,7 +36,7 @@ async function ensureHttpRoute(instance, env, route, service) {
36
36
  ],
37
37
  backendRefs: [
38
38
  {
39
- name: (0, resources_1.getResourceName)(service),
39
+ name: (0, resources_1.getResourceName)(service, env),
40
40
  port: route.servicePort ?? service.data.ports[0].containerPort,
41
41
  },
42
42
  ],
@@ -21,7 +21,7 @@ async function ensureKubernetesDeploymentExists(instance, env, service, persiste
21
21
  // the needed services by name, and the proxy of a limited network
22
22
  network) {
23
23
  const envs = (0, replace_env_variables_1.getEnvironmentVariables)(service.data.envs);
24
- const name = (0, resources_1.getResourceName)(service);
24
+ const name = (0, resources_1.getResourceName)(service, env);
25
25
  const healthcheckCommand = (0, work_service_1.getHealthcheckCommand)(service.data);
26
26
  const serviceCommand = (0, work_service_1.getServiceCommand)(service.data, { kubernetes: true });
27
27
  const probe = healthcheckCommand
@@ -47,25 +47,21 @@ network) {
47
47
  'hammerkit.dev/version': (0, version_1.getVersion)(),
48
48
  },
49
49
  labels: {
50
- 'hammerkit.dev/id': service.id(),
50
+ ...(0, resources_1.getItemLabels)(service, env),
51
51
  'hammerkit.dev/state': stateKey,
52
- ...(0, job_placement_1.getRunLabels)(env),
53
52
  [exports.DEFINITION_LABEL]: (0, service_definition_1.getServiceDefinitionHash)(service),
54
53
  },
55
54
  },
56
55
  spec: {
57
56
  selector: {
58
- matchLabels: {
59
- 'hammerkit.dev/id': service.id(),
60
- },
57
+ matchLabels: (0, resources_1.getItemLabels)(service, env),
61
58
  },
62
59
  replicas: 1,
63
60
  template: {
64
61
  metadata: {
65
62
  labels: {
66
- 'hammerkit.dev/id': service.id(),
63
+ ...(0, resources_1.getItemLabels)(service, env),
67
64
  'hammerkit.dev/state': stateKey,
68
- ...(0, job_placement_1.getRunLabels)(env),
69
65
  ...workspace.labels,
70
66
  ...(0, network_policy_1.getNetworkLabels)(env, service),
71
67
  },
@@ -127,7 +123,7 @@ network) {
127
123
  return;
128
124
  }
129
125
  await (0, await_running_state_1.awaitDeployRunningState)(instance, env, name, {
130
- podSelector: `hammerkit.dev/id=${service.id()}`,
126
+ podSelector: (0, resources_1.getItemSelector)(service, env),
131
127
  timeout: service.data.healthcheck?.timeout ?? null,
132
128
  timeoutMessage: service.data.healthcheck
133
129
  ? (0, work_healthcheck_1.getHealthcheckTimeoutMessage)(service.name, service.data.healthcheck)
@@ -65,7 +65,7 @@ async function ensureKubernetesPersistentVolumeClaimExists(instance, env, volume
65
65
  },
66
66
  },
67
67
  };
68
- service.status.console('stdout', `ensure volume ${volume.name}`);
68
+ service.status.write('debug', `ensure volume ${volume.name}`);
69
69
  // Don't block on the PVC reaching "Bound". Storage classes with
70
70
  // volumeBindingMode WaitForFirstConsumer (docker-desktop's "standard",
71
71
  // k3d/k3s local-path, most cloud defaults) keep the claim Pending until a pod
@@ -4,11 +4,10 @@ exports.ensureKubernetesServiceExists = void 0;
4
4
  const apply_1 = require("./apply");
5
5
  const resources_1 = require("./resources");
6
6
  const version_1 = require("../version");
7
- const job_placement_1 = require("./job-placement");
8
7
  // A service without ports gets a headless Service: a ClusterIP one needs a port,
9
8
  // and its tasks reach every port of the pod, as on docker.
10
9
  async function ensureKubernetesServiceExists(instance, env, service) {
11
- const name = (0, resources_1.getResourceName)(service);
10
+ const name = (0, resources_1.getResourceName)(service, env);
12
11
  const headless = service.data.ports.length === 0;
13
12
  const svc = {
14
13
  kind: 'Service',
@@ -19,17 +18,13 @@ async function ensureKubernetesServiceExists(instance, env, service) {
19
18
  annotations: {
20
19
  'hammerkit.dev/version': (0, version_1.getVersion)(),
21
20
  },
22
- labels: {
23
- 'hammerkit.dev/id': service.id(),
24
- ...(0, job_placement_1.getRunLabels)(env),
25
- },
21
+ labels: (0, resources_1.getItemLabels)(service, env),
26
22
  },
27
23
  spec: {
28
24
  type: 'ClusterIP',
29
25
  ...(headless ? { clusterIP: 'None' } : {}),
30
- selector: {
31
- 'hammerkit.dev/id': service.id(),
32
- },
26
+ // the pods of the service of this run, on a server where runs share the namespace
27
+ selector: (0, resources_1.getItemLabels)(service, env),
33
28
  ...(headless
34
29
  ? {}
35
30
  : {
@@ -1,9 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ensurePersistentData = exports.clearClaim = exports.getPodForPersistence = void 0;
3
+ exports.ensurePersistentData = exports.packSource = exports.clearClaim = exports.getPodForPersistence = void 0;
4
4
  const apply_1 = require("./apply");
5
5
  const path_1 = require("path");
6
- const tar_1 = require("tar");
6
+ const tar_1 = require("../utils/tar");
7
7
  const resources_1 = require("./resources");
8
8
  const await_running_state_1 = require("./await-running-state");
9
9
  const sleep_1 = require("../utils/sleep");
@@ -67,17 +67,14 @@ async function getPodForPersistence(instance, env, service, persistence, type, f
67
67
  for (const volume of persistence.volumes) {
68
68
  await (0, ensure_kubernetes_persistent_volume_claim_exists_1.ensureKubernetesPersistentVolumeClaimExists)(instance, env, volume, service, persistence.sizes[volume.name], persistence.kept.includes(volume.name));
69
69
  }
70
- const name = (0, resources_1.getResourceName)(service, type === 'write' ? '-upload' : '-download');
70
+ const name = (0, resources_1.getResourceName)(service, env, type === 'write' ? '-upload' : '-download');
71
71
  const podSpec = {
72
72
  kind: 'Pod',
73
73
  apiVersion: 'v1',
74
74
  metadata: {
75
75
  namespace: env.namespace,
76
76
  name,
77
- labels: {
78
- 'hammerkit.dev/id': service.id(),
79
- ...(0, job_placement_1.getRunLabels)(env),
80
- },
77
+ labels: (0, resources_1.getItemLabels)(service, env),
81
78
  annotations: {
82
79
  'hammerkit.dev/version': (0, version_1.getVersion)(),
83
80
  },
@@ -104,18 +101,18 @@ async function getPodForPersistence(instance, env, service, persistence, type, f
104
101
  volumes: persistence.volumes,
105
102
  },
106
103
  };
107
- service.status.console('stdout', `create pod ${name}`);
104
+ service.status.write('debug', `create pod ${name}`);
108
105
  await removeStalePod(instance, env.namespace, name);
109
106
  const pod = await (0, apply_1.apply)(instance, podSpec);
110
107
  if (pod.status?.phase !== 'Running') {
111
- service.status.console('stdout', `await running state ${name}`);
108
+ service.status.write('debug', `await running state ${name}`);
112
109
  await (0, await_running_state_1.awaitRunningState)(instance, env, name, 'Running');
113
110
  }
114
111
  try {
115
112
  await fn(name);
116
113
  }
117
114
  finally {
118
- service.status.console('stdout', `delete pod ${name}`);
115
+ service.status.write('debug', `delete pod ${name}`);
119
116
  await instance.coreApi.deleteNamespacedPod({ name, namespace: env.namespace });
120
117
  }
121
118
  }
@@ -124,7 +121,7 @@ exports.getPodForPersistence = getPodForPersistence;
124
121
  // the task runs on it or outputs are restored into it, so it holds what a new one would
125
122
  async function clearClaim(instance, env, work, pod) {
126
123
  const targetPath = `/dev/hammerkit/${work.id()}`;
127
- work.status.console('stdout', `clear what an earlier run left in ${targetPath}`);
124
+ work.status.write('info', `clear what an earlier run left in ${targetPath}`);
128
125
  let failure = null;
129
126
  const res = await instance.exec.exec(env.namespace, pod, 'volume', ['find', targetPath, '-mindepth', '1', '-maxdepth', '1', '-exec', 'rm', '-rf', '{}', '+'], process.stdout, process.stderr, null, false, (status) => {
130
127
  if (status.status !== 'Success') {
@@ -137,6 +134,25 @@ async function clearClaim(instance, env, work, pod) {
137
134
  });
138
135
  }
139
136
  exports.clearClaim = clearClaim;
137
+ // The archive that uploads a source into the claim: its file or directory, at its place in
138
+ // the claim (see getClaimSubPath) instead of under its own name
139
+ function packSource(source) {
140
+ const fileItem = (0, path_1.basename)(source.localPath);
141
+ return stream_1.Readable.from((0, tar_1.packArchive)({
142
+ cwd: (0, path_1.dirname)(source.localPath),
143
+ filter(path) {
144
+ return source.matcher(path, source.localPath);
145
+ },
146
+ onWriteEntry(entry) {
147
+ entry.path = source.subPath + entry.path.slice(fileItem.length);
148
+ // the second of two hard-linked files links to the first by its path in the archive
149
+ if (entry.type === 'Link' && entry.linkpath?.startsWith(fileItem)) {
150
+ entry.linkpath = source.subPath + entry.linkpath.slice(fileItem.length);
151
+ }
152
+ },
153
+ }, [fileItem]));
154
+ }
155
+ exports.packSource = packSource;
140
156
  async function ensurePersistentData(instance, env, environment, service, persistence,
141
157
  // the kept claim of the work holds what an earlier run left, which goes first
142
158
  options = {}) {
@@ -153,7 +169,7 @@ options = {}) {
153
169
  if (persistence.sources.length === 0 && !options.reset) {
154
170
  return;
155
171
  }
156
- service.status.console('stdout', 'start upload container');
172
+ service.status.write('debug', 'start upload container');
157
173
  await getPodForPersistence(instance, env, service, persistence, 'write', async (name) => {
158
174
  if (options.reset) {
159
175
  await clearClaim(instance, env, service, name);
@@ -163,14 +179,8 @@ options = {}) {
163
179
  if (!(await environment.file.exists(source.localPath))) {
164
180
  continue;
165
181
  }
166
- const fileItem = (0, path_1.basename)(source.localPath);
167
- const data = stream_1.Readable.from((0, tar_1.create)({
168
- cwd: (0, path_1.dirname)(source.localPath),
169
- filter(path) {
170
- return source.matcher(path, source.localPath);
171
- },
172
- }, [fileItem]));
173
- service.status.console('stdout', `upload ${source.localPath}`);
182
+ const data = packSource(source);
183
+ service.status.write('info', `upload ${source.localPath}`);
174
184
  const targetPath = `/dev/hammerkit/${service.id()}`;
175
185
  const res = await instance.exec.exec(env.namespace, name, 'volume', ['tar', 'xf', '-', '-C', targetPath], process.stdout, process.stderr, data, false, (status) => {
176
186
  service.status.write('debug', `upload ${source.localPath} to ${name}: ${status}`);
@@ -5,7 +5,7 @@ const resources_1 = require("./resources");
5
5
  // The address a task reaches the service on: its clusterIP, or for a headless
6
6
  // Service (a service without ports) the address of its ready pod.
7
7
  async function getServiceIp(instance, env, workService) {
8
- const name = (0, resources_1.getResourceName)(workService);
8
+ const name = (0, resources_1.getResourceName)(workService, env);
9
9
  const svc = await instance.coreApi.readNamespacedService({ name, namespace: env.namespace });
10
10
  const clusterIP = svc.spec?.clusterIP ?? null;
11
11
  if (clusterIP !== 'None') {
@@ -13,7 +13,7 @@ async function getServiceIp(instance, env, workService) {
13
13
  }
14
14
  const pods = await instance.coreApi.listNamespacedPod({
15
15
  namespace: env.namespace,
16
- labelSelector: `hammerkit.dev/id=${workService.id()}`,
16
+ labelSelector: (0, resources_1.getItemSelector)(workService, env),
17
17
  });
18
18
  const ready = pods.items.find((pod) => !pod.metadata?.deletionTimestamp &&
19
19
  pod.status?.podIP &&