hammerkit 1.26.0 → 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 (128) hide show
  1. package/build.schema.json +62 -1
  2. package/dist/cache/backends/local-cache-backend.js +10 -2
  3. package/dist/cache/backends/registry-cache-backend.js +2 -2
  4. package/dist/cache/explain.js +13 -11
  5. package/dist/cli.js +3 -0
  6. package/dist/docker/item-network.js +471 -0
  7. package/dist/docker/pull.js +4 -2
  8. package/dist/docker/remove-orphaned-containers.js +13 -6
  9. package/dist/environment/replace-env-variables.js +8 -6
  10. package/dist/executer/await-completed-dependencies.js +2 -1
  11. package/dist/executer/check-readiness.js +2 -2
  12. package/dist/executer/docker-service.js +24 -13
  13. package/dist/executer/docker-task.js +18 -29
  14. package/dist/executer/event-cache.js +24 -2
  15. package/dist/executer/execute-command.js +18 -5
  16. package/dist/executer/execute-docker.js +4 -1
  17. package/dist/executer/execution-steps.js +33 -7
  18. package/dist/executer/get-docker-executor.js +14 -7
  19. package/dist/executer/scheduler/enqueue-next.js +17 -3
  20. package/dist/executer/watch-loop.js +2 -2
  21. package/dist/executer/watch-state-key.js +1 -1
  22. package/dist/file/within-root.js +42 -0
  23. package/dist/harness/connect.js +99 -0
  24. package/dist/harness/format.js +73 -0
  25. package/dist/harness/mcp-command.js +59 -0
  26. package/dist/harness/mcp.js +432 -0
  27. package/dist/harness/opencode.js +492 -0
  28. package/dist/harness/pi.js +217 -0
  29. package/dist/harness/sandbox-client.js +411 -0
  30. package/dist/index.js +33 -36
  31. package/dist/kubernetes/apply.js +42 -1
  32. package/dist/kubernetes/await-running-state.js +33 -1
  33. package/dist/kubernetes/claim-layout.js +71 -0
  34. package/dist/kubernetes/ensure-http-route.js +1 -1
  35. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +46 -28
  36. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +132 -5
  37. package/dist/kubernetes/ensure-kubernetes-service-exists.js +27 -11
  38. package/dist/kubernetes/ensure-persistent-data.js +75 -24
  39. package/dist/kubernetes/get-service-ip.js +16 -2
  40. package/dist/kubernetes/job-placement.js +99 -0
  41. package/dist/kubernetes/job-secrets.js +320 -0
  42. package/dist/kubernetes/kept-claims.js +203 -0
  43. package/dist/kubernetes/kubernetes-instance.js +10 -2
  44. package/dist/kubernetes/network-gate.js +71 -0
  45. package/dist/kubernetes/network-policy.js +583 -0
  46. package/dist/kubernetes/remove-persistent-data.js +3 -1
  47. package/dist/kubernetes/resources.js +37 -4
  48. package/dist/kubernetes/restore-kubernetes-data.js +28 -7
  49. package/dist/kubernetes/secrets.js +7 -9
  50. package/dist/kubernetes/store-kubernetes-data.js +1 -1
  51. package/dist/kubernetes/volumes.js +104 -12
  52. package/dist/log.js +1 -1
  53. package/dist/main.js +41 -0
  54. package/dist/network/egress-proxy.js +407 -0
  55. package/dist/network/port-relay.js +35 -0
  56. package/dist/optimizer/get-work-cache-stats.js +16 -0
  57. package/dist/optimizer/work-task-cache-description.js +5 -2
  58. package/dist/planner/service-definition.js +5 -0
  59. package/dist/planner/utils/append-work-dependencies.js +5 -2
  60. package/dist/planner/utils/append-work-environment.js +2 -0
  61. package/dist/planner/utils/append-work-service.js +5 -0
  62. package/dist/planner/utils/append-work-task.js +20 -1
  63. package/dist/planner/utils/assert-item-paths.js +50 -0
  64. package/dist/planner/utils/hosted-volumes.js +35 -0
  65. package/dist/planner/utils/task-arguments.js +3 -0
  66. package/dist/planner/work-network.js +193 -0
  67. package/dist/planner/work-runtime-docker.js +43 -1
  68. package/dist/planner/work-runtime-kubernetes.js +370 -111
  69. package/dist/planner/work-runtime-local.js +2 -1
  70. package/dist/program.js +33 -5
  71. package/dist/remote/annotations.js +115 -0
  72. package/dist/remote/bundle-text.js +24 -0
  73. package/dist/remote/bundle.js +9 -1
  74. package/dist/remote/remote-commands.js +15 -3
  75. package/dist/remote/remote-work-commands.js +272 -71
  76. package/dist/remote/server-client.js +18 -5
  77. package/dist/remote/source-manifest.js +15 -2
  78. package/dist/remote/token-source.js +67 -0
  79. package/dist/schema/build-file-base-task-schema.js +7 -0
  80. package/dist/schema/build-file-container-service-schema.js +8 -0
  81. package/dist/schema/build-file-container-task-schema.js +3 -0
  82. package/dist/schema/build-file-environment-schema.js +9 -0
  83. package/dist/schema/network-schema.js +78 -0
  84. package/dist/schema/reference-parser.js +2 -0
  85. package/dist/schema/schema-parser.js +16 -3
  86. package/dist/schema/work-scope-parser.js +5 -0
  87. package/dist/secrets/command-secret-provider.js +73 -10
  88. package/dist/secrets/github-app-token-command.js +26 -0
  89. package/dist/secrets/github-app-token.js +191 -0
  90. package/dist/server/accounts-api.js +9 -2
  91. package/dist/server/definitions-api.js +69 -47
  92. package/dist/server/definitions.js +76 -51
  93. package/dist/server/executor.js +156 -41
  94. package/dist/server/git-checkout.js +72 -8
  95. package/dist/server/kubernetes-backend.js +603 -0
  96. package/dist/server/kubernetes-sandbox-storage.js +997 -0
  97. package/dist/server/oauth.js +12 -0
  98. package/dist/server/plan.js +88 -2
  99. package/dist/server/policy.js +104 -15
  100. package/dist/server/proposals-api.js +24 -40
  101. package/dist/server/proposals.js +12 -25
  102. package/dist/server/repo-access.js +37 -6
  103. package/dist/server/run-logs.js +217 -0
  104. package/dist/server/run-plan.js +48 -2
  105. package/dist/server/run-request.js +29 -10
  106. package/dist/server/run-store.js +5 -0
  107. package/dist/server/run-worker-command.js +13 -1
  108. package/dist/server/run-worker.js +121 -24
  109. package/dist/server/runs.js +191 -10
  110. package/dist/server/sandbox-docker.js +62 -0
  111. package/dist/server/sandbox-files-api.js +269 -0
  112. package/dist/server/sandbox-files.js +1054 -0
  113. package/dist/server/sandbox-patch.js +481 -0
  114. package/dist/server/sandbox-pod-command.js +15 -0
  115. package/dist/server/sandbox-pod.js +146 -0
  116. package/dist/server/sandbox-storage.js +462 -0
  117. package/dist/server/sandboxes-api.js +216 -0
  118. package/dist/server/sandboxes.js +519 -0
  119. package/dist/server/server-command.js +39 -20
  120. package/dist/server/server-config.js +443 -22
  121. package/dist/server/server.js +219 -40
  122. package/dist/utils/abort-on-signals.js +46 -6
  123. package/dist/utils/run-id.js +12 -0
  124. package/dist/utils/tar.js +25 -0
  125. package/harness/opencode.ts +11 -0
  126. package/harness/pi.ts +7 -0
  127. package/package.json +8 -2
  128. package/dist/remote/text-diff.js +0 -89
