hammerkit 1.27.0-next.8 → 1.27.0-next.9

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.
@@ -36,28 +36,39 @@ exports.apply = apply;
36
36
  * of a policy).
37
37
  */
38
38
  async function replace(instance, spec) {
39
- let current;
40
- try {
41
- current = await instance.objectApi.read(spec);
42
- }
43
- catch (e) {
44
- if (statusCodeOf(e) !== 404) {
45
- throw e;
39
+ for (let attempt = 1;; attempt++) {
40
+ let current;
41
+ try {
42
+ current = await instance.objectApi.read(spec);
43
+ }
44
+ catch (e) {
45
+ if (statusCodeOf(e) !== 404) {
46
+ throw e;
47
+ }
48
+ try {
49
+ return await instance.objectApi.create(spec);
50
+ }
51
+ catch (createError) {
52
+ if (statusCodeOf(createError) !== 409) {
53
+ throw createError;
54
+ }
55
+ current = await instance.objectApi.read(spec);
56
+ }
46
57
  }
47
58
  try {
48
- return await instance.objectApi.create(spec);
59
+ return await instance.objectApi.replace({
60
+ ...spec,
61
+ metadata: { ...spec.metadata, resourceVersion: current.metadata?.resourceVersion },
62
+ });
49
63
  }
50
- catch (createError) {
51
- if (statusCodeOf(createError) !== 409) {
52
- throw createError;
64
+ catch (e) {
65
+ // replaced by someone else since it was read (the server sets up a caller's
66
+ // namespace for a run and for a sandbox at the same time): read it again
67
+ if (statusCodeOf(e) !== 409 || attempt >= 3) {
68
+ throw e;
53
69
  }
54
- current = await instance.objectApi.read(spec);
55
70
  }
56
71
  }
57
- return await instance.objectApi.replace({
58
- ...spec,
59
- metadata: { ...spec.metadata, resourceVersion: current.metadata?.resourceVersion },
60
- });
61
72
  }
62
73
  exports.replace = replace;
63
74
  //# sourceMappingURL=apply.js.map
@@ -36,7 +36,7 @@ async function ensureHttpRoute(instance, env, route, service) {
36
36
  ],
37
37
  backendRefs: [
38
38
  {
39
- name: (0, resources_1.getResourceName)(service),
39
+ name: (0, resources_1.getResourceName)(service, env),
40
40
  port: route.servicePort ?? service.data.ports[0].containerPort,
41
41
  },
42
42
  ],
@@ -21,7 +21,7 @@ async function ensureKubernetesDeploymentExists(instance, env, service, persiste
21
21
  // the needed services by name, and the proxy of a limited network
22
22
  network) {
23
23
  const envs = (0, replace_env_variables_1.getEnvironmentVariables)(service.data.envs);
24
- const name = (0, resources_1.getResourceName)(service);
24
+ const name = (0, resources_1.getResourceName)(service, env);
25
25
  const healthcheckCommand = (0, work_service_1.getHealthcheckCommand)(service.data);
26
26
  const serviceCommand = (0, work_service_1.getServiceCommand)(service.data, { kubernetes: true });
27
27
  const probe = healthcheckCommand
@@ -47,25 +47,21 @@ network) {
47
47
  'hammerkit.dev/version': (0, version_1.getVersion)(),
48
48
  },
49
49
  labels: {
50
- 'hammerkit.dev/id': service.id(),
50
+ ...(0, resources_1.getItemLabels)(service, env),
51
51
  'hammerkit.dev/state': stateKey,
52
- ...(0, job_placement_1.getRunLabels)(env),
53
52
  [exports.DEFINITION_LABEL]: (0, service_definition_1.getServiceDefinitionHash)(service),
54
53
  },
55
54
  },
56
55
  spec: {
57
56
  selector: {
58
- matchLabels: {
59
- 'hammerkit.dev/id': service.id(),
60
- },
57
+ matchLabels: (0, resources_1.getItemLabels)(service, env),
61
58
  },
62
59
  replicas: 1,
63
60
  template: {
64
61
  metadata: {
65
62
  labels: {
66
- 'hammerkit.dev/id': service.id(),
63
+ ...(0, resources_1.getItemLabels)(service, env),
67
64
  'hammerkit.dev/state': stateKey,
68
- ...(0, job_placement_1.getRunLabels)(env),
69
65
  ...workspace.labels,
70
66
  ...(0, network_policy_1.getNetworkLabels)(env, service),
71
67
  },
@@ -127,7 +123,7 @@ network) {
127
123
  return;
128
124
  }
129
125
  await (0, await_running_state_1.awaitDeployRunningState)(instance, env, name, {
130
- podSelector: `hammerkit.dev/id=${service.id()}`,
126
+ podSelector: (0, resources_1.getItemSelector)(service, env),
131
127
  timeout: service.data.healthcheck?.timeout ?? null,
132
128
  timeoutMessage: service.data.healthcheck
133
129
  ? (0, work_healthcheck_1.getHealthcheckTimeoutMessage)(service.name, service.data.healthcheck)
@@ -4,11 +4,10 @@ exports.ensureKubernetesServiceExists = void 0;
4
4
  const apply_1 = require("./apply");
5
5
  const resources_1 = require("./resources");
6
6
  const version_1 = require("../version");
7
- const job_placement_1 = require("./job-placement");
8
7
  // A service without ports gets a headless Service: a ClusterIP one needs a port,
9
8
  // and its tasks reach every port of the pod, as on docker.
10
9
  async function ensureKubernetesServiceExists(instance, env, service) {
11
- const name = (0, resources_1.getResourceName)(service);
10
+ const name = (0, resources_1.getResourceName)(service, env);
12
11
  const headless = service.data.ports.length === 0;
13
12
  const svc = {
14
13
  kind: 'Service',
@@ -19,17 +18,13 @@ async function ensureKubernetesServiceExists(instance, env, service) {
19
18
  annotations: {
20
19
  'hammerkit.dev/version': (0, version_1.getVersion)(),
21
20
  },
22
- labels: {
23
- 'hammerkit.dev/id': service.id(),
24
- ...(0, job_placement_1.getRunLabels)(env),
25
- },
21
+ labels: (0, resources_1.getItemLabels)(service, env),
26
22
  },
27
23
  spec: {
28
24
  type: 'ClusterIP',
29
25
  ...(headless ? { clusterIP: 'None' } : {}),
30
- selector: {
31
- 'hammerkit.dev/id': service.id(),
32
- },
26
+ // the pods of the service of this run, on a server where runs share the namespace
27
+ selector: (0, resources_1.getItemLabels)(service, env),
33
28
  ...(headless
34
29
  ? {}
35
30
  : {
@@ -67,17 +67,14 @@ async function getPodForPersistence(instance, env, service, persistence, type, f
67
67
  for (const volume of persistence.volumes) {
68
68
  await (0, ensure_kubernetes_persistent_volume_claim_exists_1.ensureKubernetesPersistentVolumeClaimExists)(instance, env, volume, service, persistence.sizes[volume.name], persistence.kept.includes(volume.name));
69
69
  }
70
- const name = (0, resources_1.getResourceName)(service, type === 'write' ? '-upload' : '-download');
70
+ const name = (0, resources_1.getResourceName)(service, env, type === 'write' ? '-upload' : '-download');
71
71
  const podSpec = {
72
72
  kind: 'Pod',
73
73
  apiVersion: 'v1',
74
74
  metadata: {
75
75
  namespace: env.namespace,
76
76
  name,
77
- labels: {
78
- 'hammerkit.dev/id': service.id(),
79
- ...(0, job_placement_1.getRunLabels)(env),
80
- },
77
+ labels: (0, resources_1.getItemLabels)(service, env),
81
78
  annotations: {
82
79
  'hammerkit.dev/version': (0, version_1.getVersion)(),
83
80
  },
@@ -5,7 +5,7 @@ const resources_1 = require("./resources");
5
5
  // The address a task reaches the service on: its clusterIP, or for a headless
6
6
  // Service (a service without ports) the address of its ready pod.
7
7
  async function getServiceIp(instance, env, workService) {
8
- const name = (0, resources_1.getResourceName)(workService);
8
+ const name = (0, resources_1.getResourceName)(workService, env);
9
9
  const svc = await instance.coreApi.readNamespacedService({ name, namespace: env.namespace });
10
10
  const clusterIP = svc.spec?.clusterIP ?? null;
11
11
  if (clusterIP !== 'None') {
@@ -13,7 +13,7 @@ async function getServiceIp(instance, env, workService) {
13
13
  }
14
14
  const pods = await instance.coreApi.listNamespacedPod({
15
15
  namespace: env.namespace,
16
- labelSelector: `hammerkit.dev/id=${workService.id()}`,
16
+ labelSelector: (0, resources_1.getItemSelector)(workService, env),
17
17
  });
18
18
  const ready = pods.items.find((pod) => !pod.metadata?.deletionTimestamp &&
19
19
  pod.status?.podIP &&
@@ -65,8 +65,8 @@ exports.getSecretHosts = getSecretHosts;
65
65
  function accountsOf(secrets) {
66
66
  return [...new Set(secrets.map((s) => s.source.account))];
67
67
  }
68
- function getCredentialsName(item) {
69
- return (0, resources_1.getResourceName)(item, '-credentials');
68
+ function getCredentialsName(item, kubernetes) {
69
+ return (0, resources_1.getResourceName)(item, kubernetes, '-credentials');
70
70
  }
71
71
  exports.getCredentialsName = getCredentialsName;
72
72
  // The keys of the credentials Secret: `<account>.token` and `<account>.file.<key>`
@@ -136,9 +136,9 @@ async function ensureJobCredentials(instance, kubernetes, item, secrets, admitte
136
136
  apiVersion: 'v1',
137
137
  metadata: {
138
138
  namespace: kubernetes.namespace,
139
- name: getCredentialsName(item),
139
+ name: getCredentialsName(item, kubernetes),
140
140
  annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() },
141
- labels: { 'hammerkit.dev/id': item.id(), ...(0, job_placement_1.getRunLabels)(kubernetes) },
141
+ labels: (0, resources_1.getItemLabels)(item, kubernetes),
142
142
  },
143
143
  type: 'Opaque',
144
144
  data,
@@ -169,7 +169,10 @@ async function removeJobCredentials(instance, kubernetes, item) {
169
169
  return;
170
170
  }
171
171
  try {
172
- await instance.coreApi.deleteNamespacedSecret({ name: getCredentialsName(item), namespace: kubernetes.namespace });
172
+ await instance.coreApi.deleteNamespacedSecret({
173
+ name: getCredentialsName(item, kubernetes),
174
+ namespace: kubernetes.namespace,
175
+ });
173
176
  }
174
177
  catch (e) {
175
178
  if ((0, apply_1.statusCodeOf)(e) !== 404) {
@@ -213,7 +216,7 @@ proxyEnv = []) {
213
216
  }
214
217
  const name = `hammerkit-account-${index}`;
215
218
  accountVolume.set(account, name);
216
- volumes.push({ name, secret: { secretName: getCredentialsName(item), items, defaultMode: 0o444 } });
219
+ volumes.push({ name, secret: { secretName: getCredentialsName(item, { server }), items, defaultMode: 0o444 } });
217
220
  });
218
221
  const initContainers = secrets.map(({ index, secret, source }) => {
219
222
  const label = `secret ${(0, work_secret_1.getSecretName)(secret)} (${(0, work_secret_1.describeSecretSource)(secret)})`;
@@ -5,6 +5,7 @@ const crypto_1 = require("crypto");
5
5
  const apply_1 = require("./apply");
6
6
  const sleep_1 = require("../utils/sleep");
7
7
  const job_placement_1 = require("./job-placement");
8
+ const resources_1 = require("./resources");
8
9
  // On a server with `backend.keepVolumes`, the claim of a task stays after the run, so the
9
10
  // next run of the same source finds the outputs in it and mounts them without restoring
10
11
  // them from the cache. A kept claim has no run label (the end of the run removes what has
@@ -18,11 +19,15 @@ exports.STATE_ANNOTATION = 'hammerkit.dev/state';
18
19
  // when it goes, set by the end of the run that mounted it last
19
20
  exports.EXPIRES_ANNOTATION = 'hammerkit.dev/expires';
20
21
  // The claim of a work item: a task's on a server that keeps them carries the scope, so a
21
- // run of another source never mounts it. 63 characters at most, a pod's volume name.
22
+ // run of another source never mounts it, and the runs of one scope go one at a time
23
+ // (KubernetesExecutor.exclusive). Any other claim on a server is the run's own, as the
24
+ // runs of a caller share their namespace. 63 characters at most, a pod's volume name.
22
25
  function getClaimName(work, kubernetes) {
23
- const scope = kubernetes?.server?.volumeScope;
24
- const name = 'hammerkit-' + work.id();
25
- return scope && isTask(work) ? `${name}-${(0, crypto_1.createHash)('sha256').update(scope).digest('hex').slice(0, 12)}` : name;
26
+ const scope = kubernetes.server?.volumeScope;
27
+ if (scope && isTask(work)) {
28
+ return `hammerkit-${work.id()}-${(0, crypto_1.createHash)('sha256').update(scope).digest('hex').slice(0, 12)}`;
29
+ }
30
+ return 'hammerkit-' + (0, resources_1.getItemKey)(work, kubernetes);
26
31
  }
27
32
  exports.getClaimName = getClaimName;
28
33
  // whether the claim of the work item stays after the run
@@ -156,14 +161,17 @@ async function expireKeptClaims(instance, namespace, runLabel, expires) {
156
161
  }
157
162
  }
158
163
  exports.expireKeptClaims = expireKeptClaims;
159
- // While no run of the caller is in the namespace: the kept claims that expired, and those
160
- // a run left without an expiry because the server stopped before its end (all of them
161
- // when the server keeps none any more). The names of those removed, and when the next of
162
- // the others expires.
164
+ // The kept claims that expired, and those a run left without an expiry because the server
165
+ // stopped before its end (all of them when the server keeps none any more), but those of
166
+ // the scopes `inUse`: a run of the scope executes and mounts them, or may in a moment.
167
+ // The names of those removed, and when the next of the others expires.
163
168
  async function sweepKeptClaims(instance, namespace, options) {
164
169
  const expired = [];
165
170
  let next = null;
166
171
  for (const claim of await listKeptClaims(instance, namespace)) {
172
+ if (options.inUse?.has(claim.metadata?.labels?.[exports.VOLUME_SCOPE_LABEL] ?? '')) {
173
+ continue;
174
+ }
167
175
  const expires = Date.parse(claim.metadata?.annotations?.[exports.EXPIRES_ANNOTATION] ?? '');
168
176
  if (!options.keep || !(expires > options.now.getTime())) {
169
177
  expired.push(claim);
@@ -178,14 +186,18 @@ async function sweepKeptClaims(instance, namespace, options) {
178
186
  exports.sweepKeptClaims = sweepKeptClaims;
179
187
  // When a run needs room under the quota of its namespace: the kept claims it does not use
180
188
  // go, those of the other sources and those of its own a task of this run did not take (one
181
- // renamed or changed since). The names of those removed.
189
+ // renamed or changed since). Of another scope, only one its last run released, which has an
190
+ // expiry: one without is mounted by a run of the caller that executes now (or was left by
191
+ // a server that stopped, which the next sweep removes). The names of those removed.
182
192
  async function evictKeptClaims(instance, kubernetes, timeout) {
183
193
  const server = kubernetes.server;
184
194
  if (!server?.volumeScope) {
185
195
  return [];
186
196
  }
187
197
  const unused = await listKeptClaims(instance, kubernetes.namespace, `${exports.VOLUME_SCOPE_LABEL},${exports.KEPT_BY_LABEL}!=${(0, job_placement_1.getRunLabelValue)(server.run)}`);
188
- return removeClaims(instance, kubernetes.namespace, unused, timeout);
198
+ const released = unused.filter((claim) => claim.metadata?.labels?.[exports.VOLUME_SCOPE_LABEL] === server.volumeScope ||
199
+ !!claim.metadata?.annotations?.[exports.EXPIRES_ANNOTATION]);
200
+ return removeClaims(instance, kubernetes.namespace, released, timeout);
189
201
  }
190
202
  exports.evictKeptClaims = evictKeptClaims;
191
203
  //# sourceMappingURL=kept-claims.js.map
@@ -17,7 +17,7 @@ const sleep_1 = require("../utils/sleep");
17
17
  const network_gate_1 = require("./network-gate");
18
18
  const job_placement_1 = require("./job-placement");
19
19
  Object.defineProperty(exports, "RUN_LABEL", { enumerable: true, get: function () { return job_placement_1.RUN_LABEL; } });
20
- const ID_LABEL = 'hammerkit.dev/id';
20
+ const resources_1 = require("./resources");
21
21
  const PROXY_LABEL = 'hammerkit.dev/proxy-for';
22
22
  const NETWORK_OF_LABEL = 'hammerkit.dev/network-of';
23
23
  // on the proxy of a task on a sandbox: the sandbox, whose pod of files it stays with
@@ -86,7 +86,7 @@ function getNetworkPolicyName(item, scope) {
86
86
  exports.getNetworkPolicyName = getNetworkPolicyName;
87
87
  // the pods of the item, of the run when the scope has one
88
88
  function getItemPodLabels(item, scope) {
89
- return { [ID_LABEL]: item.id(), ...(scope.run ? { [job_placement_1.RUN_LABEL]: scope.run } : {}) };
89
+ return { [resources_1.ID_LABEL]: item.id(), ...(scope.run ? { [job_placement_1.RUN_LABEL]: scope.run } : {}) };
90
90
  }
91
91
  function toSelector(labels) {
92
92
  return Object.entries(labels)
@@ -107,7 +107,12 @@ function getIngress(env, item) {
107
107
  if (ids.length === 0) {
108
108
  return [];
109
109
  }
110
- const from = { matchExpressions: [{ key: ID_LABEL, operator: 'In', values: ids }] };
110
+ // on a server, of this run: the runs of a caller share their namespace
111
+ const run = (0, job_placement_1.getRunLabels)(env);
112
+ const from = {
113
+ matchExpressions: [{ key: resources_1.ID_LABEL, operator: 'In', values: ids }],
114
+ ...(Object.keys(run).length > 0 ? { matchLabels: run } : {}),
115
+ };
111
116
  // the client's name for `from`, a reserved word
112
117
  return [{ _from: [{ podSelector: from }] }];
113
118
  }
@@ -132,7 +137,7 @@ function buildNetworkPolicy(env, item, network, scope, proxy = getRunProxy(item,
132
137
  ingress: getIngress(env, item),
133
138
  egress: [
134
139
  ...(needs.length > 0
135
- ? [{ to: needs.map((need) => ({ podSelector: { matchLabels: { [ID_LABEL]: need.service.id() } } })) }]
140
+ ? [{ to: needs.map((need) => ({ podSelector: { matchLabels: (0, resources_1.getItemLabels)(need.service, env) } })) }]
136
141
  : []),
137
142
  ...(proxied
138
143
  ? [
@@ -306,12 +311,14 @@ async function releaseKubernetesItemNetwork(instance, env, item, scope) {
306
311
  }
307
312
  exports.releaseKubernetesItemNetwork = releaseKubernetesItemNetwork;
308
313
  // Every policy and proxy of the item, of any run, once none of its pods runs:
309
- // what `down` and `clean` remove.
314
+ // what `down` and `clean` remove. On a server, those of the server's run only: the
315
+ // runs of a caller share their namespace, and the pods of another run of the same item
316
+ // would run unlimited without their policy.
310
317
  async function removeAllKubernetesItemNetworks(instance, env, item) {
311
- if (!(await awaitPodsEnded(instance, env, { [ID_LABEL]: item.id() }))) {
318
+ if (!(await awaitPodsEnded(instance, env, (0, resources_1.getItemLabels)(item, env)))) {
312
319
  throw new Error(`pods of ${item.name} are still running, so its network policy stays`);
313
320
  }
314
- const labelSelector = `${NETWORK_OF_LABEL}=${item.id()}`;
321
+ const labelSelector = toSelector({ [NETWORK_OF_LABEL]: item.id(), ...(0, job_placement_1.getRunLabels)(env) });
315
322
  let policies;
316
323
  try {
317
324
  const list = await instance.objectApi.list('networking.k8s.io/v1', 'NetworkPolicy', env.namespace, undefined, undefined, undefined, undefined, labelSelector);
@@ -1,8 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.removePersistentData = void 0;
4
+ const resources_1 = require("./resources");
4
5
  async function removePersistentData(instance, kubernetes, task) {
5
- const labelSelector = `hammerkit.dev/id=${task.id()}`;
6
+ // on a server, the run's: a claim it kept has no run label and goes once it expired
7
+ const labelSelector = (0, resources_1.getItemSelector)(task, kubernetes);
6
8
  const configmaps = await instance.coreApi.listNamespacedConfigMap({ namespace: kubernetes.namespace, labelSelector });
7
9
  for (const configMap of configmaps.items) {
8
10
  if (configMap.metadata?.name) {
@@ -1,12 +1,34 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.removeInvalidCharacters = exports.getResourceName = void 0;
3
+ exports.removeInvalidCharacters = exports.getResourceName = exports.getItemSelector = exports.getItemLabels = exports.getItemKey = exports.ID_LABEL = void 0;
4
+ const job_placement_1 = require("./job-placement");
4
5
  // An object name is a DNS label: lowercase letters, digits and `-`, at most 63
5
6
  // characters, starting and ending with a letter or digit. The id makes a name
6
7
  // unique; the item's name is there for people and is cut to fit.
7
8
  const MAX_NAME = 63;
8
- function getResourceName(item, suffix) {
9
- const tail = `-${item.id()}${suffix ?? ''}`;
9
+ exports.ID_LABEL = 'hammerkit.dev/id';
10
+ // What tells the objects of an item apart in its namespace: its id, and on a server the
11
+ // run's label too. The runs of a caller execute side by side in their namespace, and two
12
+ // of them often run the same task (the same command on two sandboxes of a repository),
13
+ // so each has objects of its own, which nothing of the other run finds or removes. The
14
+ // first 8 digits of the run label tell runs apart, as they do for a run's proxies.
15
+ function getItemKey(item, env) {
16
+ return env.server ? `${item.id().substring(0, 32)}-${(0, job_placement_1.getRunLabelValue)(env.server.run).substring(0, 8)}` : item.id();
17
+ }
18
+ exports.getItemKey = getItemKey;
19
+ // The labels the objects of an item carry, by which they are found again
20
+ function getItemLabels(item, env) {
21
+ return { [exports.ID_LABEL]: item.id(), ...(0, job_placement_1.getRunLabels)(env) };
22
+ }
23
+ exports.getItemLabels = getItemLabels;
24
+ function getItemSelector(item, env) {
25
+ return Object.entries(getItemLabels(item, env))
26
+ .map(([key, value]) => `${key}=${value}`)
27
+ .join(',');
28
+ }
29
+ exports.getItemSelector = getItemSelector;
30
+ function getResourceName(item, env, suffix) {
31
+ const tail = `-${getItemKey(item, env)}${suffix ?? ''}`;
10
32
  const name = removeInvalidCharacters(item.name)
11
33
  .slice(0, Math.max(0, MAX_NAME - tail.length))
12
34
  .replace(/-+$/, '');
@@ -5,19 +5,18 @@ const work_secret_1 = require("../planner/work-secret");
5
5
  const apply_1 = require("./apply");
6
6
  const resources_1 = require("./resources");
7
7
  const version_1 = require("../version");
8
- const job_placement_1 = require("./job-placement");
9
8
  const SECRET_VOLUME_NAME = 'hammerkit-secrets';
10
- function getSecretResourceName(item) {
11
- return (0, resources_1.getResourceName)(item, '-secrets');
9
+ function getSecretResourceName(item, kubernetes) {
10
+ return (0, resources_1.getResourceName)(item, kubernetes, '-secrets');
12
11
  }
13
12
  exports.getSecretResourceName = getSecretResourceName;
14
13
  // The values go into a Secret of the item; the pod only references it, so no
15
14
  // value is part of a Job or Deployment spec.
16
- function getKubernetesSecretRefs(item, secrets) {
15
+ function getKubernetesSecretRefs(item, kubernetes, secrets) {
17
16
  if (secrets.length === 0) {
18
17
  return { env: [], mounts: [], volumes: [] };
19
18
  }
20
- const secretName = getSecretResourceName(item);
19
+ const secretName = getSecretResourceName(item, kubernetes);
21
20
  const refs = {
22
21
  env: [],
23
22
  mounts: [],
@@ -50,14 +49,11 @@ async function ensureKubernetesSecret(instance, kubernetes, item, secrets, envir
50
49
  apiVersion: 'v1',
51
50
  metadata: {
52
51
  namespace: kubernetes.namespace,
53
- name: getSecretResourceName(item),
52
+ name: getSecretResourceName(item, kubernetes),
54
53
  annotations: {
55
54
  'hammerkit.dev/version': (0, version_1.getVersion)(),
56
55
  },
57
- labels: {
58
- 'hammerkit.dev/id': item.id(),
59
- ...(0, job_placement_1.getRunLabels)(kubernetes),
60
- },
56
+ labels: (0, resources_1.getItemLabels)(item, kubernetes),
61
57
  },
62
58
  type: 'Opaque',
63
59
  stringData,
@@ -68,7 +64,7 @@ exports.ensureKubernetesSecret = ensureKubernetesSecret;
68
64
  async function removeKubernetesSecret(instance, kubernetes, item) {
69
65
  try {
70
66
  await instance.coreApi.deleteNamespacedSecret({
71
- name: getSecretResourceName(item),
67
+ name: getSecretResourceName(item, kubernetes),
72
68
  namespace: kubernetes.namespace,
73
69
  });
74
70
  }
@@ -51,7 +51,7 @@ async function getKubernetesPersistence(work, kubernetes) {
51
51
  mounts: [],
52
52
  initCommands: [],
53
53
  };
54
- const workspace = kubernetes?.server?.workspace;
54
+ const workspace = kubernetes.server?.workspace;
55
55
  for (const mount of work.data.mounts) {
56
56
  if (workspace &&
57
57
  appendWorkspaceMount(workspace, persistence, mount.containerPath, mount.localPath, mount.isFile, mount.readOnly)) {
@@ -94,7 +94,7 @@ function appendVolume(sourceWork, persistence, containerPath, localPath, isFile,
94
94
  },
95
95
  });
96
96
  persistence.sizes[volumeName] = 'volumeSize' in sourceWork.data ? sourceWork.data.volumeSize : null;
97
- if (kubernetes && (0, kept_claims_1.isKeptClaim)(sourceWork, kubernetes)) {
97
+ if ((0, kept_claims_1.isKeptClaim)(sourceWork, kubernetes)) {
98
98
  persistence.kept.push(volumeName);
99
99
  }
100
100
  }
@@ -38,7 +38,7 @@ const work_secret_1 = require("./work-secret");
38
38
  async function listJobNames(instance, kubernetes, task) {
39
39
  const jobs = await instance.batchApi.listNamespacedJob({
40
40
  namespace: kubernetes.namespace,
41
- labelSelector: `hammerkit.dev/id=${task.id()}`,
41
+ labelSelector: (0, resources_1.getItemSelector)(task, kubernetes),
42
42
  });
43
43
  return jobs.items.flatMap((job) => (job.metadata?.name ? [job.metadata.name] : []));
44
44
  }
@@ -201,7 +201,7 @@ function kubernetesTaskRuntime(task, kubernetes) {
201
201
  }
202
202
  const server = kubernetes.server;
203
203
  const { delivered, inJob } = (0, job_secrets_1.splitJobSecrets)(task.data.secrets, server);
204
- const secrets = (0, secrets_1.getKubernetesSecretRefs)(task, delivered);
204
+ const secrets = (0, secrets_1.getKubernetesSecretRefs)(task, kubernetes, delivered);
205
205
  const admitted = environment.hosted?.accounts;
206
206
  let secretHosts;
207
207
  try {
@@ -230,7 +230,7 @@ function kubernetesTaskRuntime(task, kubernetes) {
230
230
  : {}),
231
231
  };
232
232
  const labels = {
233
- 'hammerkit.dev/id': task.id(),
233
+ ...(0, resources_1.getItemLabels)(task, kubernetes),
234
234
  'hammerkit.dev/state': options.stateKey,
235
235
  ...workspace.labels,
236
236
  [network_policy_1.RUN_LABEL]: scope.run,
@@ -258,7 +258,7 @@ function kubernetesTaskRuntime(task, kubernetes) {
258
258
  return;
259
259
  }
260
260
  try {
261
- const podName = (0, resources_1.getResourceName)(task, '-job');
261
+ const podName = (0, resources_1.getResourceName)(task, kubernetes, '-job');
262
262
  let i = 0;
263
263
  for (const cmd of task.data.cmds) {
264
264
  const container = `cmd-${++i}`;
@@ -387,7 +387,7 @@ function kubernetesTaskRuntime(task, kubernetes) {
387
387
  async stop() {
388
388
  const jobs = await instance.batchApi.listNamespacedJob({
389
389
  namespace: kubernetes.namespace,
390
- labelSelector: `hammerkit.dev/id=${task.id()}`,
390
+ labelSelector: (0, resources_1.getItemSelector)(task, kubernetes),
391
391
  });
392
392
  for (const job of jobs.items) {
393
393
  if (job.metadata?.name) {
@@ -396,7 +396,7 @@ function kubernetesTaskRuntime(task, kubernetes) {
396
396
  }
397
397
  const deployments = await instance.appsApi.listNamespacedDeployment({
398
398
  namespace: kubernetes.namespace,
399
- labelSelector: `hammerkit.dev/id=${task.id()}`,
399
+ labelSelector: (0, resources_1.getItemSelector)(task, kubernetes),
400
400
  });
401
401
  for (const deploy of deployments.items) {
402
402
  if (deploy.metadata?.name) {
@@ -408,7 +408,7 @@ function kubernetesTaskRuntime(task, kubernetes) {
408
408
  }
409
409
  const pods = await instance.coreApi.listNamespacedPod({
410
410
  namespace: kubernetes.namespace,
411
- labelSelector: `hammerkit.dev/id=${task.id()}`,
411
+ labelSelector: (0, resources_1.getItemSelector)(task, kubernetes),
412
412
  });
413
413
  for (const pod of pods.items) {
414
414
  if (pod.metadata?.name) {
@@ -430,7 +430,7 @@ function kubernetesTaskRuntime(task, kubernetes) {
430
430
  }
431
431
  const jobs = await instance.batchApi.listNamespacedJob({
432
432
  namespace: kubernetes.namespace,
433
- labelSelector: `hammerkit.dev/id=${task.id()}`,
433
+ labelSelector: (0, resources_1.getItemSelector)(task, kubernetes),
434
434
  });
435
435
  const completedStates = jobs.items
436
436
  .filter((j) => j.status?.succeeded && j.metadata?.labels?.['hammerkit.dev/state'])
@@ -467,7 +467,7 @@ function kubernetesServiceRuntime(service, kubernetes) {
467
467
  async initialize(state) {
468
468
  const deployments = await instance.appsApi.listNamespacedDeployment({
469
469
  namespace: kubernetes.namespace,
470
- labelSelector: `hammerkit.dev/id=${service.id()}`,
470
+ labelSelector: (0, resources_1.getItemSelector)(service, kubernetes),
471
471
  });
472
472
  const deployment = deployments.items[0];
473
473
  if (!deployment?.metadata?.name) {
@@ -494,7 +494,7 @@ function kubernetesServiceRuntime(service, kubernetes) {
494
494
  async stop() {
495
495
  const deployments = await instance.appsApi.listNamespacedDeployment({
496
496
  namespace: kubernetes.namespace,
497
- labelSelector: `hammerkit.dev/id=${service.id()}`,
497
+ labelSelector: (0, resources_1.getItemSelector)(service, kubernetes),
498
498
  });
499
499
  for (const deployment of deployments.items) {
500
500
  if (deployment.metadata?.name) {
@@ -514,9 +514,13 @@ function kubernetesServiceRuntime(service, kubernetes) {
514
514
  await (0, ensure_kubernetes_service_exists_1.ensureKubernetesServiceExists)(instance, kubernetes, service);
515
515
  await (0, ensure_persistent_data_1.ensurePersistentData)(instance, kubernetes, environment, service, persistence);
516
516
  await (0, secrets_1.ensureKubernetesSecret)(instance, kubernetes, service, service.data.secrets, environment);
517
- const network = await (0, network_policy_1.ensureKubernetesItemNetwork)(instance, kubernetes, service, { followLog: false, run: null });
518
- await (0, ensure_kubernetes_deployment_exists_1.ensureKubernetesDeploymentExists)(instance, kubernetes, service, persistence, (0, secrets_1.getKubernetesSecretRefs)(service, service.data.secrets), options.stateKey, network);
519
- const name = (0, resources_1.getResourceName)(service);
517
+ // on a server, the run's: its runs share the namespace, and the service goes with the run
518
+ const network = await (0, network_policy_1.ensureKubernetesItemNetwork)(instance, kubernetes, service, {
519
+ followLog: false,
520
+ run: kubernetes.server ? (0, job_placement_1.getRunLabelValue)(kubernetes.server.run) : null,
521
+ });
522
+ await (0, ensure_kubernetes_deployment_exists_1.ensureKubernetesDeploymentExists)(instance, kubernetes, service, persistence, (0, secrets_1.getKubernetesSecretRefs)(service, kubernetes, service.data.secrets), options.stateKey, network);
523
+ const name = (0, resources_1.getResourceName)(service, kubernetes);
520
524
  options.state.set({
521
525
  type: 'running',
522
526
  dns: { containerId: name },
@@ -548,7 +552,7 @@ function kubernetesServiceRuntime(service, kubernetes) {
548
552
  }
549
553
  const services = await instance.coreApi.listNamespacedService({
550
554
  namespace: kubernetes.namespace,
551
- labelSelector: `hammerkit.dev/id=${service.id()}`,
555
+ labelSelector: (0, resources_1.getItemSelector)(service, kubernetes),
552
556
  });
553
557
  for (const service of services.items) {
554
558
  if (service.metadata?.name) {
@@ -564,7 +568,7 @@ function kubernetesServiceRuntime(service, kubernetes) {
564
568
  async currentStateKey() {
565
569
  const deployments = await instance.appsApi.listNamespacedDeployment({
566
570
  namespace: kubernetes.namespace,
567
- labelSelector: `hammerkit.dev/id=${service.id()}`,
571
+ labelSelector: (0, resources_1.getItemSelector)(service, kubernetes),
568
572
  });
569
573
  const deployment = deployments.items[0];
570
574
  return deployment?.metadata?.labels?.['hammerkit.dev/state'] ?? null;
@@ -167,18 +167,21 @@ exports.getRunRoleBinding = getRunRoleBinding;
167
167
  * process of the server as on a host (see HostExecutor); what changes is where its
168
168
  * container tasks and services go: into the caller's namespace, on the nodes of the
169
169
  * operator's pools, with the secrets of providers read in the Job itself. The runs
170
- * of one caller execute one after the other, since the objects of a task are named
171
- * by its cache id in the caller's namespace; different callers run side by side.
172
- * When a run ends, everything made for it is removed, but for the claims of its tasks
173
- * the server keeps (`backend.keepVolumes`), which go once they expired.
170
+ * of a caller execute side by side in their namespace, up to `runs.limits.running`:
171
+ * the objects of a run are named and found by its run label (getItemKey), so two runs
172
+ * of the same task have their own. What two runs share are the claims the server keeps
173
+ * (`backend.keepVolumes`) of a scope (getVolumeScope): the runs of one scope go one at
174
+ * a time (exclusive). When a run ends, everything made for it is removed, but for the
175
+ * kept claims, which go once they expired.
174
176
  */
175
177
  class KubernetesExecutor {
176
178
  constructor(config, processEnvs, options) {
177
179
  this.config = config;
178
- // the registry keeps a caller's next run in its queue, not running and timed here
179
- this.runningPerCaller = 1;
180
- // the run that executes, or the last one waiting, per caller namespace
180
+ // per caller namespace, what changes objects of the whole namespace: its setup, the
181
+ // removal of what a stopped server left, the sweeps of kept claims. The last one waiting.
181
182
  this.queues = new Map();
183
+ // per caller namespace, the runs that execute in it: by run label, their volume scope
184
+ this.active = new Map();
182
185
  // per caller namespace, when its kept claims are next looked at
183
186
  this.sweeps = new Map();
184
187
  // whether the kept claims a server left when it stopped were looked for
@@ -203,17 +206,45 @@ class KubernetesExecutor {
203
206
  this.log = options.log;
204
207
  this.host = new executor_1.HostExecutor({ ...options, placement: this.placement() });
205
208
  }
206
- execute(run, context) {
209
+ async execute(run, context) {
207
210
  const namespace = getCallerNamespace(this.config.backend.namespacePrefix, run.owner);
208
- return this.enqueue(namespace, () => {
209
- if (context.signal.aborted) {
210
- throw new Error('the run was cancelled');
211
+ const label = (0, job_placement_1.getRunLabelValue)(run.id);
212
+ let runs = this.active.get(namespace);
213
+ if (!runs) {
214
+ runs = new Map();
215
+ this.active.set(namespace, runs);
216
+ }
217
+ runs.set(label, getVolumeScope(run));
218
+ try {
219
+ await this.host.execute(run, context);
220
+ }
221
+ finally {
222
+ runs.delete(label);
223
+ if (runs.size === 0 && this.active.get(namespace) === runs) {
224
+ this.active.delete(namespace);
211
225
  }
212
- return this.host.execute(run, context);
213
- });
226
+ }
227
+ }
228
+ // The runs of a scope share its kept claims, which one of them may change at a time:
229
+ // what a task finds in one is what the last run left there. Without kept claims, runs
230
+ // share no object and nothing is held.
231
+ exclusive(run) {
232
+ if (this.keep === 0) {
233
+ return undefined;
234
+ }
235
+ return `${getCallerNamespace(this.config.backend.namespacePrefix, run.owner)}/${getVolumeScope(run)}`;
214
236
  }
215
- // What happens in a caller's namespace happens one at a time: their runs, and the
216
- // removal of the claims their runs kept
237
+ // The scopes of the runs that execute in the namespace, but `except`'s: their kept claims
238
+ // are theirs to mount and stay. A server that keeps none mounts none.
239
+ scopesInUse(namespace, except) {
240
+ if (this.keep === 0) {
241
+ return new Set();
242
+ }
243
+ return new Set([...(this.active.get(namespace) ?? new Map()).entries()]
244
+ .filter(([label]) => label !== except)
245
+ .map(([, scope]) => scope));
246
+ }
247
+ // What changes objects of a whole namespace happens one at a time, not what its runs do
217
248
  enqueue(namespace, fn) {
218
249
  const previous = this.queues.get(namespace) ?? Promise.resolve();
219
250
  const execution = previous.then(fn);
@@ -231,19 +262,28 @@ class KubernetesExecutor {
231
262
  return {
232
263
  prepare: async (run, context, work) => {
233
264
  const namespace = getCallerNamespace(backend.namespacePrefix, run.owner);
234
- await this.namespaces.prepare(namespace);
235
- // what an earlier run left when the server stopped before its end; the runs
236
- // of a caller execute one at a time, so no other run is in the namespace
237
- await removeRunObjects(this.instance, namespace, `${job_placement_1.RUN_LABEL},${job_placement_1.RUN_LABEL}!=${(0, job_placement_1.getRunLabelValue)(run.id)}`, RELEASE_TIMEOUT);
238
- // the claims earlier runs kept that expired; the others this run may mount
239
- const { next } = await (0, kept_claims_1.sweepKeptClaims)(this.instance, namespace, {
240
- now: new Date(),
241
- keep: this.keep > 0,
242
- timeout: RELEASE_TIMEOUT,
265
+ const label = (0, job_placement_1.getRunLabelValue)(run.id);
266
+ await this.enqueue(namespace, async () => {
267
+ if (context.signal.aborted) {
268
+ throw new Error('the run was cancelled');
269
+ }
270
+ await this.namespaces.prepare(namespace);
271
+ // what an earlier run left when the server stopped before its end: the objects of
272
+ // a run that does not execute. One that starts after this waits for it here
273
+ // before it makes any.
274
+ const running = [...new Set([label, ...(this.active.get(namespace)?.keys() ?? [])])];
275
+ await removeRunObjects(this.instance, namespace, `${job_placement_1.RUN_LABEL},${job_placement_1.RUN_LABEL} notin (${running.join(',')})`, RELEASE_TIMEOUT);
276
+ // the claims earlier runs kept that expired; the others this run may mount
277
+ const { next } = await (0, kept_claims_1.sweepKeptClaims)(this.instance, namespace, {
278
+ now: new Date(),
279
+ keep: this.keep > 0,
280
+ timeout: RELEASE_TIMEOUT,
281
+ inUse: this.scopesInUse(namespace, label),
282
+ });
283
+ if (next) {
284
+ this.sweepLater(namespace, Math.max(0, next.getTime() - Date.now()));
285
+ }
243
286
  });
244
- if (next) {
245
- this.sweepLater(namespace, Math.max(0, next.getTime() - Date.now()));
246
- }
247
287
  this.sweepLeftClaims();
248
288
  context.emit({ type: 'log', task: 'hammerkit', stream: 'stdout', text: `runs in the namespace ${namespace}` });
249
289
  const kubernetes = {
@@ -300,8 +340,8 @@ class KubernetesExecutor {
300
340
  },
301
341
  };
302
342
  }
303
- // The kept claims of a namespace that expired go once no run of the caller is in it: at
304
- // the soonest expiry the server knows of
343
+ // The kept claims of a namespace that expired go at the soonest expiry the server knows
344
+ // of, but those of a scope a run of the caller executes in
305
345
  sweepLater(namespace, delay) {
306
346
  const at = Date.now() + delay + 1000;
307
347
  const pending = this.sweeps.get(namespace);
@@ -321,6 +361,7 @@ class KubernetesExecutor {
321
361
  now: new Date(),
322
362
  keep: this.keep > 0,
323
363
  timeout: RELEASE_TIMEOUT,
364
+ inUse: this.scopesInUse(namespace),
324
365
  });
325
366
  if (removed.length > 0) {
326
367
  this.log?.(`removed ${removed.length} kept claims in ${namespace}`);
@@ -424,13 +424,20 @@ class RunRegistry {
424
424
  return (source.type === 'sandbox' &&
425
425
  [...this.entries.values()].some((entry) => entry.run.state === 'running' && onSandbox(entry.run, source.id)));
426
426
  }
427
+ // Whether a run that executes holds what the run would (RunExecutor.exclusive)
428
+ held(run) {
429
+ const exclusive = this.options.executor.exclusive?.bind(this.options.executor);
430
+ const key = exclusive?.(run);
431
+ return (key !== undefined &&
432
+ [...this.entries.values()].some((entry) => entry.run.state === 'running' && exclusive?.(entry.run) === key));
433
+ }
427
434
  // The first run in the queue whose caller has a place left: one caller with many runs
428
435
  // does not keep the others from the machine. A run on a sandbox another run works on
429
- // waits for it.
436
+ // waits for it, as does one that needs what an executing run holds.
430
437
  dispatch() {
431
438
  while (this.running < this.maxConcurrent) {
432
- const perCaller = Math.min(this.options.limits?.running ?? Infinity, this.options.executor.runningPerCaller ?? Infinity);
433
- const index = this.queue.findIndex((entry) => this.runningOf(entry.run.owner) < perCaller && !this.sandboxBusy(entry.run));
439
+ const perCaller = this.options.limits?.running ?? Infinity;
440
+ const index = this.queue.findIndex((entry) => this.runningOf(entry.run.owner) < perCaller && !this.sandboxBusy(entry.run) && !this.held(entry.run));
434
441
  if (index < 0) {
435
442
  return;
436
443
  }
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.27.0-next.8",
4
+ "version": "1.27.0-next.9",
5
5
  "keywords": [
6
6
  "build",
7
7
  "build-tool",