hammerkit 1.7.0 → 1.8.0

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 (31) hide show
  1. package/README.md +3 -3
  2. package/build.schema.json +10 -13
  3. package/dist/docker/using-container.js +12 -0
  4. package/dist/executer/docker-task.js +16 -2
  5. package/dist/executer/execute-docker.js +18 -2
  6. package/dist/executer/execute-work-task.js +17 -2
  7. package/dist/executer/execution-steps.js +3 -8
  8. package/dist/executer/get-container-binds.js +13 -1
  9. package/dist/index.js +14 -1
  10. package/dist/kubernetes/apply.js +6 -7
  11. package/dist/kubernetes/await-running-state.js +32 -46
  12. package/dist/kubernetes/ensure-http-route.js +7 -10
  13. package/dist/kubernetes/ensure-namespace.js +9 -10
  14. package/dist/kubernetes/ensure-persistent-data.js +6 -5
  15. package/dist/kubernetes/get-service-ip.js +2 -2
  16. package/dist/kubernetes/kubernetes-instance.js +0 -4
  17. package/dist/kubernetes/remove-persistent-data.js +16 -6
  18. package/dist/kubernetes/resolve-pod-name.js +7 -7
  19. package/dist/planner/utils/append-work-environment.js +1 -1
  20. package/dist/planner/utils/get-outputs-to-reset.js +20 -0
  21. package/dist/planner/utils/parse-work-generate.js +2 -2
  22. package/dist/planner/work-runtime-docker.js +6 -1
  23. package/dist/planner/work-runtime-kubernetes.js +66 -48
  24. package/dist/planner/work-runtime-local.js +22 -5
  25. package/dist/schema/build-file-environment-schema-http-route.js +15 -0
  26. package/dist/schema/build-file-environment-schema.js +8 -2
  27. package/dist/schema/build-file-schema.js +1 -1
  28. package/dist/schema/envs-schema.js +1 -1
  29. package/package.json +9 -9
  30. package/dist/kubernetes/ensure-ingress.js +0 -54
  31. package/dist/schema/build-file-environment-schema-ingress.js +0 -14
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # About
2
2
 
3
- <figure><img src="docs/hammerkit-small.png" width="144" alt=""><figcaption></figcaption></figure>
3
+ <img src="website/public/logo.png" width="144" alt="hammerkit">
4
4
 
5
5
  ## What is hammerkit?
6
6
 
@@ -9,7 +9,7 @@ Hammerkit is a build tool intended to build software projects in **containers**
9
9
  * the build tools come from the container image, so they are the same across all machines.
10
10
  * the build process is isolated since every file in the container has to be declared as a source or an output. Therefore, side effects can be reduced.
11
11
 
12
- For a fuller pitch — including how it compares to Make, npm scripts, Taskfile and Earthly — see [Why hammerkit](docs/why-hammerkit.md).
12
+ For a fuller pitch — including how it compares to Make, npm scripts, Taskfile and Earthly — see [Why hammerkit](https://hammerkit.dev/docs/why-hammerkit).
13
13
 
14
14
  There are a lot of containerized CI systems that allow writing containerized builds today, but they have shortcomings that hammerkit tries to address:
15
15
 
@@ -23,4 +23,4 @@ Hammerkit, on the other hand, knows the source and output files of each build st
23
23
 
24
24
  ## Where to go next?
25
25
 
26
- Check out the [docs](https://no0dles.gitbook.io/hammerkit/) for more info about how hammerkit can be used.
26
+ Check out the [docs on hammerkit.dev](https://hammerkit.dev) for more info about how hammerkit can be used.
package/build.schema.json CHANGED
@@ -12,7 +12,7 @@
12
12
  "number"
13
13
  ]
14
14
  },
15
- "description": "Environment values for the current build task\nhttps://no0dles.gitbook.io/hammerkit/build-file/environment-variables"
15
+ "description": "Environment values for the current build task\nhttps://hammerkit.dev/docs/build-file/environment-variables"
16
16
  },
17
17
  "tasks": {
18
18
  "type": "object",
@@ -61,7 +61,7 @@
61
61
  },
62
62
  "envs": {
63
63
  "$ref": "#/definitions/hammerkit/properties/envs",
64
- "description": "Environment values for the current build task\nhttps://no0dles.gitbook.io/hammerkit/build-file/environment-variables"
64
+ "description": "Environment values for the current build task\nhttps://hammerkit.dev/docs/build-file/environment-variables"
65
65
  },
66
66
  "labels": {
67
67
  "type": "object",
@@ -280,7 +280,7 @@
280
280
  },
281
281
  "envs": {
282
282
  "$ref": "#/definitions/hammerkit/properties/envs",
283
- "description": "Environment values for the current build task\nhttps://no0dles.gitbook.io/hammerkit/build-file/environment-variables"
283
+ "description": "Environment values for the current build task\nhttps://hammerkit.dev/docs/build-file/environment-variables"
284
284
  },
285
285
  "mounts": {
286
286
  "type": "array",
@@ -431,18 +431,11 @@
431
431
  "kubeconfig": {
432
432
  "type": "string"
433
433
  },
434
- "ingresses": {
434
+ "httpRoutes": {
435
435
  "type": "array",
436
436
  "items": {
437
437
  "type": "object",
438
438
  "properties": {
439
- "kind": {
440
- "type": "string",
441
- "enum": [
442
- "ingress",
443
- "httproute"
444
- ]
445
- },
446
439
  "host": {
447
440
  "type": "string"
448
441
  },
@@ -464,10 +457,14 @@
464
457
  },
465
458
  "required": [
466
459
  "host",
467
- "service"
460
+ "service",
461
+ "gateway"
468
462
  ],
469
463
  "additionalProperties": false
470
464
  }
465
+ },
466
+ "ingresses": {
467
+ "not": {}
471
468
  }
472
469
  },
473
470
  "required": [
@@ -608,7 +605,7 @@
608
605
  }
609
606
  },
610
607
  "additionalProperties": false,
611
- "description": "Build file with support for containerization\nhttps://no0dles.gitbook.io/hammerkit/build-file"
608
+ "description": "Build file with support for containerization\nhttps://hammerkit.dev/docs/build-file"
612
609
  }
613
610
  },
614
611
  "$schema": "http://json-schema.org/draft-07/schema#"
@@ -39,6 +39,18 @@ async function usingContainer(docker, item, createOptions, stateKey, callback) {
39
39
  if (container) {
40
40
  await (0, remove_container_1.removeContainer)(container);
41
41
  }
42
+ // A task run that failed has written into the outputs an earlier run's
43
+ // container still vouches for (its state label is what currentStateKey
44
+ // reads), so that record goes too.
45
+ if (stateKey) {
46
+ const containers = await docker.listContainers({
47
+ all: true,
48
+ filters: { label: [`hammerkit-id=${(0, work_instance_id_1.getWorkInstanceId)(item)}`] },
49
+ });
50
+ for (const old of containers) {
51
+ await (0, remove_container_1.removeContainer)(docker.getContainer(old.Id));
52
+ }
53
+ }
42
54
  }
43
55
  }