package/dist/index.js CHANGED
@@ -1,41 +1,38 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
- Object.defineProperty(exports, "__esModule", { value: true });
4
- const log_1 = require("./log");
5
- const get_file_context_1 = require("./file/get-file-context");
6
- const work_item_status_1 = require("./planner/work-item-status");
7
- const redact_1 = require("./utils/redact");
8
- const empty_writable_1 = require("./utils/empty-writable");
9
- const run_program_1 = require("./run-program");
10
- const abort_on_signals_1 = require("./utils/abort-on-signals");
11
- const abortCtrl = new AbortController();
12
- (0, abort_on_signals_1.abortOnSignals)(process, abortCtrl);
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;
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
21
8
  }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
22
18
  });
23
- const secrets = (0, redact_1.createSecretRegistry)();
24
- (0, run_program_1.runProgram)({
25
- cwd: process.cwd(),
26
- abortCtrl,
27
- processEnvs: process.env,
28
- file: (0, get_file_context_1.getFileContext)(process.cwd()),
29
- console: (0, log_1.consoleContext)(process.stdout),
30
- status: (0, work_item_status_1.statusConsole)((0, empty_writable_1.emptyWritable)(), secrets),
31
- secrets,
32
- stdout: process.stdout,
33
- stderr: process.stderr,
34
- stdoutColumns: process.stdout.columns,
35
- }, process.argv, false).then(() => {
36
- settled = true;
37
- }, () => {
38
- settled = true;
39
- process.exit(1);
40
- });
19
+ var __importStar = (this && this.__importStar) || function (mod) {
20
+ if (mod && mod.__esModule) return mod;
21
+ var result = {};
22
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
23
+ __setModuleDefault(result, mod);
24
+ return result;
25
+ };
26
+ // A server on a cluster runs `hammerkit server-sandbox <directory>` in the pod of a
27
+ // sandbox's files for every request on them. It loads the code of sandboxes only: the
28
+ // whole CLI takes most of a second to load, and more on a pod with little CPU.
29
+ if (process.argv[2] === 'server-sandbox' && process.argv.length === 4) {
30
+ void Promise.resolve().then(() => __importStar(require('./server/sandbox-pod-command'))).then(({ runSandboxPodCommand }) => runSandboxPodCommand(process.argv[3], process.stdin, process.stdout).catch((e) => {
31
+ process.stderr.write(`${e instanceof Error ? e.message : String(e)}\n`);
32
+ process.exitCode = 1;
33
+ }));
34
+ }
35
+ else {
36
+ void Promise.resolve().then(() => __importStar(require('./main')));
37
+ }
41
38
  //# sourceMappingURL=index.js.map
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.apply = exports.statusCodeOf = void 0;
3
+ exports.replace = exports.apply = exports.statusCodeOf = void 0;
4
4
  // The client throws an ApiException carrying the HTTP status as `code`; watch
