hammerkit 2.0.0-next.5 → 2.0.0-next.7
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/cache/explain.js +7 -5
- package/dist/dashboard/assets/graph-canvas-DLioOiRN.css +1 -0
- package/dist/dashboard/assets/graph-canvas-HmkW_-XJ.js +8 -0
- package/dist/dashboard/assets/index-gIBDC_m8.css +2 -0
- package/dist/dashboard/assets/index-vE22S5E_.js +17 -0
- package/dist/dashboard/index.html +2 -2
- package/dist/executer/docker-task.js +3 -0
- package/dist/executer/execute-work-task.js +3 -1
- package/dist/executer/local-task.js +3 -0
- package/dist/executer/scheduler/enqueue-next.js +6 -1
- package/dist/executer/scheduler/state-key.js +71 -34
- package/dist/executer/watch-loop.js +18 -5
- package/dist/file/get-file-context.js +5 -1
- package/dist/kubernetes/claim-layout.js +10 -1
- package/dist/kubernetes/ensure-persistent-data.js +84 -10
- package/dist/kubernetes/follow-pod-logs.js +124 -27
- package/dist/kubernetes/network-policy.js +4 -4
- package/dist/kubernetes/restore-kubernetes-data.js +21 -3
- package/dist/kubernetes/store-kubernetes-data.js +13 -2
- package/dist/kubernetes/volumes.js +34 -7
- package/dist/optimizer/calculate-checksum.js +46 -3
- package/dist/optimizer/get-work-cache-stats.js +36 -24
- package/dist/planner/work-item-executions.js +27 -0
- package/dist/planner/work-runtime-kubernetes.js +33 -38
- package/dist/server/capacity.js +63 -0
- package/dist/server/dashboard.js +2 -2
- package/dist/server/executor.js +10 -0
- package/dist/server/overview.js +42 -9
- package/dist/server/plan.js +16 -3
- package/dist/server/policy.js +9 -3
- package/dist/server/run-worker.js +37 -4
- package/dist/server/runs.js +49 -4
- package/dist/server/server-config.js +4 -1
- package/dist/server/server.js +10 -7
- package/dist/utils/limit.js +30 -0
- package/package.json +1 -1
- package/dist/dashboard/assets/index-D51NA6-h.js +0 -14
- package/dist/dashboard/assets/index-mmvC6u9O.css +0 -2
|
@@ -6,6 +6,11 @@ const readline_1 = require("readline");
|
|
|
6
6
|
const log_1 = require("../log");
|
|
7
7
|
// what is read of an instance of a container that already ended
|
|
8
8
|
const MAX_ENDED_LOG_BYTES = 1024 * 1024;
|
|
9
|
+
// how often in a row the log of one instance is read again after its reading broke off at once
|
|
10
|
+
const MAX_READS = 20;
|
|
11
|
+
// `timestamps: true` puts the time a line was written before it (RFC3339 with up to nine
|
|
12
|
+
// digits of a second), which says where a log that broke off got to
|
|
13
|
+
const TIMESTAMPED = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z(?: (.*))?$/;
|
|
9
14
|
/**
|
|
10
15
|
* Follows the log of a container in the pods of a selector, as `docker attach` does for a
|
|
11
16
|
* service on Docker: the instance that runs as it writes, and an instance that ended before
|
|
@@ -14,7 +19,12 @@ const MAX_ENDED_LOG_BYTES = 1024 * 1024;
|
|
|
14
19
|
*/
|
|
15
20
|
function followPodLogs(instance, namespace, labelSelector, container, onLine, options = {}) {
|
|
16
21
|
const seen = new Set();
|
|
17
|
-
const
|
|
22
|
+
const positions = new Map();
|
|
23
|
+
// the logs followed now, resolved when they end
|
|
24
|
+
const streams = new Map();
|
|
25
|
+
const following = new Map();
|
|
26
|
+
// the instances that ended, by the last look
|
|
27
|
+
const ended = new Set();
|
|
18
28
|
const reading = new Set();
|
|
19
29
|
let stopped = false;
|
|
20
30
|
let failed = false;
|
|
@@ -34,28 +44,91 @@ function followPodLogs(instance, namespace, labelSelector, container, onLine, op
|
|
|
34
44
|
const tracked = promise.catch(fail).finally(() => reading.delete(tracked));
|
|
35
45
|
reading.add(tracked);
|
|
36
46
|
};
|
|
37
|
-
const
|
|
47
|
+
const positionOf = (id) => {
|
|
48
|
+
let position = positions.get(id);
|
|
49
|
+
if (!position) {
|
|
50
|
+
position = { at: null, since: null, count: 0, reads: 0 };
|
|
51
|
+
positions.set(id, position);
|
|
52
|
+
}
|
|
53
|
+
return position;
|
|
54
|
+
};
|
|
55
|
+
// a reader of the log of one instance from where it got to: the lines read before, again
|
|
56
|
+
// at the start of a log read since their second, are passed over
|
|
57
|
+
const reader = (position) => {
|
|
58
|
+
const from = position.at;
|
|
59
|
+
const skip = position.count;
|
|
60
|
+
let atFrom = 0;
|
|
61
|
+
return (raw) => {
|
|
62
|
+
const match = TIMESTAMPED.exec(raw);
|
|
63
|
+
if (!match) {
|
|
64
|
+
onLine(raw);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const at = `${match[1]}.${(match[2] ?? '').padEnd(9, '0')}`;
|
|
68
|
+
if (from !== null && at <= from) {
|
|
69
|
+
if (at < from || ++atFrom <= skip) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (at === position.at) {
|
|
74
|
+
position.count++;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
position.at = at;
|
|
78
|
+
position.since = `${match[1]}Z`;
|
|
79
|
+
position.count = 1;
|
|
80
|
+
}
|
|
81
|
+
onLine(match[3] ?? '');
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
const readEnded = async (pod, id, previous) => {
|
|
85
|
+
const position = positionOf(id);
|
|
86
|
+
position.reads++;
|
|
38
87
|
const log = await instance.coreApi.readNamespacedPodLog({
|
|
39
88
|
name: pod,
|
|
40
89
|
namespace,
|
|
41
90
|
container,
|
|
42
91
|
previous,
|
|
92
|
+
timestamps: true,
|
|
43
93
|
limitBytes: MAX_ENDED_LOG_BYTES,
|
|
94
|
+
// this call takes no sinceTime: a minute more than since then, for clocks that differ
|
|
95
|
+
...(position.since
|
|
96
|
+
? { sinceSeconds: Math.max(1, Math.ceil((Date.now() - Date.parse(position.since)) / 1000) + 60) }
|
|
97
|
+
: {}),
|
|
44
98
|
});
|
|
99
|
+
const read = reader(position);
|
|
45
100
|
for (const line of String(log ?? '').split(/\r?\n/)) {
|
|
46
101
|
if (line.length > 0) {
|
|
47
|
-
|
|
102
|
+
read(line);
|
|
48
103
|
}
|
|
49
104
|
}
|
|
50
105
|
};
|
|
51
|
-
const follow = async (pod) => {
|
|
106
|
+
const follow = async (pod, id) => {
|
|
107
|
+
const position = positionOf(id);
|
|
108
|
+
position.reads++;
|
|
52
109
|
const output = new stream_1.PassThrough();
|
|
53
|
-
|
|
54
|
-
|
|
110
|
+
const started = Date.now();
|
|
111
|
+
let over = false;
|
|
112
|
+
let done = () => undefined;
|
|
113
|
+
const stream = new Promise((resolve) => (done = resolve));
|
|
114
|
+
streams.set(id, stream);
|
|
115
|
+
// the instance ended or the request broke: it is looked at again at once, a restarted
|
|
116
|
+
// one followed, and what this one wrote after the break read from where it got to
|
|
55
117
|
const end = () => {
|
|
56
|
-
if (!
|
|
57
|
-
|
|
118
|
+
if (!over) {
|
|
119
|
+
over = true;
|
|
58
120
|
streaming--;
|
|
121
|
+
// a follow that lasted is cut now and then (a quiet stretch, a proxy): only reads
|
|
122
|
+
// that break off at once count against the limit
|
|
123
|
+
if (Date.now() - started >= 10000) {
|
|
124
|
+
position.reads = 0;
|
|
125
|
+
}
|
|
126
|
+
streams.delete(id);
|
|
127
|
+
following.delete(id);
|
|
128
|
+
done();
|
|
129
|
+
if (!stopped && position.reads < MAX_READS) {
|
|
130
|
+
seen.delete(id);
|
|
131
|
+
}
|
|
59
132
|
wake();
|
|
60
133
|
}
|
|
61
134
|
};
|
|
@@ -64,11 +137,15 @@ function followPodLogs(instance, namespace, labelSelector, container, onLine, op
|
|
|
64
137
|
end();
|
|
65
138
|
}));
|
|
66
139
|
output.on('end', end);
|
|
67
|
-
(0, readline_1.createInterface)({ input: output }).on('line', (
|
|
140
|
+
(0, readline_1.createInterface)({ input: output }).on('line', reader(position));
|
|
68
141
|
streaming++;
|
|
69
142
|
let controller;
|
|
70
143
|
try {
|
|
71
|
-
controller = await instance.log.log(namespace, pod, container, output, {
|
|
144
|
+
controller = await instance.log.log(namespace, pod, container, output, {
|
|
145
|
+
follow: true,
|
|
146
|
+
timestamps: true,
|
|
147
|
+
...(position.since ? { sinceTime: position.since } : {}),
|
|
148
|
+
});
|
|
72
149
|
}
|
|
73
150
|
catch (e) {
|
|
74
151
|
end();
|
|
@@ -78,7 +155,7 @@ function followPodLogs(instance, namespace, labelSelector, container, onLine, op
|
|
|
78
155
|
controller.abort();
|
|
79
156
|
}
|
|
80
157
|
else {
|
|
81
|
-
following.
|
|
158
|
+
following.set(id, controller);
|
|
82
159
|
}
|
|
83
160
|
};
|
|
84
161
|
const look = (pod) => {
|
|
@@ -91,22 +168,28 @@ function followPodLogs(instance, namespace, labelSelector, container, onLine, op
|
|
|
91
168
|
// an instance that ended before this one, not seen while it ran: its lines go first
|
|
92
169
|
let before = Promise.resolve();
|
|
93
170
|
const last = status.lastState?.terminated?.containerID;
|
|
171
|
+
const current = status.containerID;
|
|
172
|
+
if (last) {
|
|
173
|
+
ended.add(last);
|
|
174
|
+
}
|
|
175
|
+
if (current && status.state?.terminated) {
|
|
176
|
+
ended.add(current);
|
|
177
|
+
}
|
|
94
178
|
if (last && !seen.has(last)) {
|
|
95
179
|
seen.add(last);
|
|
96
|
-
before = readEnded(name, true).catch(fail);
|
|
180
|
+
before = readEnded(name, last, true).catch(fail);
|
|
97
181
|
track(before);
|
|
98
182
|
}
|
|
99
|
-
const current = status.containerID;
|
|
100
183
|
if (!current || seen.has(current)) {
|
|
101
184
|
return;
|
|
102
185
|
}
|
|
103
186
|
if (status.state?.running) {
|
|
104
187
|
seen.add(current);
|
|
105
|
-
track(before.then(() => follow(name)));
|
|
188
|
+
track(before.then(() => follow(name, current)));
|
|
106
189
|
}
|
|
107
190
|
else if (status.state?.terminated) {
|
|
108
191
|
seen.add(current);
|
|
109
|
-
track(before.then(() => readEnded(name, false)));
|
|
192
|
+
track(before.then(() => readEnded(name, current, false)));
|
|
110
193
|
}
|
|
111
194
|
};
|
|
112
195
|
const poll = async () => {
|
|
@@ -157,26 +240,40 @@ function followPodLogs(instance, namespace, labelSelector, container, onLine, op
|
|
|
157
240
|
async settle() {
|
|
158
241
|
await polling;
|
|
159
242
|
await poll();
|
|
160
|
-
// what follows
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
|
|
243
|
+
// what follows an instance that ended ends by itself once it has read all there is
|
|
244
|
+
const endedStreams = () => [...streams].filter(([id]) => ended.has(id));
|
|
245
|
+
await within(Promise.all([...reading, ...endedStreams().map(([, stream]) => stream)]), 5000);
|
|
246
|
+
// one still going after that is cut, and read from where it got to
|
|
247
|
+
const cut = endedStreams();
|
|
248
|
+
if (cut.length > 0) {
|
|
249
|
+
for (const [id] of cut) {
|
|
250
|
+
following.get(id)?.abort();
|
|
251
|
+
}
|
|
252
|
+
await within(Promise.all(cut.map(([, stream]) => stream)), 1000);
|
|
253
|
+
await poll();
|
|
254
|
+
await within(Promise.all([...reading]), 5000);
|
|
255
|
+
}
|
|
171
256
|
},
|
|
172
257
|
stop() {
|
|
173
258
|
stopped = true;
|
|
174
259
|
clearTimeout(timer);
|
|
175
|
-
for (const controller of following.
|
|
260
|
+
for (const controller of following.values()) {
|
|
176
261
|
controller.abort();
|
|
177
262
|
}
|
|
263
|
+
following.clear();
|
|
178
264
|
},
|
|
179
265
|
};
|
|
180
266
|
}
|
|
181
267
|
exports.followPodLogs = followPodLogs;
|
|
268
|
+
async function within(promise, ms) {
|
|
269
|
+
let wait;
|
|
270
|
+
await Promise.race([
|
|
271
|
+
promise,
|
|
272
|
+
new Promise((resolve) => {
|
|
273
|
+
wait = setTimeout(resolve, ms);
|
|
274
|
+
wait.unref?.();
|
|
275
|
+
}),
|
|
276
|
+
]);
|
|
277
|
+
clearTimeout(wait);
|
|
278
|
+
}
|
|
182
279
|
//# sourceMappingURL=follow-pod-logs.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.removeSandboxProxies = exports.getProxyReadinessScript = exports.removeAllKubernetesItemNetworks = exports.releaseKubernetesItemNetwork = exports.awaitPodsEnded = exports.removeKubernetesItemNetwork = exports.ensureKubernetesItemNetwork = exports.buildProxyPolicy = exports.buildNetworkPolicy = exports.getNetworkPolicyName = exports.getSandboxProxy = exports.getProxyName = exports.getNetworkLabels = exports.NETWORK_PROXY = exports.NETWORK_LIMITED = exports.NETWORK_LABEL = exports.RUN_LABEL = void 0;
|
|
3
|
+
exports.removeSandboxProxies = exports.getProxyReadinessScript = exports.removeAllKubernetesItemNetworks = exports.releaseKubernetesItemNetwork = exports.awaitPodsEnded = exports.removeKubernetesItemNetwork = exports.ensureKubernetesItemNetwork = exports.buildProxyPolicy = exports.buildNetworkPolicy = exports.getNetworkPolicyName = exports.getSandboxProxy = exports.getProxyName = exports.getNetworkLabels = exports.NETWORK_PROXY = exports.NETWORK_LIMITED = exports.NETWORK_LABEL = exports.PROXY_OF_SANDBOX_LABEL = exports.RUN_LABEL = void 0;
|
|
4
4
|
const crypto_1 = require("crypto");
|
|
5
5
|
const stream_1 = require("stream");
|
|
6
6
|
const readline_1 = require("readline");
|
|
@@ -21,7 +21,7 @@ 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
|
|
24
|
-
|
|
24
|
+
exports.PROXY_OF_SANDBOX_LABEL = 'hammerkit.dev/proxy-of-sandbox';
|
|
25
25
|
const PROXY_READY_TIMEOUT_MS = 60000;
|
|
26
26
|
// the token of the endpoints of secret providers, in a Secret named like the proxy
|
|
27
27
|
const SECRET_TOKEN_KEY = 'token';
|
|
@@ -77,7 +77,7 @@ exports.getSandboxProxy = getSandboxProxy;
|
|
|
77
77
|
function getProxyLabels(env, item, proxy) {
|
|
78
78
|
return {
|
|
79
79
|
[NETWORK_OF_LABEL]: item.id(),
|
|
80
|
-
...(proxy.sandbox ? { [PROXY_OF_SANDBOX_LABEL]: proxy.sandbox.label } : (0, job_placement_1.getRunLabels)(env)),
|
|
80
|
+
...(proxy.sandbox ? { [exports.PROXY_OF_SANDBOX_LABEL]: proxy.sandbox.label } : (0, job_placement_1.getRunLabels)(env)),
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
83
|
function getNetworkPolicyName(item, scope) {
|
|
@@ -543,7 +543,7 @@ async function removeSandboxProxies(instance, namespace, of, keep = () => false)
|
|
|
543
543
|
const deployments = await instance.appsApi.listNamespacedDeployment({
|
|
544
544
|
namespace,
|
|
545
545
|
labelSelector: [
|
|
546
|
-
`${PROXY_OF_SANDBOX_LABEL}=${of.sandbox}`,
|
|
546
|
+
`${exports.PROXY_OF_SANDBOX_LABEL}=${of.sandbox}`,
|
|
547
547
|
...(of.item ? [`${NETWORK_OF_LABEL}=${of.item.id()}`] : []),
|
|
548
548
|
].join(','),
|
|
549
549
|
});
|
|
@@ -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,7 +25,16 @@ 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;
|
|
25
|
-
|
|
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
|
+
: []);
|
|
35
|
+
// a run stopped while the pod waits for room under the quota
|
|
36
|
+
const { signal } = environment.abortCtrl;
|
|
37
|
+
await (0, ensure_persistent_data_1.getPodForPersistence)(instance, kubernetes, work, persistence, 'write', signal, async (name) => {
|
|
26
38
|
if (options.reset) {
|
|
27
39
|
await (0, ensure_persistent_data_1.clearClaim)(instance, kubernetes, work, name);
|
|
28
40
|
}
|
|
@@ -37,7 +49,13 @@ options = {}) {
|
|
|
37
49
|
// a claim made for this run (a server removes the claims of a run when it
|
|
38
50
|
// ends) does not have the directory of the output yet
|
|
39
51
|
let failure = null;
|
|
40
|
-
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) => {
|
|
41
59
|
work.status.write('debug', `upload ${generatedArchive.filename} to ${name}: ${status.status}`);
|
|
42
60
|
if (status.status !== 'Success') {
|
|
43
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");
|
|
@@ -10,10 +11,20 @@ async function storeKubernetesData(work, kubernetes, instance, environment, path
|
|
|
10
11
|
// getStateFilesDir)
|
|
11
12
|
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
|
|
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
|
+
: []);
|
|
22
|
+
await (0, ensure_persistent_data_1.getPodForPersistence)(instance, kubernetes, work, persistence, 'read', signal, async (name) => {
|
|
14
23
|
for (const generatedArchive of (0, event_cache_1.getArchivePaths)(work.data, path)) {
|
|
15
24
|
const writerStream = environment.file.createWriteStream(generatedArchive.filename);
|
|
16
|
-
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) => {
|
|
17
28
|
work.status.write('debug', `archive ${generatedArchive.path} of ${name}: ${status}`);
|
|
18
29
|
writerStream.close();
|
|
19
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) {
|
|
@@ -68,20 +69,23 @@ stateKey) {
|
|
|
68
69
|
}
|
|
69
70
|
appendVolume(work, persistence, mount.containerPath, mount.localPath, mount.isFile, true, kubernetes, mounted, own);
|
|
70
71
|
}
|
|
72
|
+
// its deps' src too, uploaded into its own claim: a container task sees them as it does on
|
|
73
|
+
// docker (a tsc -b of a project that references another reads the other's tsconfig)
|
|
71
74
|
for (const src of work.data.src) {
|
|
72
|
-
if (src.inherited) {
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
75
|
if (workspace &&
|
|
76
76
|
appendWorkspaceMount(workspace, persistence, src.absolutePath, src.absolutePath, src.isFile, false)) {
|
|
77
77
|
continue;
|
|
78
78
|
}
|
|
79
|
-
appendVolume(work, persistence, src.absolutePath, src.absolutePath, src.isFile, true, kubernetes, mounted, own); // TODO matcher
|
|
79
|
+
appendVolume(work, persistence, src.absolutePath, src.absolutePath, src.isFile, true, kubernetes, mounted, own, src.inherited ? (0, claim_layout_1.getInheritedSourceSubPath)(work, src.absolutePath) : undefined); // TODO matcher
|
|
80
80
|
}
|
|
81
81
|
if (work.data.type === 'container-task') {
|
|
82
82
|
for (const generate of work.data.generates) {
|
|
83
|
-
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
|
+
}
|
|
84
87
|
}
|
|
88
|
+
persistence.files.push(...getNestedMountPlaces(persistence, getVolumeName(work, kubernetes)));
|
|
85
89
|
}
|
|
86
90
|
else {
|
|
87
91
|
for (const volume of work.data.volumes) {
|
|
@@ -93,14 +97,36 @@ stateKey) {
|
|
|
93
97
|
return persistence;
|
|
94
98
|
}
|
|
95
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
|
+
}
|
|
96
121
|
function appendVolume(sourceWork, persistence, containerPath, localPath, isFile, readOnly, kubernetes,
|
|
97
122
|
// the folder of the state of `sourceWork` the path is in, see getStateFilesDir
|
|
98
123
|
stateDir,
|
|
99
124
|
// the folder its upload goes to, when it is not the one the path is mounted from (a
|
|
100
125
|
// service that watches its files, see syncLiveFiles)
|
|
101
|
-
uploadDir = stateDir
|
|
126
|
+
uploadDir = stateDir,
|
|
127
|
+
// where it goes in the claim, when not where getClaimSubPath puts it
|
|
128
|
+
claimPath = (0, claim_layout_1.getClaimSubPath)(sourceWork, containerPath)) {
|
|
102
129
|
const volumeName = getVolumeName(sourceWork, kubernetes);
|
|
103
|
-
const claimPath = (0, claim_layout_1.getClaimSubPath)(sourceWork, containerPath);
|
|
104
130
|
const subPath = (stateDir ? `${stateDir}/` : '') + claimPath;
|
|
105
131
|
const existVolume = persistence.volumes.some((v) => v.name === volumeName);
|
|
106
132
|
if (!existVolume) {
|
|
@@ -134,6 +160,7 @@ uploadDir = stateDir) {
|
|
|
134
160
|
stateKey: '', // TODO
|
|
135
161
|
});
|
|
136
162
|
}
|
|
163
|
+
return subPath;
|
|
137
164
|
}
|
|
138
165
|
exports.appendVolume = appendVolume;
|
|
139
166
|
/**
|
|
@@ -1,17 +1,60 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.calculateChecksum = void 0;
|
|
3
|
+
exports.calculateChecksum = exports.racyWindow = void 0;
|
|
4
4
|
const crypto_1 = require("crypto");
|
|
5
|
+
// The checksums a process computed, by the file context and path: every task of a run hashes
|
|
6
|
+
// the sources of the tasks it depends on too, so most files are asked for many times
|
|
7
|
+
const known = new WeakMap();
|
|
8
|
+
// A file written again soon after it was hashed, with the same size, keeps its times on a
|
|
9
|
+
// file system whose timestamps are coarser than that: a checksum of a file changed within
|
|
10
|
+
// the window before it was hashed is not kept. Whole seconds are taken for a file system
|
|
11
|
+
// that has no more (FAT, ext3, HFS+), else the window is short.
|
|
12
|
+
function racyWindow(stats) {
|
|
13
|
+
return stats.lastModified % 1000 === 0 && stats.identity.changed % 1000 === 0 ? 2000 : 50;
|
|
14
|
+
}
|
|
15
|
+
exports.racyWindow = racyWindow;
|
|
5
16
|
// Hashes the raw bytes: decoding to text first would map every invalid UTF-8
|
|
6
17
|
// sequence to U+FFFD, so two different binary files could share a checksum.
|
|
7
18
|
// For UTF-8 text the result is unchanged. sha256, which nobody can make two files share:
|
|
8
19
|
// a cache hit is also what a verified check counts (check-key.ts).
|
|
9
|
-
|
|
20
|
+
//
|
|
21
|
+
// With the `stats` of the file, the checksum is reused while the file stays the same file:
|
|
22
|
+
// the same modification and change time, size and inode. A write changes its times, a
|
|
23
|
+
// replacement (a checkout, a rename over it) its inode.
|
|
24
|
+
async function calculateChecksum(environment, path, stats) {
|
|
25
|
+
const identity = stats?.identity;
|
|
26
|
+
if (!identity) {
|
|
27
|
+
return hashFile(environment, path);
|
|
28
|
+
}
|
|
29
|
+
let files = known.get(environment.file);
|
|
30
|
+
if (!files) {
|
|
31
|
+
files = new Map();
|
|
32
|
+
known.set(environment.file, files);
|
|
33
|
+
}
|
|
34
|
+
const cached = files.get(path);
|
|
35
|
+
if (cached &&
|
|
36
|
+
cached.lastModified === stats.lastModified &&
|
|
37
|
+
cached.size === identity.size &&
|
|
38
|
+
cached.changed === identity.changed &&
|
|
39
|
+
cached.inode === identity.inode) {
|
|
40
|
+
return cached.checksum;
|
|
41
|
+
}
|
|
42
|
+
const hashedAt = Date.now();
|
|
43
|
+
const checksum = await hashFile(environment, path);
|
|
44
|
+
if (hashedAt - Math.max(stats.lastModified, identity.changed) > racyWindow({ ...stats, identity })) {
|
|
45
|
+
files.set(path, { lastModified: stats.lastModified, ...identity, checksum });
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
files.delete(path);
|
|
49
|
+
}
|
|
50
|
+
return checksum;
|
|
51
|
+
}
|
|
52
|
+
exports.calculateChecksum = calculateChecksum;
|
|
53
|
+
async function hashFile(environment, path) {
|
|
10
54
|
const hash = (0, crypto_1.createHash)('sha256');
|
|
11
55
|
for await (const chunk of environment.file.readStream(path)) {
|
|
12
56
|
hash.update(chunk);
|
|
13
57
|
}
|
|
14
58
|
return hash.digest('hex');
|
|
15
59
|
}
|
|
16
|
-
exports.calculateChecksum = calculateChecksum;
|
|
17
60
|
//# sourceMappingURL=calculate-checksum.js.map
|
|
@@ -5,55 +5,67 @@ const calculate_checksum_1 = require("./calculate-checksum");
|
|
|
5
5
|
const path_1 = require("path");
|
|
6
6
|
const crypto_1 = require("crypto");
|
|
7
7
|
const promises_1 = require("fs/promises");
|
|
8
|
+
const limit_1 = require("../utils/limit");
|
|
8
9
|
function isExcluded(path, excluded) {
|
|
9
10
|
return excluded.some((excludedPath) => path === excludedPath || path.startsWith(excludedPath + path_1.sep));
|
|
10
11
|
}
|
|
11
|
-
|
|
12
|
+
// What the walk does at once: a stat, a listing or a checksum of a file each. Awaited one
|
|
13
|
+
// after the other, a walk of a thousand files spent most of its time waiting for the disk.
|
|
14
|
+
const WALK_CONCURRENCY = 32;
|
|
15
|
+
async function addWorkCacheStats(result, cwd, path, matcher, context, root, excluded, limit = (0, limit_1.createLimit)(WALK_CONCURRENCY)) {
|
|
12
16
|
if (isExcluded(path, excluded)) {
|
|
13
17
|
return 0;
|
|
14
18
|
}
|
|
19
|
+
// the slot is held for what this path needs of the disk, not while its children are walked
|
|
20
|
+
const visited = await limit(() => visitPath(cwd, path, matcher, context, root));
|
|
21
|
+
if (visited.type === 'file') {
|
|
22
|
+
// '/'-separated on every OS, so a Windows host keys a container task like Linux
|
|
23
|
+
result.files[visited.key] = { lastModified: visited.lastModified, checksum: visited.checksum };
|
|
24
|
+
return 1;
|
|
25
|
+
}
|
|
26
|
+
if (visited.type === 'directory') {
|
|
27
|
+
const counts = await Promise.all(visited.files.map((file) => addWorkCacheStats(result, cwd, (0, path_1.join)(path, file), matcher, context, false, excluded, limit)));
|
|
28
|
+
return counts.reduce((sum, count) => sum + count, 0);
|
|
29
|
+
}
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
async function visitPath(cwd, path, matcher, context, root) {
|
|
33
|
+
const key = (0, path_1.relative)(cwd, path).split(path_1.sep).join(path_1.posix.sep);
|
|
15
34
|
// On a server the files were written by a commit or a task: a link is a source as it is,
|
|
16
35
|
// its target, and never followed. A `**` would walk `x -> /` through the server's disk.
|
|
17
36
|
if (context.hosted) {
|
|
18
37
|
const link = await (0, promises_1.lstat)(path).catch(() => undefined);
|
|
19
38
|
if (link?.isSymbolicLink()) {
|
|
20
39
|
if (!matcher(path)) {
|
|
21
|
-
return
|
|
40
|
+
return { type: 'none' };
|
|
22
41
|
}
|
|
23
42
|
const checksum = (0, crypto_1.createHash)('sha256')
|
|
24
43
|
.update(`link:${await (0, promises_1.readlink)(path)}`)
|
|
25
44
|
.digest('hex');
|
|
26
|
-
|
|
27
|
-
return 1;
|
|
45
|
+
return { type: 'file', key, lastModified: link.mtimeMs, checksum };
|
|
28
46
|
}
|
|
29
47
|
}
|
|
30
48
|
const exists = await context.file.exists(path);
|
|
31
49
|
if (!exists) {
|
|
32
|
-
return
|
|
50
|
+
return { type: 'none' };
|
|
33
51
|
}
|
|
34
52
|
const stats = await context.file.stats(path);
|
|
35
53
|
if (stats.type === 'file') {
|
|
36
54
|
if (!matcher(path)) {
|
|
37
|
-
return
|
|
55
|
+
return { type: 'none' };
|
|
38
56
|
}
|
|
39
|
-
const checksum = await (0, calculate_checksum_1.calculateChecksum)(context, path);
|
|
40
|
-
|
|
41
|
-
result.files[(0, path_1.relative)(cwd, path).split(path_1.sep).join(path_1.posix.sep)] = { lastModified: stats.lastModified, checksum };
|
|
42
|
-
return 1;
|
|
57
|
+
const checksum = await (0, calculate_checksum_1.calculateChecksum)(context, path, stats);
|
|
58
|
+
return { type: 'file', key, lastModified: stats.lastModified, checksum };
|
|
43
59
|
}
|
|
44
60
|
else if (stats.type === 'directory') {
|
|
45
61
|
// A glob such as src/**/*.ts never matches a directory itself, so a
|
|
46
62
|
// directory is walked when files inside it could match.
|
|
47
63
|
if (!root && !matcher(path, true)) {
|
|
48
|
-
return
|
|
49
|
-
}
|
|
50
|
-
let matched = 0;
|
|
51
|
-
for (const file of await context.file.listFiles(path)) {
|
|
52
|
-
matched += await addWorkCacheStats(result, cwd, (0, path_1.join)(path, file), matcher, context, false, excluded);
|
|
64
|
+
return { type: 'none' };
|
|
53
65
|
}
|
|
54
|
-
return
|
|
66
|
+
return { type: 'directory', files: await context.file.listFiles(path) };
|
|
55
67
|
}
|
|
56
|
-
return
|
|
68
|
+
return { type: 'none' };
|
|
57
69
|
}
|
|
58
70
|
async function getWorkCacheStats(work, environment) {
|
|
59
71
|
return (await collectSourceStats(work, environment, [])).stats;
|
|
@@ -66,14 +78,14 @@ async function collectSourceStats(work, environment, excluded) {
|
|
|
66
78
|
created: new Date(),
|
|
67
79
|
files: {},
|
|
68
80
|
};
|
|
69
|
-
|
|
70
|
-
|
|
81
|
+
// one limit for all its srcs, which are walked at once: an inherited src is often a large
|
|
82
|
+
// part of what a task hashes
|
|
83
|
+
const limit = (0, limit_1.createLimit)(WALK_CONCURRENCY);
|
|
84
|
+
const matches = await Promise.all(work.src.map((src) => {
|
|
71
85
|
const matcher = (file, partial) => src.matcher(file, work.cwd, partial);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
}
|
|
86
|
+
return addWorkCacheStats(result, work.cwd, src.absolutePath, matcher, environment, true, excluded, limit);
|
|
87
|
+
}));
|
|
88
|
+
const unmatched = work.src.filter((src, index) => matches[index] === 0 && !src.inherited && !isExcluded(src.absolutePath, excluded));
|
|
77
89
|
return { stats: result, unmatched };
|
|
78
90
|
}
|
|
79
91
|
// The source stats of a task or service, leaving out everything its (transitive)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readExecutions = exports.clearExecutions = exports.recordExecution = void 0;
|
|
4
|
+
// kept by the task, not its item: the runtime and the scheduler hold copies of the item
|
|
5
|
+
const executions = new WeakMap();
|
|
6
|
+
function recordExecution(task, startedAt, finishedAt) {
|
|
7
|
+
if (Number.isNaN(startedAt.getTime()) || Number.isNaN(finishedAt.getTime()) || finishedAt < startedAt) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
let list = executions.get(task);
|
|
11
|
+
if (!list) {
|
|
12
|
+
list = [];
|
|
13
|
+
executions.set(task, list);
|
|
14
|
+
}
|
|
15
|
+
list.push({ startedAt, finishedAt });
|
|
16
|
+
}
|
|
17
|
+
exports.recordExecution = recordExecution;
|
|
18
|
+
// a task that runs again (watch) starts with none
|
|
19
|
+
function clearExecutions(task) {
|
|
20
|
+
executions.delete(task);
|
|
21
|
+
}
|
|
22
|
+
exports.clearExecutions = clearExecutions;
|
|
23
|
+
function readExecutions(task) {
|
|
24
|
+
return executions.get(task) ?? [];
|
|
25
|
+
}
|
|
26
|
+
exports.readExecutions = readExecutions;
|
|
27
|
+
//# sourceMappingURL=work-item-executions.js.map
|