44
56
  catch (e) {
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.dockerTask = exports.getNeedsNetwork = void 0;
3
+ exports.dockerTask = exports.buildCreateOptions = exports.getNeedsNetwork = void 0;
4
4
  const service_dns_1 = require("./service-dns");
5
5
  const execute_docker_1 = require("./execute-docker");
6
6
  const abort_1 = require("./abort");
@@ -13,6 +13,7 @@ const replace_env_variables_1 = require("../environment/replace-env-variables");
13
13
  const get_container_binds_1 = require("./get-container-binds");
14
14
  const get_service_containers_1 = require("./get-service-containers");
15
15
  const work_instance_id_1 = require("../planner/work-instance-id");
16
+ const get_outputs_to_reset_1 = require("../planner/utils/get-outputs-to-reset");
16
17
  function getNeedsNetwork(serviceContainers, needs) {
17
18
  const links = [];
18
19
  const hosts = [];
@@ -35,13 +36,17 @@ function buildCreateOptions(item, stateKey, serviceContainers, environment) {
35
36
  const network = getNeedsNetwork(serviceContainers, item.needs);
36
37
  const binds = (0, get_container_binds_1.getContainerBinds)(item);
37
38
  const envs = (0, replace_env_variables_1.getEnvironmentVariables)(item.data.envs);
39
+ // Running as the host's uid (Linux), the user has no home in the image and
40
+ // Docker sets HOME=/, which isn't writable: tools then fail to create their
41
+ // caches. Set on the container only, so it's not part of the cache key.
42
+ const home = item.data.user && !('HOME' in envs) ? { HOME: '/tmp' } : {};
38
43
  return {
39
44
  abortSignal: environment.abortCtrl.signal,
40
45
  Image: item.data.image,
41
46
  Tty: true,
42
47
  Entrypoint: [item.data.shell],
43
48
  Cmd: ['-c', 'sleep 3600'],
44
- Env: Object.entries(envs).map(([key, value]) => `${key}=${value}`),
49
+ Env: Object.entries({ ...envs, ...home }).map(([key, value]) => `${key}=${value}`),
45
50
  WorkingDir: (0, execute_docker_1.convertToPosixPath)(item.data.cwd),
46
51
  Labels: {
47
52
  app: 'hammerkit',
@@ -58,9 +63,15 @@ function buildCreateOptions(item, stateKey, serviceContainers, environment) {
58
63
  },
59
64
  };
60
65
  }
66
+ exports.buildCreateOptions = buildCreateOptions;
61
67
  async function dockerTask(docker, item, environment, options) {
62
68
  item.status.write('info', `execute ${item.name} in container`);
63
69
  try {
70
+ const outputsToReset = (0, get_outputs_to_reset_1.getOutputsToReset)(item.data);
71
+ // outputs on the host: file outputs (bind mounts) and exported directories
72
+ for (const generate of outputsToReset.filter((g) => g.isFile || g.export)) {
73
+ await environment.file.remove(generate.path);
74
+ }
64
75
  await (0, execution_steps_1.prepareMounts)(item, environment);
65
76
  (0, abort_1.checkForAbort)(options.abort);
66
77
  await (0, execution_steps_1.pullImage)(item, docker);
@@ -72,6 +83,9 @@ async function dockerTask(docker, item, environment, options) {
72
83
  (0, print_container_options_1.printContainerOptions)(item.status, containerOptions);
73
84
  await (0, using_container_1.usingContainer)(docker, item, containerOptions, options.stateKey, async (container) => {
74
85
  await (0, execution_steps_1.setUserPermissions)(item, container, environment);
86
+ for (const generate of outputsToReset.filter((g) => !g.isFile)) {
87
+ await (0, execute_docker_1.clearContainerDirectory)(item.status, environment, container, generate.path);
88
+ }
75
89
  for (const cmd of item.data.cmds) {
76
90
  (0, abort_1.checkForAbort)(options.abort);
77
91
  item.status.write('info', `execute cmd "${cmd.cmd}" in container`);
@@ -3,16 +3,22 @@ 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.execCommand = exports.convertToPosixPath = exports.startContainer = exports.getContainerCli = void 0;
6
+ exports.clearContainerDirectory = exports.execCommand = exports.convertToPosixPath = exports.startContainer = exports.getContainerCli = exports.createDockerClient = void 0;
7
7
  const stream_1 = require("../docker/stream");
8
8
  const dockerode_1 = __importDefault(require("dockerode"));
9
9
  const path_1 = require("path");
10
10
  const os_1 = require("os");
11
11
  const abort_event_1 = require("../utils/abort-event");
12
12
  let dockerInstance = null;
13
+ // Without options dockerode reads DOCKER_HOST and the TLS variables; passing
14
+ // `{ host: undefined }` would overwrite the host it parsed from DOCKER_HOST.
15
+ function createDockerClient(host) {
16
+ return host ? new dockerode_1.default({ host }) : new dockerode_1.default();
17
+ }
18
+ exports.createDockerClient = createDockerClient;
13
19
  function getContainerCli(workEnvironment) {
14
20
  if (!dockerInstance) {
15
- dockerInstance = new dockerode_1.default({ host: workEnvironment.host });
21
+ dockerInstance = createDockerClient(workEnvironment.host);
16
22
  }
17
23
  return dockerInstance;
18
24
  }
@@ -127,4 +133,14 @@ async function execCommand(status, environment, container, cwd, cmd, user, timeo
127
133
  });
128
134
  }
129
135
  exports.execCommand = execCommand;
136
+ // Empty a directory inside a running container, as root. Volumes are emptied
137
+ // this way rather than recreated: Docker refuses to remove a volume while any
138
+ // container (a dependent's paused one, say) still mounts it.
139
+ async function clearContainerDirectory(status, environment, container, path) {
140
+ const result = await execCommand(status, environment, container, '/', ['sh', '-c', 'rm -rf "$1"/* "$1"/.[!.]* "$1"/..?*', 'sh', convertToPosixPath(path)], null, undefined, environment.abortCtrl.signal);
141
+ if (result.type !== 'result' || result.result.ExitCode !== 0) {
142
+ throw new Error(`unable to empty ${path}`);
143
+ }
144
+ }
145
+ exports.clearContainerDirectory = clearContainerDirectory;
130
146
  //# sourceMappingURL=execute-docker.js.map
@@ -51,6 +51,15 @@ function withDeadline(parent, timeout) {
51
51
  },
52
52
  };
53
53
  }
54
+ // A task without cmds only groups its dependencies (`ci: { deps: [test, build] }`).
55
+ // Having no src of its own it is never a cache hit, so its dependencies are
56
+ // always checked and their outputs restored. When every one of them was a hit,
57
+ // nothing ran: report the group as cached too. Reporting only, the cache
58
+ // decision is untouched.
59
+ function onlyGroupsCacheHits(work) {
60
+ return (work.data.cmds.length === 0 &&
61
+ work.deps.every((dep) => dep.state.current.type === 'completed' && dep.state.current.cached));
62
+ }
54
63
  async function executeWorkTask(work, environment, options,
55
64
  // when set, the task was not requested and only runs if one of these tasks
56
65
  // depending on it misses the cache (see executeWorkTree)
@@ -139,12 +148,13 @@ dependents = null) {
139
148
  if (work.state.current.type === 'running') {
140
149
  work.status.write('debug', 'completed for state key ' + cacheState.stateKey);
141
150
  await pushToBackend(work, environment, cacheState, options);
151
+ const cached = onlyGroupsCacheHits(work);
142
152
  work.state.set({
143
153
  stateKey: cacheState.stateKey,
144
154
  type: 'completed',
145
- cached: false,
155
+ cached,
146
156
  duration: (0, states_1.getDuration)(started),
147
- missCauses,
157
+ missCauses: cached ? undefined : missCauses,
148
158
  });
149
159
  }
150
160
  else {
@@ -170,6 +180,11 @@ dependents = null) {
170
180
  errorMessage: (0, log_1.getErrorMessage)(e),
171
181
  stateKey: null,
172
182
  });
183
+ // Stop the run like a failing command does: dependents wait for this task
184
+ // to complete, which it now never will.
185
+ if (!options.watch) {
186
+ environment.abortCtrl.abort();
187
+ }
173
188
  }
174
189
  }
175
190
  }
@@ -26,18 +26,13 @@ async function prepareVolume(item, docker) {
26
26
  }
27
27
  }
28
28
  if ((0, work_task_1.isContainerWorkTask)(item.data)) {
29
+ // a task's outputs are emptied inside its container (see dockerTask)
29
30
  for (const generate of item.data.generates) {
30
31
  if (generate.isFile) {
31
32
  continue;
32
33
  }
33
- if (generate.resetOnChange && !generate.inherited) {
34
- item.status.write('debug', 'recreate volume');
35
- await (0, get_docker_executor_1.recreateVolume)(docker, item.status, generate.volumeName);
36
- }
37
- else {
38
- item.status.write('debug', 'ensure volume exists');
39
- await (0, get_docker_executor_1.ensureVolumeExists)(docker, item.status, generate.volumeName);
40
- }
34
+ item.status.write('debug', 'ensure volume exists');
35
+ await (0, get_docker_executor_1.ensureVolumeExists)(docker, item.status, generate.volumeName);
41
36
  }
42
37
  }
43
38
  }