5
5
  // errors carry it as `statusCode`.
6
6
  function statusCodeOf(e) {
@@ -30,4 +30,45 @@ async function apply(instance, spec) {
30
30
  }
31
31
  }
32
32
  exports.apply = apply;
33
+ /**
34
+ * Makes the object exactly as given: replaced as a whole when it exists, so what was
35
+ * dropped from it goes too, which a merge patch would keep (a key of a quota, a rule
36
+ * of a policy).
37
+ */
38
+ async function replace(instance, spec) {
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
+ }
57
+ }
58
+ try {
59
+ return await instance.objectApi.replace({
60
+ ...spec,
61
+ metadata: { ...spec.metadata, resourceVersion: current.metadata?.resourceVersion },
62
+ });
63
+ }
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;
69
+ }
70
+ }
71
+ }
72
+ }
73
+ exports.replace = replace;
33
74
  //# sourceMappingURL=apply.js.map
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.deleteJobAndWait = exports.awaitDeployRunningState = exports.deleteJob = exports.awaitJobCompletion = exports.awaitRunningState = void 0;
3
+ exports.stopJob = exports.deleteJobAndWait = exports.awaitDeployRunningState = exports.deleteJob = exports.awaitJobCompletion = exports.awaitRunningState = void 0;
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");
@@ -126,4 +126,36 @@ async function deleteJobAndWait(instance, env, name, abort, pollInterval = 500)
126
126
  }
127
127
  }
128
128
  exports.deleteJobAndWait = deleteJobAndWait;
