hammerkit 2.0.0-next.6 → 2.0.0-next.8

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.
@@ -6,6 +6,7 @@ const abort_event_1 = require("../utils/abort-event");
6
6
  const apply_1 = require("./apply");
7
7
  const sleep_1 = require("../utils/sleep");
8
8
  const cluster_state_1 = require("./cluster-state");
9
+ const quota_wait_1 = require("./quota-wait");
9
10
  // The waits below poll instead of using the client's Watch: Watch aborts every
10
11
  // request after 30s, which a pod or deployment still pulling its image outlasts.
11
12
  async function awaitRunningState(instance, env, name, phase, pollInterval = 1000) {
@@ -22,11 +23,16 @@ async function awaitRunningState(instance, env, name, phase, pollInterval = 1000
22
23
  }
23
24
  }
24
25
  exports.awaitRunningState = awaitRunningState;
25
- // Wait for a job to finish by polling its status: resolves once it succeeded,
26
- // rejects when it failed (its `Failed` condition, i.e. retries are exhausted).
27
- // On abort the job is deleted — with its pods — and an AbortError is thrown, so
28
- // a cancelled or timed-out task leaves nothing running on the cluster.
29
- async function awaitJobCompletion(instance, env, name, abort, pollInterval = 1000) {
26
+ async function awaitJobCompletion(instance, env, name, abort, pollInterval = 1000, quota) {
27
+ const wait = quota
28
+ ? new quota_wait_1.QuotaWait(instance, env, {
29
+ subject: `the pod of job ${name}`,
30
+ timeout: quota.timeout,
31
+ onWaiting: quota.onWaiting,
32
+ })
33
+ : null;
34
+ // when the job last had a pod: a refusal before it is not one of the pod it waits for now
35
+ let hadPod = 0;
30
36
  for (;;) {
31
37
  if (abort.aborted) {
32
38
  await deleteJob(instance, env, name);
@@ -41,10 +47,50 @@ async function awaitJobCompletion(instance, env, name, abort, pollInterval = 100
41
47
  if (failed) {
42
48
  throw new Error(`job ${name} failed: ${failed.message ?? failed.reason ?? 'unknown reason'}`);
43
49
  }
50
+ if (wait) {
51
+ let refusal = null;
52
+ if (status?.active || status?.ready) {
53
+ hadPod = Date.now();
54
+ }
55
+ else {
56
+ // the job controller can't make the pod: no pod to wait on, only its events say why
57
+ refusal = await readJobRefusal(instance, env, name, job, hadPod);
58
+ }
59
+ try {
60
+ await wait.check(refusal);
61
+ }
62
+ catch (e) {
63
+ await deleteJob(instance, env, name);
64
+ throw e;
65
+ }
66
+ }
44
67
  await sleep(pollInterval, abort);
45
68
  }
46
69
  }
47
70
  exports.awaitJobCompletion = awaitJobCompletion;
71
+ // The last `FailedCreate` of the job for its quota after `after`. Events the server may not list
72
+ // leave the wait as it was.
73
+ async function readJobRefusal(instance, env, name, job, after) {
74
+ try {
75
+ const events = await instance.coreApi.listNamespacedEvent({
76
+ namespace: env.namespace,
77
+ fieldSelector: `involvedObject.kind=Job,involvedObject.name=${name},reason=FailedCreate`,
78
+ });
79
+ const uid = job.metadata?.uid;
80
+ const latest = events.items
81
+ .filter((event) => !uid || !event.involvedObject?.uid || event.involvedObject.uid === uid)
82
+ .filter((event) => eventTime(event) > after && (0, quota_wait_1.getQuotaRefusal)(event.message))
83
+ .sort((a, b) => eventTime(b) - eventTime(a))[0];
84
+ return (0, quota_wait_1.getQuotaRefusal)(latest?.message);
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
90
+ function eventTime(event) {
91
+ const at = event.lastTimestamp ?? event.eventTime ?? event.firstTimestamp ?? event.metadata?.creationTimestamp;
92
+ return at ? new Date(at).getTime() : 0;
93
+ }
48
94
  function sleep(ms, abort) {
49
95
  if (abort.aborted) {
50
96
  return Promise.resolve();
@@ -77,6 +123,20 @@ async function deleteJob(instance, env, name) {
77
123
  }
78
124
  }
79
125
  exports.deleteJob = deleteJob;
126
+ // The Deployment says when its ReplicaSet can't make a pod: `ReplicaFailure`, `FailedCreate`.
127
+ function getDeploymentRefusal(deployment) {
128
+ const failure = deployment.status?.conditions?.find((condition) => condition.type === 'ReplicaFailure' && condition.status === 'True');
129
+ return (0, quota_wait_1.getQuotaRefusal)(failure?.message);
130
+ }
131
+ function getDeployQuotaWait(instance, env, name, readiness, now) {
132
+ return new quota_wait_1.QuotaWait(instance, env, {
133
+ subject: readiness.subject ?? name,
134
+ // without a limit of its own it waits as long as the run does
135
+ timeout: readiness.quotaTimeout ?? Number.POSITIVE_INFINITY,
136
+ onWaiting: (text) => readiness.onWaiting?.(text),
137
+ now,
138
+ });
139
+ }
80
140
  // how long a reason that may still pass (an image pull a registry refused for now, a
81
141
  // container the runtime could not create) may last before it fails the service
82
142
  exports.PASSING_FAILURE_MS = 60000;
@@ -96,12 +156,14 @@ async function awaitDeployRunningState(instance, env, name, readiness, pollInter
96
156
  let said;
97
157
  // since when a pod has had a failure that may still pass, by pod
98
158
  const failingSince = new Map();
159
+ const quota = getDeployQuotaWait(instance, env, name, readiness, now);
99
160
  for (;;) {
100
161
  const deployment = await instance.appsApi.readNamespacedDeployment({ name, namespace: env.namespace });
101
162
  // available: ready for the Deployment's minReadySeconds
102
163
  if ((deployment.status?.availableReplicas ?? 0) > 0) {
103
164
  return;
104
165
  }
166
+ await quota.check(getDeploymentRefusal(deployment));
105
167
  const created = deployment.metadata?.creationTimestamp;
106
168
  const listed = await instance.coreApi.listNamespacedPod({
107
169
  namespace: env.namespace,
@@ -163,6 +225,7 @@ async function awaitDeployRollout(instance, env, name, readiness, pollInterval =
163
225
  let runningSince = null;
164
226
  let said;
165
227
  const failingSince = new Map();
228
+ const quota = getDeployQuotaWait(instance, env, name, readiness, now);
166
229
  for (;;) {
167
230
  if (readiness.signal?.aborted) {
168
231
  throw new abort_1.AbortError();
@@ -179,6 +242,7 @@ async function awaitDeployRollout(instance, env, name, readiness, pollInterval =
179
242
  if (stalled && (status?.observedGeneration ?? 0) >= readiness.generation) {
180
243
  throw new Error(`${timeoutMessage}: ${stalled.message ?? 'its rollout made no progress'}`);
181
244
  }
245
+ await quota.check(getDeploymentRefusal(deployment));
182
246
  const listed = await instance.coreApi.listNamespacedPod({
183
247
  namespace: env.namespace,
184
248
  labelSelector: readiness.podSelector,
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getServiceContainerName = exports.MIN_READY_SECONDS = exports.getPodTemplateHash = exports.POD_ANNOTATION = exports.ROLLOUT_DEADLINE_SECONDS = exports.ensureKubernetesDeploymentExists = exports.DEFINITION_LABEL = void 0;
3
+ exports.getServiceContainerName = exports.MIN_READY_SECONDS = exports.getPodTemplateHash = exports.POD_ANNOTATION = exports.ROLLOUT_DEADLINE_SECONDS = exports.ensureKubernetesDeploymentExists = exports.DEBUG_CONTAINER_RESOURCES = exports.DEFINITION_LABEL = void 0;
4
4
  const service_definition_1 = require("../planner/service-definition");
5
5
  const work_service_1 = require("../planner/work-service");
6
6
  const volumes_1 = require("./volumes");
@@ -17,10 +17,15 @@ const job_placement_1 = require("./job-placement");
17
17
  const network_policy_1 = require("./network-policy");
18
18
  const egress_proxy_1 = require("../network/egress-proxy");
19
19
  const service_rollouts_1 = require("./service-rollouts");
20
+ const quota_wait_1 = require("./quota-wait");
20
21
  // what the service was created from, to tell a changed one
21
22
  exports.DEFINITION_LABEL = 'hammerkit.dev/definition';
22
23
  // how often a starting container is checked: as the liveness probe
23
24
  const STARTUP_PERIOD_SECONDS = 5;
25
+ exports.DEBUG_CONTAINER_RESOURCES = {
26
+ requests: { cpu: '10m', memory: '16Mi' },
27
+ limits: { cpu: '100m', memory: '256Mi' },
28
+ };
24
29
  async function ensureKubernetesDeploymentExists(instance, env, service, persistence, secrets, stateKey,
25
30
  // the needed services by name, and the proxy of a limited network
26
31
  network,
@@ -131,6 +136,9 @@ rollout = {}) {
131
136
  command: ['sleep'],
132
137
  args: ['3600'],
133
138
  volumeMounts: persistence.mounts.map((m) => m.mount),
139
+ // a shell to look at the service's files: without a size of its own it would get
140
+ // the namespace's default, and count as much against its quota as the service
141
+ resources: exports.DEBUG_CONTAINER_RESOURCES,
134
142
  ...(0, job_placement_1.getContainerSecurity)(env),
135
143
  },
136
144
  ],
@@ -149,6 +157,7 @@ rollout = {}) {
149
157
  subject: `service ${service.name}`,
150
158
  onWaiting: (text) => service.status.write('warn', `${service.name} ${text}`),
151
159
  ...(service.data.restarts !== null ? { restarts: service.data.restarts } : {}),
160
+ quotaTimeout: quota_wait_1.QUOTA_TIMEOUT,
152
161
  };
153
162
  if (env.server?.serviceRun) {
154
163
  await rollOut(instance, env, service, deployment, readiness, rollout);
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ensurePersistentData = exports.packSource = exports.clearStateFiles = exports.restoreLiveFiles = exports.syncLiveFiles = exports.SYNC_FILES = exports.getStateDirsInUse = exports.REMOVE_OTHER_STATES = exports.execInPod = exports.clearClaim = exports.getPodForPersistence = exports.applyWithinQuota = exports.QUOTA_TIMEOUT = exports.TRANSFER_POD_RESOURCES = void 0;
3
+ exports.ensurePersistentData = exports.packSource = exports.clearStateFiles = exports.ENSURE_OUTPUT_FILES = exports.restoreLiveFiles = exports.syncLiveFiles = exports.SYNC_FILES = exports.getStateDirsInUse = exports.REMOVE_OTHER_STATES = exports.execInPod = exports.clearClaim = exports.getPodForPersistence = exports.applyWithinQuota = exports.QUOTA_TIMEOUT = exports.TRANSFER_POD_RESOURCES = void 0;
4
+ const quota_wait_1 = require("./quota-wait");
5
+ Object.defineProperty(exports, "QUOTA_TIMEOUT", { enumerable: true, get: function () { return quota_wait_1.QUOTA_TIMEOUT; } });
4
6
  const volumes_1 = require("./volumes");
5
7
  const apply_1 = require("./apply");
6
8
  const path_1 = require("path");
@@ -50,12 +52,9 @@ exports.TRANSFER_POD_RESOURCES = {
50
52
  requests: { cpu: '50m', memory: '64Mi' },
51
53
  limits: { memory: '512Mi' },
52
54
  };
53
- // how long a transfer pod waits for room under the quota of its namespace, such as the
54
- // pods of other tasks of the run ending, before its task fails
55
- exports.QUOTA_TIMEOUT = 10 * 60 * 1000;
56
55
  // A Job waits for room under a full quota, a pod is refused: so a transfer pod is
57
56
  // created again until the pods of the tasks it runs beside are done and make room
58
- async function applyWithinQuota(instance, podSpec, service, timeout = exports.QUOTA_TIMEOUT, pollInterval = 2000,
57
+ async function applyWithinQuota(instance, podSpec, service, timeout = quota_wait_1.QUOTA_TIMEOUT, pollInterval = 2000,
59
58
  // a run stopped while it waits: it stops waiting
60
59
  signal) {
61
60
  const deadline = Date.now() + timeout;
@@ -88,7 +87,11 @@ signal, fn) {
88
87
  // and takes no upload
89
88
  const own = persistence.mounts.filter((m) => !m.workspace);
90
89
  const mounts = type === 'read'
91
- ? own.map((m) => m.mount)
90
+ ? // what a task stores are its outputs, which it mounts writable: its src and the
91
+ // outputs of its deps are not mounted, so an output inside a folder of its src
92
+ // (pkg/coverage/lcov.info) has a place in the pod even when the claim holds no src,
93
+ // after a restore into a new claim
94
+ own.filter((m) => service.data.type !== 'container-task' || !m.mount.readOnly).map((m) => m.mount)
92
95
  : own.reduce((array, v) => {
93
96
  if (!array.some((i) => i.name === v.mount.name)) {
94
97
  array.push({
@@ -99,11 +102,13 @@ signal, fn) {
99
102
  }
100
103
  return array;
101
104
  }, []);
105
+ // only the claims it mounts: one of a dep it doesn't read would only bind where it goes
106
+ const volumes = persistence.volumes.filter((volume) => mounts.some((mount) => mount.name === volume.name));
102
107
  // Guarantee the claims exist right before scheduling the pod that mounts
103
108
  // them. ensurePersistentData also creates them, but store/restore reach this
104
109
  // function directly, and a claim shared with another work item can be removed
105
110
  // concurrently — without this the pod would hang Pending on "pvc not found".
106
- for (const volume of persistence.volumes) {
111
+ for (const volume of volumes) {
107
112
  await (0, ensure_kubernetes_persistent_volume_claim_exists_1.ensureKubernetesPersistentVolumeClaimExists)(instance, env, volume, service, persistence.sizes[volume.name], persistence.kept.includes(volume.name));
108
113
  }
109
114
  const name = (0, resources_1.getResourceName)(service, env, type === 'write' ? '-upload' : '-download');
@@ -111,7 +116,7 @@ signal, fn) {
111
116
  // commit): the pod is not one its Service sends to
112
117
  const serving = !!env.server?.serviceRun && service.data.type === 'container-service';
113
118
  // and on a service run, it goes where the pods that mount the same claims run
114
- const claims = env.server?.serviceRun ? (0, volumes_1.getClaimPodSpec)(persistence.volumes) : { labels: {} };
119
+ const claims = env.server?.serviceRun ? (0, volumes_1.getClaimPodSpec)(volumes) : { labels: {} };
115
120
  const podSpec = {
116
121
  kind: 'Pod',
117
122
  apiVersion: 'v1',
@@ -144,12 +149,12 @@ signal, fn) {
144
149
  ...(0, job_placement_1.getContainerSecurity)(env),
145
150
  },
146
151
  ],
147
- volumes: persistence.volumes,
152
+ volumes,
148
153
  },
149
154
  };
150
155
  service.status.write('debug', `create pod ${name}`);
151
156
  await removeStalePod(instance, env.namespace, name);
152
- const pod = await applyWithinQuota(instance, podSpec, service, exports.QUOTA_TIMEOUT, 2000, signal);
157
+ const pod = await applyWithinQuota(instance, podSpec, service, quota_wait_1.QUOTA_TIMEOUT, 2000, signal);
153
158
  if (pod.status?.phase !== 'Running') {
154
159
  service.status.write('debug', `await running state ${name}`);
155
160
  await (0, await_running_state_1.awaitRunningState)(instance, env, name, 'Running');
@@ -329,6 +334,33 @@ async function prepareStateFiles(instance, env, work, pod, dir, inUse) {
329
334
  await clearStateDir(instance, env, pod, `${targetPath}/${dir}`);
330
335
  return true;
331
336
  }
337
+ // Makes each of `files` (paths in the claim of `work`, see KubernetesPersistence.files) an
338
+ // empty file unless it is one: Kubernetes would make a directory of a subPath that isn't
339
+ // there, and the task could not write the file. A directory there is what an earlier
340
+ // version left in a kept claim, and goes. A path ending in `/` is made a folder.
341
+ exports.ENSURE_OUTPUT_FILES = `cd "$1" || exit 1
342
+ shift
343
+ for f; do
344
+ case "$f" in */) mkdir -p "$f" || exit 1; continue ;; esac
345
+ if [ -d "$f" ] && [ ! -L "$f" ]; then rm -rf "$f" || exit 1; fi
346
+ if [ ! -e "$f" ] && [ ! -L "$f" ]; then mkdir -p "$(dirname "$f")" && : > "$f" || exit 1; fi
347
+ done`;
348
+ async function ensureOutputFiles(instance, env, work, pod, files) {
349
+ if (files.length === 0) {
350
+ return;
351
+ }
352
+ const made = await execInPod(instance, env, pod, [
353
+ 'sh',
354
+ '-c',
355
+ exports.ENSURE_OUTPUT_FILES,
356
+ 'sh',
357
+ `/dev/hammerkit/${work.id()}`,
358
+ ...files,
359
+ ]);
360
+ if (made) {
361
+ throw new Error(`unable to make the output files ${files.join(', ')} of ${work.name}: ${made}`);
362
+ }
363
+ }
332
364
  // what a failed upload, restore or task left: tar does not remove files the commit does not have
333
365
  async function clearStateDir(instance, env, pod, path) {
334
366
  const cleared = await execInPod(instance, env, pod, ['rm', '-rf', path]);
@@ -397,7 +429,7 @@ options = {}) {
397
429
  const files = persistence.volumes.some((volume) => volume.persistentVolumeClaim?.claimName === claim)
398
430
  ? options.files
399
431
  : undefined;
400
- if (persistence.sources.length === 0 && !options.reset && !files) {
432
+ if (persistence.sources.length === 0 && persistence.files.length === 0 && !options.reset && !files) {
401
433
  return;
402
434
  }
403
435
  // the folders of the states the pods of the run mount, before the upload pod mounts the claim
@@ -447,6 +479,7 @@ options = {}) {
447
479
  }
448
480
  });
449
481
  }
482
+ await ensureOutputFiles(instance, env, service, name, persistence.files);
450
483
  if (files) {
451
484
  const dir = `/dev/hammerkit/${service.id()}/${files.dir}`;
452
485
  const marked = await execInPod(instance, env, name, [
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.QuotaWait = exports.exceedsQuota = exports.getQuotaRefusal = exports.QUOTA_TIMEOUT = void 0;
4
+ const units_1 = require("../utils/units");
5
+ // How long a pod the quota of its namespace refuses waits for room before what needs it fails:
6
+ // a transfer pod in any case, a task's or service's only when no pod that will end holds the
7
+ // room (see QuotaWait)
8
+ exports.QUOTA_TIMEOUT = 10 * 60 * 1000;
9
+ // What a refusal of the quota of the namespace says, from where it starts: `exceeded quota:
10
+ // hammerkit, requested: limits.memory=4Gi, used: limits.memory=24Gi, limited: limits.memory=26Gi`
11
+ function getQuotaRefusal(message) {
12
+ const index = message ? message.search(/exceeded quota/i) : -1;
13
+ return message && index >= 0 ? message.slice(index) : null;
14
+ }
15
+ exports.getQuotaRefusal = getQuotaRefusal;
16
+ // A pod that asks for more of a resource than the quota allows at all never fits.
17
+ function exceedsQuota(refusal) {
18
+ const requested = readAmounts(/requested: (\S+?)(?:,\s|$)/.exec(refusal)?.[1]);
19
+ const limited = readAmounts(/limited: (\S+?)(?:,\s|$)/.exec(refusal)?.[1]);
20
+ return [...requested].some(([resource, amount]) => {
21
+ const limit = limited.get(resource);
22
+ return limit !== undefined && amount > limit;
23
+ });
24
+ }
25
+ exports.exceedsQuota = exceedsQuota;
26
+ // `limits.memory=4Gi,requests.cpu=1` → limits.memory 4Gi, requests.cpu 1
27
+ function readAmounts(list = '') {
28
+ const amounts = new Map();
29
+ for (const entry of list.split(',')) {
30
+ const [resource, value] = entry.split('=');
31
+ const amount = (0, units_1.parseQuantity)(value);
32
+ if (resource && amount !== undefined) {
33
+ amounts.set(resource, amount);
34
+ }
35
+ }
36
+ return amounts;
37
+ }
38
+ // A pod that will end and free its room: one of a Job (a task) or a pod of its own (the copy of
39
+ // a task's files). The pods of a Deployment (a service) stay as long as what needs them.
40
+ function willEnd(pod) {
41
+ if (pod.metadata?.deletionTimestamp || (pod.status?.phase !== 'Pending' && pod.status?.phase !== 'Running')) {
42
+ return false;
43
+ }
44
+ return !(pod.metadata?.ownerReferences ?? []).some((owner) => owner.kind === 'ReplicaSet');
45
+ }
46
+ /**
47
+ * The wait of a pod the quota of its namespace refuses. It is told what the quota refused,
48
+ * fails at once when the pod asks for more than the quota allows at all, and after `timeout`
49
+ * goes on waiting only while a pod that will end (a task's, another run's) holds room in the
50
+ * namespace: when only services hold it, they wait for what waits for them, and it fails.
51
+ */
52
+ class QuotaWait {
53
+ constructor(instance, env, options) {
54
+ this.instance = instance;
55
+ this.env = env;
56
+ this.options = options;
57
+ this.since = null;
58
+ this.told = '';
59
+ }
60
+ // Called on each poll with what the quota refused now, or null when nothing is refused.
61
+ async check(refusal) {
62
+ const now = this.options.now ?? Date.now;
63
+ if (!refusal) {
64
+ this.since = null;
65
+ return;
66
+ }
67
+ if (refusal !== this.told) {
68
+ this.told = refusal;
69
+ this.options.onWaiting?.(`waits for room under the quota of the namespace: ${refusal}`);
70
+ }
71
+ if (exceedsQuota(refusal)) {
72
+ throw new Error(`${this.options.subject} asks for more than the quota of the namespace allows: ${refusal}`);
73
+ }
74
+ this.since = this.since ?? now();
75
+ if (now() - this.since < this.options.timeout) {
76
+ return;
77
+ }
78
+ if (await this.othersWillEnd()) {
79
+ // checked again after another `timeout`
80
+ this.since = now();
81
+ return;
82
+ }
83
+ throw new Error(`${this.options.subject} did not fit the quota of the namespace within ${Math.round(this.options.timeout / 60000)} minutes, and only services hold its room: ${refusal}`);
84
+ }
85
+ async othersWillEnd() {
86
+ try {
87
+ const pods = await this.instance.coreApi.listNamespacedPod({ namespace: this.env.namespace });
88
+ return pods.items.some(willEnd);
89
+ }
90
+ catch {
91
+ // what it can not see may still end
92
+ return true;
93
+ }
94
+ }
95
+ }
96
+ exports.QuotaWait = QuotaWait;
97
+ //# sourceMappingURL=quota-wait.js.map
@@ -1,11 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.restoreKubernetesData = exports.getRestoreTarget = void 0;
3
+ exports.restoreKubernetesData = exports.getRestoreTarget = exports.RESTORE_FILE = void 0;
4
4
  const event_cache_1 = require("../executer/event-cache");
5
5
  const log_1 = require("../log");
6
6
  const volumes_1 = require("./volumes");
7
7
  const ensure_persistent_data_1 = require("./ensure-persistent-data");
8
8
  const claim_layout_1 = require("./claim-layout");
9
+ // Unpacks the archive of an output that is a file to `$1`: by its content, since the name in
10
+ // the archive is the file's and the one in the claim may carry a hash (see getClaimSubPath)
11
+ exports.RESTORE_FILE = 'mkdir -p "$(dirname "$1")" && rm -rf "$1" && tar xzf - -O > "$1"';
9
12
  // Where the upload pod (which mounts the work's claim at /dev/hammerkit/<id>) unpacks the
10
13
  // archive of an output: the output's place in the claim, which its mount reads
11
14
  function getRestoreTarget(work, path,
@@ -22,6 +25,13 @@ async function restoreKubernetesData(work, kubernetes, instance, environment, pa
22
25
  options = {}) {
23
26
  const persistence = await (0, volumes_1.getKubernetesPersistence)(work, kubernetes, options.stateKey);
24
27
  const stateDir = kubernetes.server?.serviceRun ? (0, volumes_1.getItemFilesDir)(work, options.stateKey) : undefined;
28
+ // an output that is a file: its archive holds it alone (see storeKubernetesData), and it
29
+ // takes the place of whatever is at its path in the claim
30
+ const files = new Set(work.data.type === 'container-task'
31
+ ? work.data.generates
32
+ .filter((generate) => generate.isFile && !generate.inherited)
33
+ .map((generate) => generate.path)
34
+ : []);
25
35
  // a run stopped while the pod waits for room under the quota
26
36
  const { signal } = environment.abortCtrl;
27
37
  await (0, ensure_persistent_data_1.getPodForPersistence)(instance, kubernetes, work, persistence, 'write', signal, async (name) => {
@@ -39,7 +49,13 @@ options = {}) {
39
49
  // a claim made for this run (a server removes the claims of a run when it
40
50
  // ends) does not have the directory of the output yet
41
51
  let failure = null;
42
- const res = await instance.exec.exec(kubernetes.namespace, name, 'volume', ['sh', '-c', 'mkdir -p "$1" && tar xzf - -C "$1"', 'sh', targetPath], process.stdout, process.stderr, environment.file.readStream(generatedArchive.filename), false, (status) => {
52
+ const res = await instance.exec.exec(kubernetes.namespace, name, 'volume', [
53
+ 'sh',
54
+ '-c',
55
+ files.has(generatedArchive.path) ? exports.RESTORE_FILE : 'mkdir -p "$1" && tar xzf - -C "$1"',
56
+ 'sh',
57
+ targetPath,
58
+ ], process.stdout, process.stderr, environment.file.readStream(generatedArchive.filename), false, (status) => {
43
59
  work.status.write('debug', `upload ${generatedArchive.filename} to ${name}: ${status.status}`);
44
60
  if (status.status !== 'Success') {
45
61
  failure = status.message ?? status.reason ?? 'unknown';
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.storeKubernetesData = void 0;
4
+ const path_1 = require("path");
4
5
  const log_1 = require("../log");
5
6
  const event_cache_1 = require("../executer/event-cache");
6
7
  const volumes_1 = require("./volumes");
@@ -12,10 +13,18 @@ stateKey) {
12
13
  const persistence = await (0, volumes_1.getKubernetesPersistence)(work, kubernetes, stateKey);
13
14
  // a run stopped while the pod waits for room under the quota
14
15
  const { signal } = environment.abortCtrl;
16
+ // an output that is a file is mounted as one: its archive holds it by its name, as on Docker
17
+ const files = new Set(work.data.type === 'container-task'
18
+ ? work.data.generates
19
+ .filter((generate) => generate.isFile && !generate.inherited)
20
+ .map((generate) => generate.path)
21
+ : []);
15
22
  await (0, ensure_persistent_data_1.getPodForPersistence)(instance, kubernetes, work, persistence, 'read', signal, async (name) => {
16
23
  for (const generatedArchive of (0, event_cache_1.getArchivePaths)(work.data, path)) {
17
24
  const writerStream = environment.file.createWriteStream(generatedArchive.filename);
18
- const res = await instance.exec.exec(kubernetes.namespace, name, 'volume', ['tar', 'zcf', '-', '-C', generatedArchive.path, '.'], writerStream, process.stderr, null, false, (status) => {
25
+ const res = await instance.exec.exec(kubernetes.namespace, name, 'volume', files.has(generatedArchive.path)
26
+ ? ['tar', 'zcf', '-', '-C', path_1.posix.dirname(generatedArchive.path), path_1.posix.basename(generatedArchive.path)]
27
+ : ['tar', 'zcf', '-', '-C', generatedArchive.path, '.'], writerStream, process.stderr, null, false, (status) => {
19
28
  work.status.write('debug', `archive ${generatedArchive.path} of ${name}: ${status}`);
20
29
  writerStream.close();
21
30
  });
@@ -59,6 +59,7 @@ stateKey) {
59
59
  sources: [],
60
60
  mounts: [],
61
61
  initCommands: [],
62
+ files: [],
62
63
  };
63
64
  const workspace = kubernetes.server?.workspace;
64
65
  for (const mount of work.data.mounts) {
@@ -79,8 +80,12 @@ stateKey) {
79
80
  }
80
81
  if (work.data.type === 'container-task') {
81
82
  for (const generate of work.data.generates) {
82
- appendVolume(generate.inherited ?? work, persistence, generate.path, null, false, !!generate.inherited, kubernetes, dirOf(generate.inherited ?? work));
83
+ const subPath = appendVolume(generate.inherited ?? work, persistence, generate.path, null, generate.isFile, !!generate.inherited, kubernetes, dirOf(generate.inherited ?? work));
84
+ if (generate.isFile && !generate.inherited) {
85
+ persistence.files.push(subPath);
86
+ }
83
87
  }
88
+ persistence.files.push(...getNestedMountPlaces(persistence, getVolumeName(work, kubernetes)));
84
89
  }
85
90
  else {
86
91
  for (const volume of work.data.volumes) {
@@ -92,6 +97,27 @@ stateKey) {
92
97
  return persistence;
93
98
  }
94
99
  exports.getKubernetesPersistence = getKubernetesPersistence;
100
+ // The places in the claim `claim` of the paths mounted inside a folder that is mounted read
101
+ // only from it: an output of a task inside a folder of its src (pkg/coverage/lcov.info with a
102
+ // src pkg). The container runtime makes the place of a mount where it isn't there, which it
103
+ // can't in a folder mounted read only, and the pod fails to start (exit 128). Each is in the
104
+ // claim's folder of the read-only path, a folder ending in `/`, or a file.
105
+ function getNestedMountPlaces(persistence, claim) {
106
+ const own = persistence.mounts.filter((m) => !m.workspace);
107
+ const folders = own.filter((m) => m.mount.name === claim && m.mount.readOnly && m.cwd === m.mount.mountPath && m.mount.subPath);
108
+ const places = [];
109
+ for (const mount of own) {
110
+ const parent = folders
111
+ .filter((folder) => folder !== mount && mount.mount.mountPath.startsWith(`${folder.mount.mountPath}/`))
112
+ .sort((a, b) => b.mount.mountPath.length - a.mount.mountPath.length)[0];
113
+ if (!parent) {
114
+ continue;
115
+ }
116
+ const place = `${parent.mount.subPath}/${mount.mount.mountPath.slice(parent.mount.mountPath.length + 1)}`;
117
+ places.push(mount.cwd === mount.mount.mountPath ? `${place}/` : place);
118
+ }
119
+ return places;
120
+ }
95
121
  function appendVolume(sourceWork, persistence, containerPath, localPath, isFile, readOnly, kubernetes,
96
122
  // the folder of the state of `sourceWork` the path is in, see getStateFilesDir
97
123
  stateDir,
@@ -134,6 +160,7 @@ claimPath = (0, claim_layout_1.getClaimSubPath)(sourceWork, containerPath)) {
134
160
  stateKey: '', // TODO
135
161
  });
136
162
  }
163
+ return subPath;
137
164
  }
138
165
  exports.appendVolume = appendVolume;
139
166
  /**
@@ -385,7 +385,10 @@ function kubernetesTaskRuntime(task, kubernetes) {
385
385
  try {
386
386
  if (!pod.status?.succeeded) {
387
387
  try {
388
- await (0, await_running_state_1.awaitJobCompletion)(instance, kubernetes, spec.metadata.name, options.abort);
388
+ await (0, await_running_state_1.awaitJobCompletion)(instance, kubernetes, spec.metadata.name, options.abort, 1000, {
389
+ onWaiting: (text) => task.status.write('warn', `${task.name} ${text}`),
390
+ timeout: ensure_persistent_data_1.QUOTA_TIMEOUT,
391
+ });
389
392
  }
390
393
  catch (e) {
391
394
  if (!(e instanceof abort_1.AbortError)) {
@@ -7,41 +7,8 @@ const apply_1 = require("../kubernetes/apply");
7
7
  const job_placement_1 = require("../kubernetes/job-placement");
8
8
  const volumes_1 = require("../kubernetes/volumes");
9
9
  const network_policy_1 = require("../kubernetes/network-policy");
10
- const UNITS = {
11
- n: 1e-9,
12
- u: 1e-6,
13
- m: 1e-3,
14
- '': 1,
15
- k: 1e3,
16
- K: 1e3,
17
- M: 1e6,
18
- G: 1e9,
19
- T: 1e12,
20
- P: 1e15,
21
- E: 1e18,
22
- Ki: 1024,
23
- Mi: 1024 ** 2,
24
- Gi: 1024 ** 3,
25
- Ti: 1024 ** 4,
26
- Pi: 1024 ** 5,
27
- Ei: 1024 ** 6,
28
- };
29
- // A quantity of Kubernetes (`250m`, `1.5`, `512Mi`, `12345678n`, `1e3`), as a number.
30
- function parseQuantity(value) {
31
- if (value === undefined) {
32
- return undefined;
33
- }
34
- if (typeof value === 'number') {
35
- return value;
36
- }
37
- const match = /^([+-]?[0-9.]+(?:[eE][+-]?\d+)?)([a-zA-Z]*)$/.exec(value.trim());
38
- if (!match || !(match[2] in UNITS)) {
39
- return undefined;
40
- }
41
- const number = Number(match[1]);
42
- return Number.isFinite(number) ? number * UNITS[match[2]] : undefined;
43
- }
44
- exports.parseQuantity = parseQuantity;
10
+ const units_1 = require("../utils/units");
11
+ Object.defineProperty(exports, "parseQuantity", { enumerable: true, get: function () { return units_1.parseQuantity; } });
45
12
  function add(into, cpu, memory) {
46
13
  if (cpu !== undefined) {
47
14
  into.cpu = (into.cpu ?? 0) + cpu;
@@ -182,8 +149,8 @@ class KubernetesCapacity {
182
149
  use.pods++;
183
150
  for (const container of pod.spec?.containers ?? []) {
184
151
  const requests = container.resources?.requests ?? {};
185
- add(group.requested, parseQuantity(requests.cpu) ?? 0, parseQuantity(requests.memory) ?? 0);
186
- add(use.requested, parseQuantity(requests.cpu) ?? 0, parseQuantity(requests.memory) ?? 0);
152
+ add(group.requested, (0, units_1.parseQuantity)(requests.cpu) ?? 0, (0, units_1.parseQuantity)(requests.memory) ?? 0);
153
+ add(use.requested, (0, units_1.parseQuantity)(requests.cpu) ?? 0, (0, units_1.parseQuantity)(requests.memory) ?? 0);
187
154
  }
188
155
  }
189
156
  }
@@ -196,8 +163,8 @@ class KubernetesCapacity {
196
163
  namespace,
197
164
  });
198
165
  const hard = quota.spec?.hard ?? {};
199
- const cpu = parseQuantity(hard['requests.cpu'] ?? hard.cpu);
200
- const memory = parseQuantity(hard['requests.memory'] ?? hard.memory);
166
+ const cpu = (0, units_1.parseQuantity)(hard['requests.cpu'] ?? hard.cpu);
167
+ const memory = (0, units_1.parseQuantity)(hard['requests.memory'] ?? hard.memory);
201
168
  if (cpu !== undefined || memory !== undefined) {
202
169
  group.quota = { ...(cpu !== undefined ? { cpu } : {}), ...(memory !== undefined ? { memory } : {}) };
203
170
  }
@@ -215,10 +182,10 @@ class KubernetesCapacity {
215
182
  for (const pod of metrics.items) {
216
183
  const use = usesOfPods.get(pod.metadata?.name ?? '');
217
184
  for (const container of pod.containers) {
218
- add(used, parseQuantity(container.usage.cpu), parseQuantity(container.usage.memory));
185
+ add(used, (0, units_1.parseQuantity)(container.usage.cpu), (0, units_1.parseQuantity)(container.usage.memory));
219
186
  if (use) {
220
187
  use.used ?? (use.used = { cpu: 0, memory: 0 });
221
- add(use.used, parseQuantity(container.usage.cpu), parseQuantity(container.usage.memory));
188
+ add(use.used, (0, units_1.parseQuantity)(container.usage.cpu), (0, units_1.parseQuantity)(container.usage.memory));
222
189
  }
223
190
  }
224
191
  }
@@ -248,7 +215,7 @@ class KubernetesCapacity {
248
215
  const allocatable = {};
249
216
  for (const node of nodes.items) {
250
217
  const of = node.status?.allocatable ?? {};
251
- add(allocatable, parseQuantity(of.cpu), parseQuantity(of.memory));
218
+ add(allocatable, (0, units_1.parseQuantity)(of.cpu), (0, units_1.parseQuantity)(of.memory));
252
219
  }
253
220
  machines = { count: nodes.items.length, allocatable };
254
221
  if (this.instance.metrics) {
@@ -256,7 +223,7 @@ class KubernetesCapacity {
256
223
  const metrics = await this.instance.metrics.getNodeMetrics();
257
224
  const nodesUsed = {};
258
225
  for (const node of metrics.items) {
259
- add(nodesUsed, parseQuantity(node.usage.cpu), parseQuantity(node.usage.memory));
226
+ add(nodesUsed, (0, units_1.parseQuantity)(node.usage.cpu), (0, units_1.parseQuantity)(node.usage.memory));
260
227
  }
261
228
  machines.used = nodesUsed;
262
229
  }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.parseCpus = exports.formatSize = exports.parseSize = exports.formatDuration = exports.parseDuration = void 0;
3
+ exports.parseQuantity = exports.parseCpus = exports.formatSize = exports.parseSize = exports.formatDuration = exports.parseDuration = void 0;
4
4
  const DURATION_UNITS = {
5
5
  ms: 1,
6
6
  s: 1000,
@@ -97,4 +97,39 @@ function parseCpus(value) {
97
97
  return millicores / 1000;
98
98
  }
99
99
  exports.parseCpus = parseCpus;
100
+ const QUANTITY_UNITS = {
101
+ n: 1e-9,
102
+ u: 1e-6,
103
+ m: 1e-3,
104
+ '': 1,
105
+ k: 1e3,
106
+ K: 1e3,
107
+ M: 1e6,
108
+ G: 1e9,
109
+ T: 1e12,
110
+ P: 1e15,
111
+ E: 1e18,
112
+ Ki: 1024,
113
+ Mi: 1024 ** 2,
114
+ Gi: 1024 ** 3,
115
+ Ti: 1024 ** 4,
116
+ Pi: 1024 ** 5,
117
+ Ei: 1024 ** 6,
118
+ };
119
+ // A quantity of Kubernetes (`250m`, `1.5`, `512Mi`, `12345678n`, `1e3`), as a number.
120
+ function parseQuantity(value) {
121
+ if (value === undefined) {
122
+ return undefined;
123
+ }
124
+ if (typeof value === 'number') {
125
+ return value;
126
+ }
127
+ const match = /^([+-]?[0-9.]+(?:[eE][+-]?\d+)?)([a-zA-Z]*)$/.exec(value.trim());
128
+ if (!match || !(match[2] in QUANTITY_UNITS)) {
129
+ return undefined;
130
+ }
131
+ const number = Number(match[1]);
132
+ return Number.isFinite(number) ? number * QUANTITY_UNITS[match[2]] : undefined;
133
+ }
134
+ exports.parseQuantity = parseQuantity;
100
135
  //# sourceMappingURL=units.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": "2.0.0-next.6",
4
+ "version": "2.0.0-next.8",
5
5
  "keywords": [
6
6
  "build",
7
7
  "build-tool",