@@ -1,10 +1,22 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getContainerBinds = void 0;
4
+ const path_1 = require("path");
5
+ function isInside(path, parent) {
6
+ const rel = (0, path_1.relative)(parent, path);
7
+ return rel !== '' && !rel.startsWith('..') && !(0, path_1.isAbsolute)(rel);
8
+ }
4
9
  function getContainerBinds(item) {
10
+ const sources = item.data.src.map((s) => s.absolutePath);
5
11
  const items = [
6
12
  ...item.data.mounts,
7
- ...item.data.src.map((s) => ({ localPath: s.absolutePath, containerPath: s.absolutePath })),
13
+ // A source inside another source (a dependency's `app/app.csproj` under the
14
+ // task's own `app`) is already visible through the outer bind. Binding it
15
+ // again would nest bind mounts, which Docker Desktop then refuses to let the
16
+ // host delete while the paused container exists.
17
+ ...sources
18
+ .filter((path) => !sources.some((other) => isInside(path, other)))
19
+ .map((path) => ({ localPath: path, containerPath: path })),
8
20
  ...item.data.generates.filter((g) => !g.isFile).map((v) => ({ localPath: v.volumeName, containerPath: v.path })),
9
21
  ...item.data.generates.filter((g) => g.isFile).map((v) => ({ localPath: v.path, containerPath: v.path })),
10
22
  ];
package/dist/index.js CHANGED
@@ -10,6 +10,16 @@ const abortCtrl = new AbortController();
10
10
  process.on('SIGINT', function () {
11
11
  abortCtrl.abort();
12
12
  });
13
+ let settled = false;
14
+ // A run that never settles (a task waiting on something that can't happen)
15
+ // lets the event loop drain, and Node then exits 0: CI would report success
16
+ // for a build that never finished. Fail loudly instead.
17
+ process.on('beforeExit', () => {
18
+ if (!settled) {
19
+ process.stderr.write('hammerkit stopped before the run finished\n');
20
+ process.exitCode = 1;
21
+ }
22
+ });
13
23
  (0, run_program_1.runProgram)({
14
24
  cwd: process.cwd(),
15
25
  abortCtrl,
@@ -20,7 +30,10 @@ process.on('SIGINT', function () {
20
30
  stdout: process.stdout,
21
31
  stderr: process.stderr,
22
32
  stdoutColumns: process.stdout.columns,
23
- }, process.argv, false).catch(() => {
33
+ }, process.argv, false).then(() => {
34
+ settled = true;
35
+ }, () => {
36
+ settled = true;
24
37
  process.exit(1);
25
38
  });
26
39
  //# sourceMappingURL=index.js.map
@@ -1,21 +1,21 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.apply = exports.statusCodeOf = void 0;
4
+ // The client throws an ApiException carrying the HTTP status as `code`; watch
5
+ // errors carry it as `statusCode`.
4
6
  function statusCodeOf(e) {
5
7
  const err = e;
6
- return err?.statusCode ?? err?.response?.statusCode ?? err?.body?.code;
8
+ return typeof err?.code === 'number' ? err.code : err?.statusCode;
7
9
  }
8
10
  exports.statusCodeOf = statusCodeOf;
9
11
  async function apply(instance, spec) {
10
12
  try {
11
13
  await instance.objectApi.read(spec);
12
- const response = await instance.objectApi.patch(spec);
13
- return response.body;
14
+ return await instance.objectApi.patch(spec);
14
15
  }
15
16
  catch (e) {
16
17
  try {
17
- const response = await instance.objectApi.create(spec);
18
- return response.body;
18
+ return await instance.objectApi.create(spec);
19
19
  }
20
20
  catch (createError) {
21
21
  // Two work items can apply the same object concurrently (e.g. a volume
@@ -23,8 +23,7 @@ async function apply(instance, spec) {
23
23
  // lost the create race, the object now exists — read it back instead of
24
24
  // failing the whole item.
25
25
  if (statusCodeOf(createError) === 409) {
26
- const response = await instance.objectApi.read(spec);
27
- return response.body;
26
+ return await instance.objectApi.read(spec);
28
27
  }
29
28
  throw createError;
30
29
  }
@@ -4,25 +4,21 @@ exports.deleteJobAndWait = exports.awaitDeployRunningState = exports.deleteJob =
4
4
  const abort_1 = require("../executer/abort");
5
5
  const abort_event_1 = require("../utils/abort-event");
6
6
  const apply_1 = require("./apply");
7
- function awaitRunningState(instance, env, name, phase) {
8
- return new Promise((resolve, reject) => {
9
- const req = instance.watch.watch(`/api/v1/namespaces/${env.namespace}/pods`, {}, (type, obj) => {
10
- if (obj.metadata.name === name && obj.metadata.namespace === env.namespace && obj.status.phase === phase) {
11
- req.then((r) => r.abort());
12
- resolve();
13
- }
14
- }, (err) => {
15
- if (err.message === 'aborted') {
16
- return;
17
- }
18
- if (err) {
19
- reject(err);
20
- }
21
- else {
22
- resolve();
23
- }
24
- });
25
- });
7
+ const sleep_1 = require("../utils/sleep");
8
+ // The waits below poll instead of using the client's Watch: Watch aborts every
9
+ // request after 30s, which a pod or deployment still pulling its image outlasts.
10
+ async function awaitRunningState(instance, env, name, phase, pollInterval = 1000) {
11
+ for (;;) {
12
+ const pod = await instance.coreApi.readNamespacedPod({ name, namespace: env.namespace });
13
+ const current = pod.status?.phase;
14
+ if (current === phase) {
15
+ return;
16
+ }
17
+ if (current === 'Succeeded' || current === 'Failed') {
18
+ throw new Error(`pod ${name} ended in phase ${current} before reaching ${phase}`);
19
+ }
20
+ await (0, sleep_1.sleep)(pollInterval);
21
+ }
26
22
  }
27
23
  exports.awaitRunningState = awaitRunningState;
28
24
  // Wait for a job to finish by polling its status: resolves once it succeeded,
@@ -35,8 +31,8 @@ async function awaitJobCompletion(instance, env, name, abort, pollInterval = 100
35
31
  await deleteJob(instance, env, name);
36
32
  throw new abort_1.AbortError();
37
33
  }
38
- const job = await instance.batchApi.readNamespacedJobStatus(name, env.namespace);
39
- const status = job.body.status;
34
+ const job = await instance.batchApi.readNamespacedJobStatus({ name, namespace: env.namespace });
35
+ const status = job.status;
40
36
  if (status?.succeeded) {
41
37
  return;
42
38
  }
@@ -66,7 +62,12 @@ function sleep(ms, abort) {
66
62
  }
67
63
  async function deleteJob(instance, env, name) {
68
64
  try {
69
- await instance.batchApi.deleteNamespacedJob(name, env.namespace, undefined, undefined, 0, undefined, 'Background');
65
+ await instance.batchApi.deleteNamespacedJob({
66
+ name,
67
+ namespace: env.namespace,
68
+ gracePeriodSeconds: 0,
69
+ propagationPolicy: 'Background',
70
+ });
70
71
  }
71
72
  catch (e) {
72
73
  if ((0, apply_1.statusCodeOf)(e) !== 404) {
@@ -75,29 +76,14 @@ async function deleteJob(instance, env, name) {
75
76
  }
76
77
  }
77
78
  exports.deleteJob = deleteJob;
78
- function awaitDeployRunningState(instance, env, name) {
79
- return new Promise((resolve, reject) => {
80
- const req = instance.watch.watch(`/apis/apps/v1/namespaces/${env.namespace}/deployments`, {}, (type, obj) => {
81
- if ((obj &&
82
- obj.metadata?.name === name &&
83
- obj.metadata?.namespace === env.namespace &&
84
- obj.status?.readyReplicas) ??
85
- 0 > 0) {
86
- req.then((r) => r.abort());
87
- resolve();
88
- }
89
- }, (err) => {
90
- if (err && err.message === 'aborted') {
91
- return;
92
- }
93
- if (err) {
94
- reject(err);
95
- }
96
- else {
97
- resolve();
98
- }
99
- });
100
- });
79
+ async function awaitDeployRunningState(instance, env, name, pollInterval = 1000) {
80
+ for (;;) {
81
+ const deployment = await instance.appsApi.readNamespacedDeployment({ name, namespace: env.namespace });
82
+ if ((deployment.status?.readyReplicas ?? 0) > 0) {
83
+ return;
84
+ }
85
+ await (0, sleep_1.sleep)(pollInterval);
86
+ }
101
87
  }
102
88
  exports.awaitDeployRunningState = awaitDeployRunningState;
103
89
  // Delete a job and wait until it is gone: its name is reused for the task's next
@@ -107,7 +93,7 @@ async function deleteJobAndWait(instance, env, name, abort, pollInterval = 500)
107
93
  await deleteJob(instance, env, name);
108
94
  for (;;) {
109
95
  try {
110
- await instance.batchApi.readNamespacedJob(name, env.namespace);
96
+ await instance.batchApi.readNamespacedJob({ name, namespace: env.namespace });
111
97
  }
112
98
  catch (e) {
113
99
  if ((0, apply_1.statusCodeOf)(e) === 404) {
@@ -5,15 +5,12 @@ const apply_1 = require("./apply");
5
5
  const resources_1 = require("./resources");
6
6
  exports.HTTP_ROUTE_API_VERSION = 'gateway.networking.k8s.io/v1';
7
7
  exports.HTTP_ROUTE_KIND = 'HTTPRoute';
8
- async function ensureHttpRoute(instance, env, ingress, service) {
9
- if (!ingress.gateway) {
10
- throw new Error(`httproute for ${ingress.host} requires a "gateway" (the parent Gateway name)`);
11
- }
8
+ async function ensureHttpRoute(instance, env, route, service) {
12
9
  const resource = {
13
10
  apiVersion: exports.HTTP_ROUTE_API_VERSION,
14
11
  kind: exports.HTTP_ROUTE_KIND,
15
12
  metadata: {
16
- name: ingress.host,
13
+ name: route.host,
17
14
  namespace: env.namespace,
18
15
  labels: {
19
16
  'hammerkit.dev/id': service.id(),
@@ -22,25 +19,25 @@ async function ensureHttpRoute(instance, env, ingress, service) {
22
19
  spec: {
23
20
  parentRefs: [
24
21
  {
25
- name: ingress.gateway,
26
- ...(ingress.gatewayNamespace ? { namespace: ingress.gatewayNamespace } : {}),
22
+ name: route.gateway,
23
+ ...(route.gatewayNamespace ? { namespace: route.gatewayNamespace } : {}),
27
24
  },
28
25
  ],
29
- hostnames: [ingress.host],
26
+ hostnames: [route.host],
30
27
  rules: [
31
28
  {
32
29
  matches: [
33
30
  {
34
31
  path: {
35
32
  type: 'PathPrefix',
36
- value: ingress.path ?? '/',
33
+ value: route.path ?? '/',
37
34
  },
38
35
  },
39
36
  ],
40
37
  backendRefs: [
41
38
  {
42
39
  name: (0, resources_1.getResourceName)(service),
43
- port: ingress.servicePort ?? service.data.ports[0].containerPort,
40
+ port: route.servicePort ?? service.data.ports[0].containerPort,
44
41
  },
45
42
  ],
46
43
  },
@@ -1,10 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ensureNamespace = void 0;
4
- function statusCodeOf(e) {
5
- const err = e;
6
- return err?.statusCode ?? err?.response?.statusCode ?? err?.body?.code;
7
- }
4
+ const apply_1 = require("./apply");
8
5
  /**
9
6
  * Idempotently make sure `namespace` exists. hammerkit does not otherwise
10
7
  * create namespaces — deployments/services/jobs are applied straight into
@@ -16,25 +13,27 @@ function statusCodeOf(e) {
16
13
  */
17
14
  async function ensureNamespace(instance, namespace) {
18
15
  try {
19
- await instance.coreApi.readNamespace(namespace);
16
+ await instance.coreApi.readNamespace({ name: namespace });
20
17
  return;
21
18
  }
22
19
  catch (e) {
23
- if (statusCodeOf(e) !== 404) {
20
+ if ((0, apply_1.statusCodeOf)(e) !== 404) {
24
21
  throw e;
25
22
  }
26
23
  }
27
24
  try {
28
25
  await instance.coreApi.createNamespace({
29
- metadata: {
30
- name: namespace,
31
- labels: { 'hammerkit.dev/managed': 'true' },
26
+ body: {
27
+ metadata: {
28
+ name: namespace,
29
+ labels: { 'hammerkit.dev/managed': 'true' },
30
+ },
32
31
  },
33
32
  });
34
33
  }
35
34
  catch (e) {
36
35
  // another worker created it between our read and create
37
- if (statusCodeOf(e) !== 409) {
36
+ if ((0, apply_1.statusCodeOf)(e) !== 409) {
38
37
  throw e;
39
38
  }
40
39
  }
@@ -10,6 +10,7 @@ const sleep_1 = require("../utils/sleep");
10
10
  const version_1 = require("../version");
11
11
  const log_1 = require("../log");
12
12
  const ensure_kubernetes_persistent_volume_claim_exists_1 = require("./ensure-kubernetes-persistent-volume-claim-exists");
13
+ const stream_1 = require("stream");
13
14
  // A previous run that crashed between creating an upload/download pod and its
14
15
  // `finally` cleanup leaves a pod behind under the same deterministic name. apply()
15
16
  // would then patch that stale pod in place — and if it is stuck in a terminal
@@ -17,7 +18,7 @@ const ensure_kubernetes_persistent_volume_claim_exists_1 = require("./ensure-kub
17
18
  // and wait for it to disappear before scheduling a fresh one.
18
19
  async function removeStalePod(instance, namespace, name) {
19
20
  try {
20
- await instance.coreApi.deleteNamespacedPod(name, namespace, undefined, undefined, 0);
21
+ await instance.coreApi.deleteNamespacedPod({ name, namespace, gracePeriodSeconds: 0 });
21
22
  }
22
23
  catch (e) {
23
24
  if ((0, apply_1.statusCodeOf)(e) === 404) {
@@ -27,7 +28,7 @@ async function removeStalePod(instance, namespace, name) {
27
28
  }
28
29
  for (let attempt = 0; attempt < 30; attempt++) {
29
30
  try {
30
- await instance.coreApi.readNamespacedPod(name, namespace);
31
+ await instance.coreApi.readNamespacedPod({ name, namespace });
31
32
  }
32
33
  catch (e) {
33
34
  if ((0, apply_1.statusCodeOf)(e) === 404) {
@@ -100,7 +101,7 @@ async function getPodForPersistence(instance, env, service, persistence, type, f
100
101
  }
101
102
  finally {
102
103
  service.status.console('stdout', `delete pod ${name}`);
103
- await instance.coreApi.deleteNamespacedPod(name, env.namespace);
104
+ await instance.coreApi.deleteNamespacedPod({ name, namespace: env.namespace });
104
105
  }
105
106
  }
106
107
  exports.getPodForPersistence = getPodForPersistence;
@@ -122,12 +123,12 @@ async function ensurePersistentData(instance, env, environment, service, persist
122
123
  continue;
123
124
  }
124
125
  const fileItem = (0, path_1.basename)(source.localPath);
125
- const data = (0, tar_1.create)({
126
+ const data = stream_1.Readable.from((0, tar_1.create)({
126
127
  cwd: (0, path_1.dirname)(source.localPath),
127
128
  filter(path) {
128
129
  return source.matcher(path, source.localPath);
129
130
  },
130
- }, [fileItem]);
131
+ }, [fileItem]));
131
132
  service.status.console('stdout', `upload ${source.localPath}`);
132
133
  const targetPath = `/dev/hammerkit/${service.id()}`;
133
134
  const res = await instance.exec.exec(env.namespace, name, 'volume', ['tar', 'xf', '-', '-C', targetPath], process.stdout, process.stderr, data, false, (status) => {
@@ -4,8 +4,8 @@ exports.getServiceIp = void 0;
4
4
  const resources_1 = require("./resources");
5
5
  async function getServiceIp(instance, env, workService) {
6
6
  const name = (0, resources_1.getResourceName)(workService);
7
- const svc = await instance.coreApi.readNamespacedService(name, env.namespace);
8
- return svc.body.spec?.clusterIP ?? null;
7
+ const svc = await instance.coreApi.readNamespacedService({ name, namespace: env.namespace });
8
+ return svc.spec?.clusterIP ?? null;
9
9
  }
10
10
  exports.getServiceIp = getServiceIp;
11
11
  //# sourceMappingURL=get-service-ip.js.map
@@ -16,14 +16,10 @@ function createKubernetesInstances(kubernetes) {
16
16
  const coreApi = kc.makeApiClient(client_node_1.CoreV1Api);
17
17
  const batchApi = kc.makeApiClient(client_node_1.BatchV1Api);
18
18
  const appsApi = kc.makeApiClient(client_node_1.AppsV1Api);
19
- const networkingApi = kc.makeApiClient(client_node_1.NetworkingV1Api);
20
19
  const objectApi = kc.makeApiClient(client_node_1.KubernetesObjectApi);
21
20
  const exec = new client_node_1.Exec(kc);
22
- const watch = new client_node_1.Watch(kc);
23
21
  return {
24
22
  objectApi,
25
- networkingApi,
26
- watch,
27
23
  exec,
28
24
  appsApi,
29
25
  coreApi,
@@ -2,16 +2,26 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.removePersistentData = void 0;
4
4
  async function removePersistentData(instance, kubernetes, task) {
5
- const configmaps = await instance.coreApi.listNamespacedConfigMap(kubernetes.namespace, undefined, undefined, undefined, undefined, `hammerkit.dev/id=${task.id()}`);
6
- for (const configMap of configmaps.body.items) {
5
+ const labelSelector = `hammerkit.dev/id=${task.id()}`;
6
+ const configmaps = await instance.coreApi.listNamespacedConfigMap({ namespace: kubernetes.namespace, labelSelector });
7
+ for (const configMap of configmaps.items) {
7
8
  if (configMap.metadata?.name) {
8
- await instance.coreApi.deleteNamespacedConfigMap(configMap.metadata.name, kubernetes.namespace);
9
+ await instance.coreApi.deleteNamespacedConfigMap({
10
+ name: configMap.metadata.name,
11
+ namespace: kubernetes.namespace,
12
+ });
9
13
  }
10
14
  }
11
- const pvcs = await instance.coreApi.listNamespacedPersistentVolumeClaim(kubernetes.namespace, undefined, undefined, undefined, undefined, `hammerkit.dev/id=${task.id()}`);
12
- for (const pvc of pvcs.body.items) {
15
+ const pvcs = await instance.coreApi.listNamespacedPersistentVolumeClaim({
16
+ namespace: kubernetes.namespace,
17
+ labelSelector,
18
+ });
19
+ for (const pvc of pvcs.items) {
13
20
  if (pvc.metadata?.name) {
14
- await instance.coreApi.deleteNamespacedPersistentVolumeClaim(pvc.metadata.name, kubernetes.namespace);
21
+ await instance.coreApi.deleteNamespacedPersistentVolumeClaim({
22
+ name: pvc.metadata.name,
23
+ namespace: kubernetes.namespace,
24
+ });
15
25
  }
16
26
  }
17
27
  }
@@ -7,9 +7,9 @@ function formatSelector(matchLabels) {
7
7
  .join(',');
8
8
  }
9
9
  async function findReadyPod(coreApi, namespace, labelSelector) {
10
- const pods = await coreApi.listNamespacedPod(namespace, undefined, undefined, undefined, undefined, labelSelector);
11
- const ready = pods.body.items.find((p) => p.status?.conditions?.some((c) => c.type === 'Ready' && c.status === 'True'));
12
- const pod = ready ?? pods.body.items[0];
10
+ const pods = await coreApi.listNamespacedPod({ namespace, labelSelector });
11
+ const ready = pods.items.find((p) => p.status?.conditions?.some((c) => c.type === 'Ready' && c.status === 'True'));
12
+ const pod = ready ?? pods.items[0];
13
13
  if (!pod?.metadata?.name) {
14
14
  throw new Error(`no pod found for selector ${labelSelector} in namespace ${namespace}`);
15
15
  }
@@ -21,16 +21,16 @@ async function resolvePodName(coreApi, appsApi, namespace, selector) {
21
21
  return selector.name;
22
22
  }
23
23
  if (type === 'service') {
24
- const svc = await coreApi.readNamespacedService(selector.name, namespace);
25
- const matchLabels = svc.body.spec?.selector;
24
+ const svc = await coreApi.readNamespacedService({ name: selector.name, namespace });
25
+ const matchLabels = svc.spec?.selector;
26
26
  if (!matchLabels || Object.keys(matchLabels).length === 0) {
27
27
  throw new Error(`service ${selector.name} has no selector`);
28
28
  }
29
29
  return findReadyPod(coreApi, namespace, formatSelector(matchLabels));
30
30
  }
31
31
  if (type === 'deployment') {
32
- const deploy = await appsApi.readNamespacedDeployment(selector.name, namespace);
33
- const matchLabels = deploy.body.spec?.selector?.matchLabels;
32
+ const deploy = await appsApi.readNamespacedDeployment({ name: selector.name, namespace });
33
+ const matchLabels = deploy.spec?.selector?.matchLabels;
34
34
  if (!matchLabels) {
35
35
  throw new Error(`deployment ${selector.name} has no matchLabels`);
36
36
  }
@@ -13,7 +13,7 @@ function appendWorkEnvironment(referenced) {
13
13
  type: 'kubernetes',
14
14
  context: referenced.schema.kubernetes.context,
15
15
  kubeConfig: referenced.schema.kubernetes.kubeconfig,
16
- ingresses: referenced.schema.kubernetes.ingresses || [],
16
+ httpRoutes: referenced.schema.kubernetes.httpRoutes || [],
17
17
  namespace: referenced.schema.kubernetes.namespace ?? 'default',
18
18
  };
19
19
  }
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getOutputsToReset = void 0;
4
+ const path_1 = require("path");
5
+ function contains(parent, path) {
6
+ const rel = (0, path_1.relative)(parent, path);
7
+ return rel === '' || (!rel.startsWith('..') && !(0, path_1.isAbsolute)(rel));
8
+ }
9
+ // The outputs a run empties before the task's commands: its own outputs with
10
+ // resetOnChange (the default), except one that is a source or contains one
11
+ // (`package-lock.json` declared both ways) or contains the task's directory.
12
+ // Emptying those would delete what the task reads.
13
+ function getOutputsToReset(task) {
14
+ return task.generates.filter((generate) => generate.resetOnChange &&
15
+ !generate.inherited &&
16
+ !contains(generate.path, task.cwd) &&
17
+ !task.src.some((src) => contains(generate.path, src.absolutePath)));
18
+ }
19
+ exports.getOutputsToReset = getOutputsToReset;
20
+ //# sourceMappingURL=get-outputs-to-reset.js.map
@@ -24,12 +24,12 @@ function parseWorkGenerate(cwd, schema, envs) {
24
24
  exports.parseWorkGenerate = parseWorkGenerate;
25
25
  function mapGenerate(generate) {
26
26
  if (typeof generate === 'string') {
27
- return { path: generate, resetOnChange: false, export: false };
27
+ return { path: generate, resetOnChange: true, export: false };
28
28
  }
29
29
  else {
30
30
  return {
31
31
  path: generate.path,
32
- resetOnChange: generate.resetOnChange ?? false,
32
+ resetOnChange: generate.resetOnChange ?? true,
33
33
  export: generate.export ?? false,
34
34
  };
35
35
  }
@@ -12,6 +12,7 @@ const path_1 = require("path");
12
12
  const tar_1 = require("tar");
13
13
  const plan_work_volume_1 = require("./utils/plan-work-volume");
14
14
  const work_instance_id_1 = require("./work-instance-id");
15
+ const stream_1 = require("stream");
15
16
  function dockerTaskRuntime(task, workEnvironment) {
16
17
  const docker = (0, execute_docker_1.getContainerCli)(workEnvironment);
17
18
  return {
@@ -132,6 +133,9 @@ async function restoreContainer(docker, environment, item, path) {
132
133
  }, null, async (container) => {
133
134
  for (const generate of (0, event_cache_1.getArchivePaths)(item.data, path)) {
134
135
  if (await environment.file.exists(generate.filename)) {
136
+ // a cache hit means exactly the stored outputs: whatever a failed or
137
+ // older run left in the volume goes first
138
+ await (0, execute_docker_1.clearContainerDirectory)(item.status, environment, container, generate.path);
135
139
  await container.putArchive(environment.file.readStream(generate.filename), {
136
140
  path: (0, path_1.dirname)(generate.path),
137
141
  });
@@ -142,6 +146,7 @@ async function restoreContainer(docker, environment, item, path) {
142
146
  const onHost = new Set(item.data.generates.filter((g) => !g.inherited && isOnHost(g)).map((g) => g.path));
143
147
  for (const generate of (0, event_cache_1.getArchivePaths)(item.data, path)) {
144
148
  if (onHost.has(generate.path) && (await environment.file.exists(generate.filename))) {
149
+ await environment.file.remove(generate.path);
145
150
  await (0, tar_1.extract)({ file: generate.filename, cwd: (0, path_1.dirname)(generate.path) });
146
151
  }
147
152
  }
@@ -251,7 +256,7 @@ async function archiveContainer(docker, environment, item, path) {
251
256
  for (const generatedArchive of (0, event_cache_1.getArchivePaths)(item.data, path)) {
252
257
  // a file output is a bind mount of a host file, so archive it from the host
253
258
  const readable = hostFiles.has(generatedArchive.path)
254
- ? (0, tar_1.create)({ cwd: (0, path_1.dirname)(generatedArchive.path) }, [(0, path_1.basename)(generatedArchive.path)])
259
+ ? stream_1.Readable.from((0, tar_1.create)({ cwd: (0, path_1.dirname)(generatedArchive.path) }, [(0, path_1.basename)(generatedArchive.path)]))
255
260
  : await container.getArchive({
256
261
  path: generatedArchive.path,
257
262
  });
@@ -16,7 +16,6 @@ const ensure_persistent_data_1 = require("../kubernetes/ensure-persistent-data")
16
16
  const await_running_state_1 = require("../kubernetes/await-running-state");
17
17
  const volumes_1 = require("../kubernetes/volumes");
18
18
  const resources_1 = require("../kubernetes/resources");
19
- const ensure_ingress_1 = require("../kubernetes/ensure-ingress");
20
19
  const ensure_http_route_1 = require("../kubernetes/ensure-http-route");
21
20
  const find_process_1 = __importDefault(require("find-process"));
22
21
  const log_1 = require("../log");
@@ -24,13 +23,12 @@ const version_1 = require("../version");
24
23
  const restore_kubernetes_data_1 = require("../kubernetes/restore-kubernetes-data");
25
24
  const store_kubernetes_data_1 = require("../kubernetes/store-kubernetes-data");
26
25
  const remove_persistent_data_1 = require("../kubernetes/remove-persistent-data");
27
- function statusCodeOf(e) {
28
- const err = e;
29
- return err?.statusCode ?? err?.response?.statusCode ?? err?.body?.code;
30
- }
31
26
  async function listJobNames(instance, kubernetes, task) {
32
- const jobs = await instance.batchApi.listNamespacedJob(kubernetes.namespace, undefined, undefined, undefined, undefined, `hammerkit.dev/id=${task.id()}`);
33
- return jobs.body.items.flatMap((job) => (job.metadata?.name ? [job.metadata.name] : []));
27
+ const jobs = await instance.batchApi.listNamespacedJob({
28
+ namespace: kubernetes.namespace,
29
+ labelSelector: `hammerkit.dev/id=${task.id()}`,
30
+ });
31
+ return jobs.items.flatMap((job) => (job.metadata?.name ? [job.metadata.name] : []));
34
32
  }
35
33
  function kubernetesTaskRuntime(task, kubernetes) {
36
34
  const instance = (0, kubernetes_instance_1.createKubernetesInstances)(kubernetes);
@@ -128,22 +126,34 @@ function kubernetesTaskRuntime(task, kubernetes) {
128
126
  }
129
127
  },
130
128
  async stop() {
131
- const jobs = await instance.batchApi.listNamespacedJob(kubernetes.namespace, undefined, undefined, undefined, undefined, `hammerkit.dev/id=${task.id()}`);
132
- for (const pod of jobs.body.items) {
129
+ const jobs = await instance.batchApi.listNamespacedJob({
130
+ namespace: kubernetes.namespace,
131
+ labelSelector: `hammerkit.dev/id=${task.id()}`,
132
+ });
133
+ for (const pod of jobs.items) {
133
134
  if (pod.metadata?.name) {
134
- await instance.batchApi.deleteNamespacedJob(pod.metadata.name, kubernetes.namespace);
135
+ await instance.batchApi.deleteNamespacedJob({ name: pod.metadata.name, namespace: kubernetes.namespace });
135
136
  }
136
137
  }
137
- const deployments = await instance.appsApi.listNamespacedDeployment(kubernetes.namespace, undefined, undefined, undefined, undefined, `hammerkit.dev/id=${task.id()}`);
138
- for (const deploy of deployments.body.items) {
138
+ const deployments = await instance.appsApi.listNamespacedDeployment({
139
+ namespace: kubernetes.namespace,
140
+ labelSelector: `hammerkit.dev/id=${task.id()}`,
141
+ });
142
+ for (const deploy of deployments.items) {
139
143
  if (deploy.metadata?.name) {
140
- await instance.appsApi.deleteNamespacedDeployment(deploy.metadata.name, kubernetes.namespace);
144
+ await instance.appsApi.deleteNamespacedDeployment({
145
+ name: deploy.metadata.name,
146
+ namespace: kubernetes.namespace,
147
+ });
141
148
  }
142
149
  }
143
- const pods = await instance.coreApi.listNamespacedPod(kubernetes.namespace, undefined, undefined, undefined, undefined, `hammerkit.dev/id=${task.id()}`);
144
- for (const pod of pods.body.items) {
150
+ const pods = await instance.coreApi.listNamespacedPod({
151
+ namespace: kubernetes.namespace,
152
+ labelSelector: `hammerkit.dev/id=${task.id()}`,
153
+ });
154
+ for (const pod of pods.items) {
145
155
  if (pod.metadata?.name) {
146
- await instance.coreApi.deleteNamespacedPod(pod.metadata.name, kubernetes.namespace);
156
+ await instance.coreApi.deleteNamespacedPod({ name: pod.metadata.name, namespace: kubernetes.namespace });
147
157
  }
148
158
  }
149
159
  },
@@ -152,8 +162,11 @@ function kubernetesTaskRuntime(task, kubernetes) {
152
162
  await (0, remove_persistent_data_1.removePersistentData)(instance, kubernetes, task);
153
163
  },
154
164
  async currentStateKey() {
155
- const jobs = await instance.batchApi.listNamespacedJob(kubernetes.namespace, undefined, undefined, undefined, undefined, `hammerkit.dev/id=${task.id()}`);
156
- const completedStates = jobs.body.items
165
+ const jobs = await instance.batchApi.listNamespacedJob({
166
+ namespace: kubernetes.namespace,
167
+ labelSelector: `hammerkit.dev/id=${task.id()}`,
168
+ });
169
+ const completedStates = jobs.items
157
170
  .filter((j) => j.status?.succeeded && j.metadata?.labels?.['hammerkit.dev/state'])
158
171
  .map((j) => j.metadata.labels['hammerkit.dev/state']);
159
172
  const stateKey = completedStates[completedStates.length - 1] ?? null;
@@ -163,11 +176,14 @@ function kubernetesTaskRuntime(task, kubernetes) {
163
176
  // outputs live in the task's claim; without it the finished job left
164
177
  // nothing to reuse
165
178
  try {
166
- const claim = await instance.coreApi.readNamespacedPersistentVolumeClaim((0, volumes_1.getVolumeName)(task), kubernetes.namespace);
167
- return claim.body.metadata?.deletionTimestamp ? null : stateKey;
179
+ const claim = await instance.coreApi.readNamespacedPersistentVolumeClaim({
180
+ name: (0, volumes_1.getVolumeName)(task),
181
+ namespace: kubernetes.namespace,
182
+ });
183
+ return claim.metadata?.deletionTimestamp ? null : stateKey;
168
184
  }
169
185
  catch (e) {
170
- if (statusCodeOf(e) === 404) {
186
+ if ((0, apply_1.statusCodeOf)(e) === 404) {
171
187
  return null;
172
188
  }
173
189
  throw e;
@@ -189,10 +205,16 @@ function kubernetesServiceRuntime(service, kubernetes) {
189
205
  await (0, store_kubernetes_data_1.storeKubernetesData)(service, kubernetes, instance, environment, path);
190
206
  },
191
207
  async stop() {
192
- const deployments = await instance.appsApi.listNamespacedDeployment(kubernetes.namespace, undefined, undefined, undefined, undefined, `hammerkit.dev/id=${service.id()}`);
193
- for (const deployment of deployments.body.items) {
208
+ const deployments = await instance.appsApi.listNamespacedDeployment({
209
+ namespace: kubernetes.namespace,
210
+ labelSelector: `hammerkit.dev/id=${service.id()}`,
211
+ });
212
+ for (const deployment of deployments.items) {
194
213
  if (deployment.metadata?.name) {
195
- await instance.appsApi.deleteNamespacedDeployment(deployment.metadata.name, kubernetes.namespace);
214
+ await instance.appsApi.deleteNamespacedDeployment({
215
+ name: deployment.metadata.name,
216
+ namespace: kubernetes.namespace,
217
+ });
196
218
  }
197
219
  }
198
220
  },
@@ -209,29 +231,16 @@ function kubernetesServiceRuntime(service, kubernetes) {
209
231
  stateKey: options.stateKey,
210
232
  remote: null,
211
233
  });
212
- const ingresses = kubernetes.ingresses.filter((i) => i.service === service.name);
213
- for (const ingress of ingresses) {
214
- if (ingress.kind === 'httproute') {
215
- await (0, ensure_http_route_1.ensureHttpRoute)(instance, kubernetes, ingress, service);
216
- }
217
- else {
218
- await (0, ensure_ingress_1.ensureIngress)(instance, kubernetes, ingress, service);
219
- }
234
+ for (const route of kubernetes.httpRoutes.filter((r) => r.service === service.name)) {
235
+ await (0, ensure_http_route_1.ensureHttpRoute)(instance, kubernetes, route, service);
220
236
  }
221
237
  },
222
238
  async remove() {
223
- const ingresses = await instance.networkingApi.listNamespacedIngress(kubernetes.namespace, undefined, undefined, undefined, undefined, `hammerkit.dev/id=${service.id()}`);
224
- for (const ingress of ingresses.body.items) {
225
- if (ingress.metadata?.name) {
226
- await instance.networkingApi.deleteNamespacedIngress(ingress.metadata.name, kubernetes.namespace);
227
- }
228
- }
229
239
  // HTTPRoutes are a CRD, so they are deleted by name through the generic
230
240
  // objectApi rather than listed (a list would error on clusters without the
231
- // Gateway API CRDs even when none are declared). Only entries this service
232
- // declared as httproute are removed; a missing route (404) is fine.
233
- const httpRoutes = kubernetes.ingresses.filter((i) => i.kind === 'httproute' && i.service === service.name);
234
- for (const route of httpRoutes) {
241
+ // Gateway API CRDs even when none are declared). Only the routes this
242
+ // service declares are removed; a missing route (404) is fine.
243
+ for (const route of kubernetes.httpRoutes.filter((r) => r.service === service.name)) {
235
244
  try {
236
245
  await instance.objectApi.delete({
237
246
  apiVersion: ensure_http_route_1.HTTP_ROUTE_API_VERSION,
@@ -240,23 +249,32 @@ function kubernetesServiceRuntime(service, kubernetes) {
240
249
  });
241
250
  }
242
251
  catch (e) {
243
- if (statusCodeOf(e) !== 404) {
252
+ if ((0, apply_1.statusCodeOf)(e) !== 404) {
244
253
  throw e;
245
254
  }
246
255
  }
247
256
  }
248
- const services = await instance.coreApi.listNamespacedService(kubernetes.namespace, undefined, undefined, undefined, undefined, `hammerkit.dev/id=${service.id()}`);
249
- for (const service of services.body.items) {
257
+ const services = await instance.coreApi.listNamespacedService({
258
+ namespace: kubernetes.namespace,
259
+ labelSelector: `hammerkit.dev/id=${service.id()}`,
260
+ });
261
+ for (const service of services.items) {
250
262
  if (service.metadata?.name) {
251
- await instance.coreApi.deleteNamespacedService(service.metadata.name, kubernetes.namespace);
263
+ await instance.coreApi.deleteNamespacedService({
264
+ name: service.metadata.name,
265
+ namespace: kubernetes.namespace,
266
+ });
252
267
  }
253
268
  }
254
269
  await this.stop();
255
270
  await (0, remove_persistent_data_1.removePersistentData)(instance, kubernetes, service);
256
271
  },
257
272
  async currentStateKey() {
258
- const deployments = await instance.appsApi.listNamespacedDeployment(kubernetes.namespace, undefined, undefined, undefined, undefined, `hammerkit.dev/id=${service.id()}`);
259
- const deployment = deployments.body.items[0];
273
+ const deployments = await instance.appsApi.listNamespacedDeployment({
274
+ namespace: kubernetes.namespace,
275
+ labelSelector: `hammerkit.dev/id=${service.id()}`,
276
+ });
277
+ const deployment = deployments.items[0];
260
278
  return deployment?.metadata?.labels?.['hammerkit.dev/state'] ?? null;
261
279
  },
262
280
  };
@@ -8,7 +8,9 @@ const tar_1 = require("tar");
8
8
  const path_1 = require("path");
9
9
  const local_task_1 = require("../executer/local-task");
10
10
  const event_cache_1 = require("../executer/event-cache");
11
+ const get_outputs_to_reset_1 = require("./utils/get-outputs-to-reset");
11
12
  const find_process_1 = __importDefault(require("find-process"));
13
+ const stream_1 = require("stream");
12
14
  function getStateFilename(task) {
13
15
  return (0, path_1.join)(task.data.cwd, '.hammerkit', `${task.id()}`);
14
16
  }
@@ -56,8 +58,21 @@ function getLocalWorkRuntime(task) {
56
58
  const pidFile = getPidFilename(task);
57
59
  await environment.file.writeFile(pidFile, `${process.pid}`);
58
60
  try {
61
+ // The record says "the outputs belong to this state key". A run that
62
+ // fails overwrites outputs, so the previous record goes first, and only
63
+ // a run that succeeded writes a new one.
64
+ const stateFile = getStateFilename(task);
65
+ if (await environment.file.exists(stateFile)) {
66
+ await environment.file.remove(stateFile);
67
+ }
68
+ for (const generate of (0, get_outputs_to_reset_1.getOutputsToReset)(task.data)) {
69
+ await environment.file.remove(generate.path);
70
+ }
59
71
  await (0, local_task_1.localTask)(task, environment, options);
60
- await environment.file.writeFile(getStateFilename(task), options.stateKey);
72
+ const failed = ['crash', 'error', 'canceled'].includes(options.state.current.type);
73
+ if (!failed) {
74
+ await environment.file.writeFile(stateFile, options.stateKey);
75
+ }
61
76
  }
62
77
  finally {
63
78
  if (await environment.file.exists(pidFile)) {
@@ -96,15 +111,17 @@ function getLocalWorkRuntime(task) {
96
111
  exports.getLocalWorkRuntime = getLocalWorkRuntime;
97
112
  async function archiveLocal(environment, task, path) {
98
113
  for (const generatedArchive of (0, event_cache_1.getArchivePaths)(task.data, path)) {
99
- await environment.file.writeStream(generatedArchive.filename, (0, tar_1.create)({
100
- cwd: task.data.cwd,
101
- gzip: true,
102
- }, [(0, path_1.relative)(task.data.cwd, generatedArchive.path)]));
114
+ await environment.file.writeStream(generatedArchive.filename,
115
+ // tar's stream is a Minipass stream; Readable.from adapts it to a Node one
116
+ stream_1.Readable.from((0, tar_1.create)({ cwd: task.data.cwd, gzip: true }, [(0, path_1.relative)(task.data.cwd, generatedArchive.path)])));
103
117
  }
104
118
  }
105
119
  async function restoreLocal(environment, task, path) {
106
120
  for (const generate of (0, event_cache_1.getArchivePaths)(task, path)) {
107
121
  if (await environment.file.exists(generate.filename)) {
122
+ // a cache hit means exactly the stored outputs: whatever a failed or
123
+ // older run left in the folder goes first
124
+ await environment.file.remove(generate.path);
108
125
  await (0, tar_1.extract)({
109
126
  file: generate.filename,
110
127
  cwd: task.cwd,
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildFileEnvironmentSchemaHttpRoute = void 0;
4
+ const zod_1 = require("zod");
5
+ // A Gateway API HTTPRoute exposing a service of the environment under a hostname.
6
+ exports.buildFileEnvironmentSchemaHttpRoute = (0, zod_1.object)({
7
+ host: (0, zod_1.string)(),
8
+ service: (0, zod_1.string)(),
9
+ servicePort: (0, zod_1.number)().optional(),
10
+ path: (0, zod_1.string)().optional(),
11
+ // the parent Gateway the route attaches to
12
+ gateway: (0, zod_1.string)(),
13
+ gatewayNamespace: (0, zod_1.string)().optional(),
14
+ }).strict();
15
+ //# sourceMappingURL=build-file-environment-schema-http-route.js.map
@@ -2,12 +2,18 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.buildFileEnvironmentSchema = exports.buildFileDockerEnvironmentSchema = exports.buildFileKubernetesEnvironmentSchema = void 0;
4
4
  const zod_1 = require("zod");
5
- const build_file_environment_schema_ingress_1 = require("./build-file-environment-schema-ingress");
5
+ const build_file_environment_schema_http_route_1 = require("./build-file-environment-schema-http-route");
6
6
  exports.buildFileKubernetesEnvironmentSchema = (0, zod_1.object)({
7
7
  namespace: (0, zod_1.string)().optional(),
8
8
  context: (0, zod_1.string)(),
9
9
  kubeconfig: (0, zod_1.string)().optional(),
10
- ingresses: (0, zod_1.array)(build_file_environment_schema_ingress_1.buildFileEnvironmentSchemaIngress).optional(),
10
+ httpRoutes: (0, zod_1.array)(build_file_environment_schema_http_route_1.buildFileEnvironmentSchemaHttpRoute).optional(),
11
+ // Ingress support was removed in 1.8.0. Fail instead of dropping the routes.
12
+ ingresses: (0, zod_1.never)({
13
+ errorMap: () => ({
14
+ message: 'ingresses were removed in 1.8.0: declare Gateway API routes under httpRoutes, each with a gateway',
15
+ }),
16
+ }).optional(),
11
17
  });
12
18
  exports.buildFileDockerEnvironmentSchema = (0, zod_1.object)({
13
19
  host: (0, zod_1.string)().optional(),
@@ -19,5 +19,5 @@ exports.buildFileSchema = (0, zod_1.object)({
19
19
  labels: labels_schema_1.labelsSchema.optional(),
20
20
  })
21
21
  .strict()
22
- .describe('Build file with support for containerization\nhttps://no0dles.gitbook.io/hammerkit/build-file');
22
+ .describe('Build file with support for containerization\nhttps://hammerkit.dev/docs/build-file');
23
23
  //# sourceMappingURL=build-file-schema.js.map
@@ -2,5 +2,5 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.envsSchema = void 0;
4
4
  const zod_1 = require("zod");
5
- exports.envsSchema = (0, zod_1.record)((0, zod_1.union)([(0, zod_1.string)(), (0, zod_1.number)()])).describe('Environment values for the current build task\nhttps://no0dles.gitbook.io/hammerkit/build-file/environment-variables');
5
+ exports.envsSchema = (0, zod_1.record)((0, zod_1.union)([(0, zod_1.string)(), (0, zod_1.number)()])).describe('Environment values for the current build task\nhttps://hammerkit.dev/docs/build-file/environment-variables');
6
6
  //# sourceMappingURL=envs-schema.js.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hammerkit",
3
3
  "description": "Containerized build tool with incremental caching that runs the same build file locally, in Docker and on Kubernetes.",
4
- "version": "1.7.0",
4
+ "version": "1.8.0",
5
5
  "keywords": [
6
6
  "build",
7
7
  "build-tool",
@@ -12,7 +12,7 @@
12
12
  "monorepo",
13
13
  "task-runner"
14
14
  ],
15
- "homepage": "https://no0dles.gitbook.io/hammerkit/",
15
+ "homepage": "https://hammerkit.dev",
16
16
  "bin": "dist/index.js",
17
17
  "files": [
18
18
  "dist",
@@ -47,15 +47,15 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@aws-sdk/client-s3": "^3.470.0",
50
- "@kubernetes/client-node": "^0.20.0",
51
- "chokidar": "^3.5.3",
50
+ "@kubernetes/client-node": "^2.0.0",
51
+ "chokidar": "^3.6.0",
52
52
  "colors": "^1.4.0",
53
53
  "commander": "^11.1.0",
54
- "dockerode": "^4.0.2",
54
+ "dockerode": "^4.0.12",
55
55
  "find-process": "^1.4.7",
56
- "minimatch": "^6.1.5",
57
- "tar": "^6.2.0",
58
- "yaml": "^2.3.4",
56
+ "minimatch": "^6.2.3",
57
+ "tar": "^7.5.22",
58
+ "yaml": "^2.9.1",
59
59
  "zod": "^3.22.4"
60
60
  },
61
61
  "devDependencies": {
@@ -64,7 +64,7 @@
64
64
  "@types/dockerode": "^3.3.23",
65
65
  "@types/minimatch": "^5.1.2",
66
66
  "@types/node": "^24.0.0",
67
- "@types/tar": "^6.1.3",
67
+ "@types/ws": "^8.18.2",
68
68
  "@typescript-eslint/eslint-plugin": "^6.16.0",
69
69
  "@typescript-eslint/parser": "^6.16.0",
70
70
  "@vitest/coverage-istanbul": "^2.1.8",
@@ -1,54 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ensureIngress = void 0;
4
- const apply_1 = require("./apply");
5
- const resources_1 = require("./resources");
6
- async function ensureIngress(instance, env, ingress, service) {
7
- const resource = {
8
- kind: 'Ingress',
9
- apiVersion: 'networking.k8s.io/v1',
10
- metadata: {
11
- name: ingress.host,
12
- namespace: env.namespace,
13
- annotations: {
14
- 'cert-manager.io/cluster-issuer': 'hammerkit-issuer',
15
- },
16
- labels: {
17
- 'hammerkit.dev/id': service.id(),
18
- },
19
- },
20
- spec: {
21
- ingressClassName: 'nginx',
22
- rules: [
23
- {
24
- host: ingress.host,
25
- http: {
26
- paths: [
27
- {
28
- path: ingress.path ?? '/',
29
- pathType: 'Prefix',
30
- backend: {
31
- service: {
32
- name: (0, resources_1.getResourceName)(service),
33
- port: {
34
- number: ingress.servicePort ?? service.data.ports[0].containerPort,
35
- },
36
- },
37
- },
38
- },
39
- ],
40
- },
41
- },
42
- ],
43
- tls: [
44
- {
45
- secretName: `${ingress.host}-tls`,
46
- hosts: [ingress.host],
47
- },
48
- ],
49
- },
50
- };
51
- return await (0, apply_1.apply)(instance, resource);
52
- }
53
- exports.ensureIngress = ensureIngress;
54
- //# sourceMappingURL=ensure-ingress.js.map
@@ -1,14 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.buildFileEnvironmentSchemaIngress = void 0;
4
- const zod_1 = require("zod");
5
- exports.buildFileEnvironmentSchemaIngress = (0, zod_1.object)({
6
- kind: zod_1.z.enum(['ingress', 'httproute']).optional(),
7
- host: (0, zod_1.string)(),
8
- service: (0, zod_1.string)(),
9
- servicePort: (0, zod_1.number)().optional(),
10
- path: (0, zod_1.string)().optional(),
11
- gateway: (0, zod_1.string)().optional(),
12
- gatewayNamespace: (0, zod_1.string)().optional(),
13
- }).strict();
14
- //# sourceMappingURL=build-file-environment-schema-ingress.js.map