129
+ // Stops a Job at once: its pods go without a grace period, and this returns once they
130
+ // are gone, so what limited them (the item's NetworkPolicy) can go after them.
131
+ async function stopJob(instance, env, name, timeout = 30000, pollInterval = 500) {
132
+ await deleteJob(instance, env, name);
133
+ const deadline = Date.now() + timeout;
134
+ for (;;) {
135
+ const pods = await instance.coreApi.listNamespacedPod({
136
+ namespace: env.namespace,
137
+ labelSelector: `job-name=${name}`,
138
+ });
139
+ if (pods.items.length === 0) {
140
+ return;
141
+ }
142
+ if (Date.now() > deadline) {
143
+ throw new Error(`the pods of ${name} were still there after ${Math.round(timeout / 1000)}s`);
144
+ }
145
+ for (const pod of pods.items) {
146
+ // also one the Job's deletion already ends, with the default grace period
147
+ if (pod.metadata?.name) {
148
+ await instance.coreApi
149
+ .deleteNamespacedPod({ name: pod.metadata.name, namespace: env.namespace, gracePeriodSeconds: 0 })
150
+ .catch((e) => {
151
+ if ((0, apply_1.statusCodeOf)(e) !== 404) {
152
+ throw e;
153
+ }
154
+ });
155
+ }
156
+ }
157
+ await (0, sleep_1.sleep)(pollInterval);
158
+ }
159
+ }
160
+ exports.stopJob = stopJob;
129
161
  //# sourceMappingURL=await-running-state.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
  ],
@@ -1,45 +1,42 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ensureKubernetesDeploymentExists = void 0;
4
- const work_item_1 = require("../planner/work-item");
3
+ exports.ensureKubernetesDeploymentExists = exports.DEFINITION_LABEL = void 0;
4
+ const service_definition_1 = require("../planner/service-definition");
5
5
  const work_service_1 = require("../planner/work-service");
6
+ const volumes_1 = require("./volumes");
6
7
  const service_hostname_1 = require("../planner/utils/service-hostname");
7
8
  const apply_1 = require("./apply");
8
- const get_service_ip_1 = require("./get-service-ip");
9
9
  const version_1 = require("../version");
10
10
  const replace_env_variables_1 = require("../environment/replace-env-variables");
11
11
  const await_running_state_1 = require("./await-running-state");
12
12
  const resources_1 = require("./resources");
13
13
  const work_healthcheck_1 = require("../planner/work-healthcheck");
14
14
  const container_resources_1 = require("./container-resources");
