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.
- package/dist/kubernetes/await-running-state.js +69 -5
- package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +10 -1
- package/dist/kubernetes/ensure-persistent-data.js +44 -11
- package/dist/kubernetes/quota-wait.js +97 -0
- package/dist/kubernetes/restore-kubernetes-data.js +18 -2
- package/dist/kubernetes/store-kubernetes-data.js +10 -1
- package/dist/kubernetes/volumes.js +28 -1
- package/dist/planner/work-runtime-kubernetes.js +4 -1
- package/dist/server/capacity.js +10 -43
- package/dist/utils/units.js +36 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 =
|
|
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
|
-
?
|
|
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
|
|
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)(
|
|
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
|
|
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,
|
|
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', [
|
|
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',
|
|
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,
|
|
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)) {
|
package/dist/server/capacity.js
CHANGED
|
@@ -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
|
|
11
|
-
|
|
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
|
}
|
package/dist/utils/units.js
CHANGED
|
@@ -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