15
- async function ensureKubernetesDeploymentExists(instance, env, service, persistence, secrets, stateKey) {
16
- const hostAliases = [];
17
- for (const need of service.needs) {
18
- if (!(0, work_item_1.isContainerWorkServiceItem)(need.service)) {
19
- continue;
20
- }
21
- const ip = await (0, get_service_ip_1.getServiceIp)(instance, env, need.service);
22
- if (!ip) {
23
- throw new Error(`unable to get service ip for ${need.name}`);
24
- }
25
- hostAliases.push({
26
- ip,
27
- hostnames: [need.name],
28
- });
29
- }
15
+ const job_placement_1 = require("./job-placement");
16
+ const network_policy_1 = require("./network-policy");
17
+ const egress_proxy_1 = require("../network/egress-proxy");
18
+ // what the service was created from, to tell a changed one
19
+ exports.DEFINITION_LABEL = 'hammerkit.dev/definition';
20
+ async function ensureKubernetesDeploymentExists(instance, env, service, persistence, secrets, stateKey,
21
+ // the needed services by name, and the proxy of a limited network
22
+ network) {
30
23
  const envs = (0, replace_env_variables_1.getEnvironmentVariables)(service.data.envs);
31
- const name = (0, resources_1.getResourceName)(service);
24
+ const name = (0, resources_1.getResourceName)(service, env);
32
25
  const healthcheckCommand = (0, work_service_1.getHealthcheckCommand)(service.data);
33
26
  const serviceCommand = (0, work_service_1.getServiceCommand)(service.data, { kubernetes: true });
34
27
  const probe = healthcheckCommand
35
28
  ? {
36
29
  exec: {
37
- command: healthcheckCommand,
30
+ // the service itself, not through the proxy (see getProxylessEnvironment)
31
+ command: withoutProxy(healthcheckCommand, network.env),
38
32
  },
39
33
  periodSeconds: 5,
40
34
  failureThreshold: 3,
41
35
  }
42
36
  : undefined;
37
+ const server = env.server;
38
+ const placement = server ? (0, job_placement_1.getPodPlacement)(server, undefined) : null;
39
+ const workspace = (0, volumes_1.getWorkspacePodSpec)(env, persistence);
43
40
  const deployment = {
44
41
  kind: 'Deployment',
45
42
  apiVersion: 'apps/v1',
@@ -50,28 +47,40 @@ async function ensureKubernetesDeploymentExists(instance, env, service, persiste
50
47
  'hammerkit.dev/version': (0, version_1.getVersion)(),
51
48
  },
52
49
  labels: {
53
- 'hammerkit.dev/id': service.id(),
50
+ ...(0, resources_1.getItemLabels)(service, env),
54
51
  'hammerkit.dev/state': stateKey,
52
+ [exports.DEFINITION_LABEL]: (0, service_definition_1.getServiceDefinitionHash)(service),
55
53
  },
56
54
  },
57
55
  spec: {
58
56
  selector: {
59
- matchLabels: {
60
- 'hammerkit.dev/id': service.id(),
61
- },
57
+ matchLabels: (0, resources_1.getItemLabels)(service, env),
62
58
  },
63
59
  replicas: 1,
64
60
  template: {
65
61
  metadata: {
66
62
  labels: {
67
- 'hammerkit.dev/id': service.id(),
63
+ ...(0, resources_1.getItemLabels)(service, env),
68
64
  'hammerkit.dev/state': stateKey,
65
+ ...workspace.labels,
66
+ ...(0, network_policy_1.getNetworkLabels)(env, service),
69
67
  },
70
68
  },
71
69
  spec: {
70
+ ...(workspace.affinity ? { affinity: workspace.affinity } : {}),
72
71
  // the pod resolves its own service name, as with Docker's Hostname
73
72
  hostname: (0, service_hostname_1.getServiceHostname)(service.name),
74
- hostAliases: hostAliases,
73
+ hostAliases: network.hostAliases,
74
+ initContainers: network.initContainers,
75
+ // on a server the pod is the build's: no token of the namespace, and the
76
+ // nodes of the first pool
77
+ ...(placement
78
+ ? {
79
+ automountServiceAccountToken: false,
80
+ nodeSelector: placement.nodeSelector,
81
+ tolerations: placement.tolerations,
82
+ }
83
+ : {}),
75
84
  containers: [
76
85
  {
77
86
  name: service.name.replace(/:/, '-'),
@@ -83,6 +92,7 @@ async function ensureKubernetesDeploymentExists(instance, env, service, persiste
83
92
  value: value,
84
93
  })),
85
94
  ...secrets.env,
95
+ ...network.env,
86
96
  ],
87
97
  ...getKubernetesCommand(serviceCommand),
88
98
  ports: service.data.ports.map((p) => ({
@@ -92,6 +102,7 @@ async function ensureKubernetesDeploymentExists(instance, env, service, persiste
92
102
  livenessProbe: probe,
93
103
  ...(0, container_resources_1.getKubernetesResources)(service.data.resources),
94
104
  volumeMounts: [...persistence.mounts.map((m) => m.mount), ...secrets.mounts],
105
+ ...(0, job_placement_1.getContainerSecurity)(env),
95
106
  },
96
107
  {
97
108
  name: 'debug',
@@ -99,9 +110,10 @@ async function ensureKubernetesDeploymentExists(instance, env, service, persiste
99
110
  command: ['sleep'],
100
111
  args: ['3600'],
101
112
  volumeMounts: persistence.mounts.map((m) => m.mount),
113
+ ...(0, job_placement_1.getContainerSecurity)(env),
102
114
  },
103
115
  ],
104
- volumes: [...persistence.volumes, ...secrets.volumes],
116
+ volumes: [...persistence.volumes, ...persistence.workspace, ...secrets.volumes],
105
117
  },
106
118
  },
107
119
  },
@@ -111,7 +123,7 @@ async function ensureKubernetesDeploymentExists(instance, env, service, persiste
111
123
  return;
112
124
  }
113
125
  await (0, await_running_state_1.awaitDeployRunningState)(instance, env, name, {
114
- podSelector: `hammerkit.dev/id=${service.id()}`,
126
+ podSelector: (0, resources_1.getItemSelector)(service, env),
115
127
  timeout: service.data.healthcheck?.timeout ?? null,
116
128
  timeoutMessage: service.data.healthcheck
117
129
  ? (0, work_healthcheck_1.getHealthcheckTimeoutMessage)(service.name, service.data.healthcheck)
@@ -131,4 +143,10 @@ function getKubernetesCommand(command) {
131
143
  }
132
144
  return {};
133
145
  }
146
+ // A probe runs with the container's variables, so it empties the proxy ones
147
+ // through `env`, as a Docker healthcheck does with the exec's own variables.
148
+ function withoutProxy(command, env) {
149
+ const proxyless = (0, egress_proxy_1.getProxylessEnvironment)(Object.fromEntries(env.map((e) => [e.name, e.value ?? ''])));
150
+ return proxyless ? ['env', ...Object.keys(proxyless).map((key) => `${key}=`), ...command] : command;
151
+ }
134
152
  //# sourceMappingURL=ensure-kubernetes-deployment-exists.js.map
@@ -1,9 +1,46 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ensureKubernetesPersistentVolumeClaimExists = void 0;
3
+ exports.ensureKubernetesPersistentVolumeClaimExists = exports.getClaimSize = exports.DEFAULT_VOLUME_SIZE = void 0;
4
4
  const apply_1 = require("./apply");
5
5
  const version_1 = require("../version");
6
- async function ensureKubernetesPersistentVolumeClaimExists(instance, env, volume, service) {
6
+ const job_placement_1 = require("./job-placement");
7
+ const log_1 = require("../log");
8
+ const units_1 = require("../utils/units");
9
+ const sleep_1 = require("../utils/sleep");
10
+ const kept_claims_1 = require("./kept-claims");
11
+ // how long a claim that is being removed (a kept one that expired) gets to go before one
12
+ // of its name is made again, and the kept claims of other sources when the quota is full
13
+ const REMOVAL_TIMEOUT = 60000;
14
+ // what the claim of a task or service requests when neither it nor the environment says
15
+ exports.DEFAULT_VOLUME_SIZE = '1Gi';
16
+ // The size of a claim: the work's `volumeSize`, else the environment's, at most what the
17
+ // environment allows (a server's `maxVolumeSize`).
18
+ function getClaimSize(env, size, name) {
19
+ const requested = size ?? env.volumeSize ?? exports.DEFAULT_VOLUME_SIZE;
20
+ if (env.maxVolumeSize && (0, units_1.parseSize)(requested) > (0, units_1.parseSize)(env.maxVolumeSize)) {
21
+ throw new Error(`${name} asks for a claim of ${requested}, the server allows at most ${env.maxVolumeSize}`);
22
+ }
23
+ return requested;
24
+ }
25
+ exports.getClaimSize = getClaimSize;
26
+ // The size a claim requests, in bytes, as the cluster may write it back (`1Gi`, bytes)
27
+ function getRequestedBytes(claim) {
28
+ const storage = claim.spec?.resources?.requests?.storage;
29
+ try {
30
+ return storage === undefined ? null : (0, units_1.parseSize)(String(storage));
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ // The claim of a work item, of `size` (see getClaimSize). One a run before made smaller
37
+ // grows, where its storage class allows that; a larger one stays as it is, since a claim
38
+ // never shrinks. A `kept` claim (see kept-claims) carries the scope of the run instead of
39
+ // its label, and does not expire while the run mounts it.
40
+ async function ensureKubernetesPersistentVolumeClaimExists(instance, env, volume, service, size, kept = false) {
41
+ const storage = getClaimSize(env, size, service.name);
42
+ const scope = kept ? env.server?.volumeScope : undefined;
43
+ const runLabel = env.server ? (0, job_placement_1.getRunLabelValue)(env.server.run) : undefined;
7
44
  const pvc = {
8
45
  kind: 'PersistentVolumeClaim',
9
46
  apiVersion: 'v1',
@@ -15,6 +52,7 @@ async function ensureKubernetesPersistentVolumeClaimExists(instance, env, volume
15
52
  },
16
53
  labels: {
17
54
  'hammerkit.dev/id': service.id(),
55
+ ...(scope && runLabel ? { [kept_claims_1.VOLUME_SCOPE_LABEL]: scope, [kept_claims_1.KEPT_BY_LABEL]: runLabel } : (0, job_placement_1.getRunLabels)(env)),
18
56
  },
19
57
  },
20
58
  spec: {
@@ -22,19 +60,108 @@ async function ensureKubernetesPersistentVolumeClaimExists(instance, env, volume
22
60
  storageClassName: env.storageClass,
23
61
  resources: {
24
62
  requests: {
25
- storage: '1Gi',
63
+ storage,
26
64
  },
27
65
  },
28
66
  },
29
67
  };
30
- service.status.console('stdout', `ensure volume ${volume.name}`);
68
+ service.status.write('debug', `ensure volume ${volume.name}`);
31
69
  // Don't block on the PVC reaching "Bound". Storage classes with
32
70
  // volumeBindingMode WaitForFirstConsumer (docker-desktop's "standard",
33
71
  // k3d/k3s local-path, most cloud defaults) keep the claim Pending until a pod
34
72
  // mounts it — and hammerkit creates that pod (upload pod / deployment) only
35
73
  // after this call. Waiting here deadlocks until the test times out. k8s binds
36
74
  // the claim when the consuming pod is scheduled, so applying it is enough.
37
- return await (0, apply_1.apply)(instance, pvc);
75
+ let existing = await readClaim(instance, pvc);
76
+ if (existing?.metadata?.deletionTimestamp) {
77
+ // a kept claim that expired: one of the same name is made once it is gone
78
+ existing = await untilRemoved(instance, pvc);
79
+ }
80
+ if (!existing) {
81
+ return await createClaim(instance, env, pvc);
82
+ }
83
+ if (scope) {
84
+ if (existing.metadata?.labels?.[kept_claims_1.VOLUME_SCOPE_LABEL] !== scope) {
85
+ throw new Error(`the claim ${volume.name} is not one this run may mount, it was made for another source`);
86
+ }
87
+ if (existing.metadata?.labels?.[kept_claims_1.KEPT_BY_LABEL] !== runLabel ||
88
+ existing.metadata?.annotations?.[kept_claims_1.EXPIRES_ANNOTATION]) {
89
+ // mounted by this run now: it expires at the run's end
90
+ existing = await instance.objectApi.patch({
91
+ apiVersion: pvc.apiVersion,
92
+ kind: pvc.kind,
93
+ metadata: {
94
+ name: pvc.metadata.name,
95
+ namespace: pvc.metadata.namespace,
96
+ labels: { [kept_claims_1.KEPT_BY_LABEL]: runLabel },
97
+ // null removes it
98
+ annotations: { [kept_claims_1.EXPIRES_ANNOTATION]: null },
99
+ },
100
+ });
101
+ }
102
+ }
103
+ const current = getRequestedBytes(existing);
104
+ if (current === null || current >= (0, units_1.parseSize)(storage)) {
105
+ return existing;
106
+ }
107
+ try {
108
+ // the requested size is all of a bound claim's spec that may change
109
+ return await instance.objectApi.patch({
110
+ ...pvc,
111
+ spec: { resources: { requests: { storage } } },
112
+ });
113
+ }
114
+ catch (e) {
115
+ throw new Error(`the claim ${volume.name} has ${(0, units_1.formatSize)(current)} and ${service.name} needs ${storage}, ` +
116
+ `which its storage class could not grow it to (${(0, log_1.getErrorMessage)(e)}): ` +
117
+ 'remove it with hammerkit clean and run again');
118
+ }
38
119
  }
39
120
  exports.ensureKubernetesPersistentVolumeClaimExists = ensureKubernetesPersistentVolumeClaimExists;
121
+ async function createClaim(instance, env, pvc) {
122
+ for (let evicted = false;; evicted = true) {
123
+ try {
124
+ return await instance.objectApi.create(pvc);
125
+ }
126
+ catch (e) {
127
+ // Two work items can make the same claim concurrently (e.g. a volume
128
+ // shared between a task and a service that inherits its generates)
129
+ if ((0, apply_1.statusCodeOf)(e) === 409) {
130
+ return await instance.objectApi.read(pvc);
131
+ }
132
+ // the claims a server keeps for other sources make way under the quota
133
+ if (evicted || (0, apply_1.statusCodeOf)(e) !== 403 || !/exceeded quota/i.test((0, log_1.getErrorMessage)(e))) {
134
+ throw e;
135
+ }
136
+ const removed = await (0, kept_claims_1.evictKeptClaims)(instance, env, REMOVAL_TIMEOUT);
137
+ if (removed.length === 0) {
138
+ throw e;
139
+ }
140
+ }
141
+ }
142
+ }
143
+ async function untilRemoved(instance, pvc) {
144
+ const deadline = Date.now() + REMOVAL_TIMEOUT;
145
+ for (;;) {
146
+ const claim = await readClaim(instance, pvc);
147
+ if (!claim) {
148
+ return null;
149
+ }
150
+ if (Date.now() > deadline) {
151
+ throw new Error(`the claim ${pvc.metadata.name} is being removed and still there after ${REMOVAL_TIMEOUT / 1000}s`);
152
+ }
153
+ await (0, sleep_1.sleep)(1000);
154
+ }
155
+ }
156
+ async function readClaim(instance, pvc) {
157
+ try {
158
+ return await instance.objectApi.read(pvc);
159
+ }
160
+ catch (e) {
161
+ if ((0, apply_1.statusCodeOf)(e) === 404) {
162
+ return null;
163
+ }
164
+ throw e;
165
+ }
166
+ }
40
167
  //# sourceMappingURL=ensure-kubernetes-persistent-volume-claim-exists.js.map
@@ -4,8 +4,11 @@ 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
+ // A service without ports gets a headless Service: a ClusterIP one needs a port,
8
+ // and its tasks reach every port of the pod, as on docker.
7
9
  async function ensureKubernetesServiceExists(instance, env, service) {
8
- const name = (0, resources_1.getResourceName)(service);
10
+ const name = (0, resources_1.getResourceName)(service, env);
11
+ const headless = service.data.ports.length === 0;
9
12
  const svc = {
10
13
  kind: 'Service',
11
14
  apiVersion: 'v1',
@@ -15,21 +18,34 @@ async function ensureKubernetesServiceExists(instance, env, service) {
15
18
  annotations: {
16
19
  'hammerkit.dev/version': (0, version_1.getVersion)(),
17
20
  },
18
- labels: {
19
- 'hammerkit.dev/id': service.id(),
20
- },
21
+ labels: (0, resources_1.getItemLabels)(service, env),
21
22
  },
22
23
  spec: {
23
24
  type: 'ClusterIP',
24
- selector: {
25
- 'hammerkit.dev/id': service.id(),
26
- },
27
- ports: service.data.ports.map((port) => ({
28
- port: port.hostPort ?? port.containerPort,
29
- targetPort: port.containerPort,
30
- })),
25
+ ...(headless ? { clusterIP: 'None' } : {}),
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),
28
+ ...(headless
29
+ ? {}
30
+ : {
31
+ ports: service.data.ports.map((port) => ({
32
+ port: port.hostPort ?? port.containerPort,
33
+ targetPort: port.containerPort,
34
+ })),
35
+ }),
31
36
  },
32
37
  };
38
+ // the clusterIP of a Service is fixed, so one from before its ports changed goes first
39
+ const existing = await instance.coreApi
40
+ .readNamespacedService({ name, namespace: env.namespace })
41
+ .catch((e) => ((0, apply_1.statusCodeOf)(e) === 404 ? null : Promise.reject(e)));
42
+ if (existing && (existing.spec?.clusterIP === 'None') !== headless) {
43
+ await instance.coreApi.deleteNamespacedService({ name, namespace: env.namespace }).catch((e) => {
44
+ if ((0, apply_1.statusCodeOf)(e) !== 404) {
45
+ throw e;
46
+ }
47
+ });
48
+ }
33
49
  return await (0, apply_1.apply)(instance, svc);
34
50
  }
35
51
  exports.ensureKubernetesServiceExists = ensureKubernetesServiceExists;