@geminixiang/mikan 1.3.0 → 1.4.0-beta.0
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/CHANGELOG.md +37 -0
- package/README.md +7 -8
- package/dist/adapters/commands/login.js +1 -1
- package/dist/adapters/commands/login.js.map +1 -1
- package/dist/adapters/commands/sandbox.js +2 -2
- package/dist/adapters/commands/sandbox.js.map +1 -1
- package/dist/cli/boot.js +1 -1
- package/dist/cli/boot.js.map +1 -1
- package/dist/cli/migrate.d.ts.map +1 -1
- package/dist/cli/migrate.js +3 -2
- package/dist/cli/migrate.js.map +1 -1
- package/dist/cli/onboard.d.ts.map +1 -1
- package/dist/cli/onboard.js +15 -13
- package/dist/cli/onboard.js.map +1 -1
- package/dist/cli/pm2-ecosystem.d.ts +1 -2
- package/dist/cli/pm2-ecosystem.d.ts.map +1 -1
- package/dist/cli/pm2-ecosystem.js +2 -3
- package/dist/cli/pm2-ecosystem.js.map +1 -1
- package/dist/env-manifest.d.ts.map +1 -1
- package/dist/env-manifest.js +11 -0
- package/dist/env-manifest.js.map +1 -1
- package/dist/harness/execution-resolver.d.ts +3 -2
- package/dist/harness/execution-resolver.d.ts.map +1 -1
- package/dist/harness/execution-resolver.js +12 -10
- package/dist/harness/execution-resolver.js.map +1 -1
- package/dist/harness/prompt.d.ts.map +1 -1
- package/dist/harness/prompt.js +3 -2
- package/dist/harness/prompt.js.map +1 -1
- package/dist/harness/runner.d.ts.map +1 -1
- package/dist/harness/runner.js +37 -62
- package/dist/harness/runner.js.map +1 -1
- package/dist/harness/session.js +1 -1
- package/dist/harness/session.js.map +1 -1
- package/dist/harness/tools/attach.d.ts +3 -2
- package/dist/harness/tools/attach.d.ts.map +1 -1
- package/dist/harness/tools/attach.js +9 -2
- package/dist/harness/tools/attach.js.map +1 -1
- package/dist/harness/tools/index.d.ts +3 -2
- package/dist/harness/tools/index.d.ts.map +1 -1
- package/dist/harness/tools/index.js +6 -4
- package/dist/harness/tools/index.js.map +1 -1
- package/dist/harness/tools/jev-browser.d.ts +2 -2
- package/dist/harness/tools/jev-browser.d.ts.map +1 -1
- package/dist/harness/tools/jev-browser.js +41 -33
- package/dist/harness/tools/jev-browser.js.map +1 -1
- package/dist/harness/tools/sandbox.js +2 -2
- package/dist/harness/tools/sandbox.js.map +1 -1
- package/dist/harness/tools/types.d.ts +4 -0
- package/dist/harness/tools/types.d.ts.map +1 -1
- package/dist/harness/tools/types.js.map +1 -1
- package/dist/harness/tools/web-fetch.d.ts +6 -0
- package/dist/harness/tools/web-fetch.d.ts.map +1 -0
- package/dist/harness/tools/web-fetch.js +136 -0
- package/dist/harness/tools/web-fetch.js.map +1 -0
- package/dist/harness/types.d.ts +4 -3
- package/dist/harness/types.d.ts.map +1 -1
- package/dist/harness/types.js.map +1 -1
- package/dist/main.js +10 -24
- package/dist/main.js.map +1 -1
- package/dist/migrations/index.js +2 -2
- package/dist/migrations/index.js.map +1 -1
- package/dist/package-version.d.ts +2 -0
- package/dist/package-version.d.ts.map +1 -0
- package/dist/package-version.js +19 -0
- package/dist/package-version.js.map +1 -0
- package/dist/sandbox/container.d.ts +3 -17
- package/dist/sandbox/container.d.ts.map +1 -1
- package/dist/sandbox/container.js +108 -129
- package/dist/sandbox/container.js.map +1 -1
- package/dist/sandbox/engine.d.ts +4 -0
- package/dist/sandbox/engine.d.ts.map +1 -0
- package/dist/sandbox/engine.js +30 -0
- package/dist/sandbox/engine.js.map +1 -0
- package/dist/sandbox/host.d.ts +1 -10
- package/dist/sandbox/host.d.ts.map +1 -1
- package/dist/sandbox/host.js +2 -79
- package/dist/sandbox/host.js.map +1 -1
- package/dist/sandbox/provisioner.d.ts +15 -1
- package/dist/sandbox/provisioner.d.ts.map +1 -1
- package/dist/sandbox/provisioner.js +159 -111
- package/dist/sandbox/provisioner.js.map +1 -1
- package/dist/sandbox/published-image.d.ts +3 -0
- package/dist/sandbox/published-image.d.ts.map +1 -0
- package/dist/sandbox/published-image.js +9 -0
- package/dist/sandbox/published-image.js.map +1 -0
- package/dist/sandbox/registry.d.ts +5 -3
- package/dist/sandbox/registry.d.ts.map +1 -1
- package/dist/sandbox/registry.js +36 -27
- package/dist/sandbox/registry.js.map +1 -1
- package/dist/sandbox/types.d.ts +14 -22
- package/dist/sandbox/types.d.ts.map +1 -1
- package/dist/sandbox/types.js.map +1 -1
- package/dist/sandbox/utils.d.ts +0 -15
- package/dist/sandbox/utils.d.ts.map +1 -1
- package/dist/sandbox/utils.js +0 -72
- package/dist/sandbox/utils.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/vault/index.js +1 -1
- package/dist/vault/index.js.map +1 -1
- package/package.json +3 -1
- package/dist/harness/execution-env.d.ts +0 -4
- package/dist/harness/execution-env.d.ts.map +0 -1
- package/dist/harness/execution-env.js +0 -322
- package/dist/harness/execution-env.js.map +0 -1
|
@@ -4,29 +4,19 @@ import { readFileSync, statSync } from "node:fs";
|
|
|
4
4
|
import { isAbsolute, relative } from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import * as log from "../log.js";
|
|
7
|
+
import { isPublishedSandboxImage } from "./published-image.js";
|
|
7
8
|
import { reportUserFacingError } from "../observability/index.js";
|
|
8
9
|
const execFileAsync = promisify(execFile);
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
return false;
|
|
12
|
-
const stderr = err.stderr;
|
|
13
|
-
const message = err.message;
|
|
14
|
-
const haystack = `${typeof stderr === "string" ? stderr : ""}\n${typeof message === "string" ? message : ""}`.toLowerCase();
|
|
15
|
-
return (haystack.includes("no such network") ||
|
|
16
|
-
haystack.includes("no such container") ||
|
|
17
|
-
haystack.includes("no such object") ||
|
|
18
|
-
haystack.includes("network not found") ||
|
|
19
|
-
/network [^\n]+ not found/.test(haystack) ||
|
|
20
|
-
/error: no such [^\n]+/.test(haystack));
|
|
21
|
-
}
|
|
22
|
-
import { errorMessage } from "../unknown-values.js";
|
|
23
|
-
import { sweepOrphanedCommands } from "./container.js";
|
|
10
|
+
import { errorMessage, isRecord } from "../unknown-values.js";
|
|
11
|
+
import { containerEngine } from "./engine.js";
|
|
24
12
|
export class DockerContainerManager {
|
|
25
13
|
static { this.MANAGED_LABEL = "mikan.managed=true"; }
|
|
26
14
|
static { this.IMAGE_MODE_LABEL = "mikan.sandbox=image"; }
|
|
27
15
|
static { this.VAULT_ID_LABEL_KEY = "mikan.vault-id"; }
|
|
28
16
|
static { this.CONVERSATION_ID_LABEL_KEY = "mikan.conversation-id"; }
|
|
29
17
|
static { this.MOUNT_SIGNATURE_LABEL_KEY = "mikan.mount-signature"; }
|
|
18
|
+
static { this.NETWORK_LABEL_KEY = "mikan.network"; }
|
|
19
|
+
static { this.IMAGE_ID_LABEL_KEY = "mikan.image-id"; }
|
|
30
20
|
constructor(image, options = {}) {
|
|
31
21
|
this.image = image;
|
|
32
22
|
this.state = new Map();
|
|
@@ -37,6 +27,14 @@ export class DockerContainerManager {
|
|
|
37
27
|
this.limits = options.limits;
|
|
38
28
|
this.boostLimits = options.boostLimits;
|
|
39
29
|
this.execFileImpl = options.execFileImpl ?? execFileAsync;
|
|
30
|
+
this.configuredEngine = options.engine;
|
|
31
|
+
this.timeZone = options.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
32
|
+
}
|
|
33
|
+
get engine() {
|
|
34
|
+
return this.configuredEngine ?? containerEngine();
|
|
35
|
+
}
|
|
36
|
+
run(args) {
|
|
37
|
+
return this.execFileImpl(this.engine, args);
|
|
40
38
|
}
|
|
41
39
|
static containerName(containerKey) {
|
|
42
40
|
return `mikan-sandbox-${containerKey}`;
|
|
@@ -75,11 +73,14 @@ export class DockerContainerManager {
|
|
|
75
73
|
: await this.runtimeDrift(containerKey, containerName, mounts, status);
|
|
76
74
|
if (drift) {
|
|
77
75
|
log.logInfo(`Container ${containerName} is out of date (${drift}); replacing container`);
|
|
76
|
+
const previousImage = drift === "image" ? await this.containerImage(containerName) : undefined;
|
|
78
77
|
await this.replaceContainer(containerKey, containerName, mounts, options);
|
|
79
78
|
log.logInfo(`Container ${containerName} replaced from image ${this.image}`);
|
|
79
|
+
if (previousImage)
|
|
80
|
+
await this.removeImageIfUnused(previousImage);
|
|
80
81
|
}
|
|
81
82
|
else if (status === "stopped") {
|
|
82
|
-
await this.
|
|
83
|
+
await this.run(["start", containerName]);
|
|
83
84
|
log.logInfo(`Container ${containerName} started`);
|
|
84
85
|
}
|
|
85
86
|
else if (status === "missing") {
|
|
@@ -126,13 +127,18 @@ export class DockerContainerManager {
|
|
|
126
127
|
getBoostLimits() {
|
|
127
128
|
return this.boostLimits;
|
|
128
129
|
}
|
|
130
|
+
markUsed(containerKey) {
|
|
131
|
+
const state = this.state.get(containerKey);
|
|
132
|
+
if (state)
|
|
133
|
+
state.lastUsed = Date.now();
|
|
134
|
+
}
|
|
129
135
|
stop(containerKey) {
|
|
130
136
|
return this.serialize(containerKey, () => this.stopInner(containerKey));
|
|
131
137
|
}
|
|
132
138
|
async stopInner(containerKey) {
|
|
133
139
|
const containerName = this.getContainerName(containerKey);
|
|
134
140
|
try {
|
|
135
|
-
await this.
|
|
141
|
+
await this.run(["stop", containerName]);
|
|
136
142
|
this.setState(containerKey, "stopped", containerName);
|
|
137
143
|
this.boostedKeys.delete(containerKey);
|
|
138
144
|
this.overrideLimits.delete(containerKey);
|
|
@@ -153,7 +159,7 @@ export class DockerContainerManager {
|
|
|
153
159
|
throw new Error(`Failed to remove container ${containerName}`);
|
|
154
160
|
}
|
|
155
161
|
try {
|
|
156
|
-
await this.
|
|
162
|
+
await this.run(["network", "rm", networkName]);
|
|
157
163
|
log.logInfo(`Network ${networkName} removed`);
|
|
158
164
|
}
|
|
159
165
|
catch (err) {
|
|
@@ -192,9 +198,6 @@ export class DockerContainerManager {
|
|
|
192
198
|
const lastUsed = details.startedAtMs ?? Date.now();
|
|
193
199
|
this.state.set(containerKey, { status, lastUsed, containerName });
|
|
194
200
|
}
|
|
195
|
-
await Promise.all(Array.from(this.state.values())
|
|
196
|
-
.filter((state) => state.status === "running")
|
|
197
|
-
.map((state) => sweepOrphanedCommands(state.containerName, this.execFileImpl)));
|
|
198
201
|
const running = Array.from(this.state.values()).filter((s) => s.status === "running").length;
|
|
199
202
|
const stopped = this.state.size - running;
|
|
200
203
|
log.logInfo(`Reconciled ${this.state.size} managed containers (running=${running}, stopped=${stopped})`);
|
|
@@ -229,7 +232,9 @@ export class DockerContainerManager {
|
|
|
229
232
|
if (mounts.length > 0) {
|
|
230
233
|
labels.push("--label", `${DockerContainerManager.MOUNT_SIGNATURE_LABEL_KEY}=${this.mountSignature(mounts)}`);
|
|
231
234
|
}
|
|
232
|
-
|
|
235
|
+
labels.push("--label", `${DockerContainerManager.NETWORK_LABEL_KEY}=${networkName}`);
|
|
236
|
+
labels.push("--label", `${DockerContainerManager.IMAGE_ID_LABEL_KEY}=${await this.pulledImageId()}`);
|
|
237
|
+
await this.run([
|
|
233
238
|
"run",
|
|
234
239
|
"-d",
|
|
235
240
|
"--name",
|
|
@@ -242,6 +247,8 @@ export class DockerContainerManager {
|
|
|
242
247
|
"no-new-privileges",
|
|
243
248
|
"--pids-limit",
|
|
244
249
|
"1024",
|
|
250
|
+
"--env",
|
|
251
|
+
`TZ=${this.timeZone}`,
|
|
245
252
|
...labels,
|
|
246
253
|
...this.resourceLimitArgs(this.effectiveLimits(containerKey)),
|
|
247
254
|
...this.mountArgs(mounts),
|
|
@@ -261,7 +268,7 @@ export class DockerContainerManager {
|
|
|
261
268
|
resourceLimitArgs(limits) {
|
|
262
269
|
const args = [];
|
|
263
270
|
if (limits?.cpus)
|
|
264
|
-
args.push(
|
|
271
|
+
args.push(...cpuQuotaArgs(limits.cpus));
|
|
265
272
|
if (limits?.memory) {
|
|
266
273
|
args.push("--memory", limits.memory);
|
|
267
274
|
args.push("--memory-swap", limits.memory);
|
|
@@ -274,7 +281,7 @@ export class DockerContainerManager {
|
|
|
274
281
|
return;
|
|
275
282
|
const args = ["update", ...limitArgs, containerName];
|
|
276
283
|
try {
|
|
277
|
-
await this.
|
|
284
|
+
await this.run(args);
|
|
278
285
|
}
|
|
279
286
|
catch (err) {
|
|
280
287
|
log.logWarning(`Failed to apply resource limits to container ${containerName}`, errorMessage(err));
|
|
@@ -284,7 +291,7 @@ export class DockerContainerManager {
|
|
|
284
291
|
operation: "apply_resource_limits",
|
|
285
292
|
severity: "warning",
|
|
286
293
|
context: {
|
|
287
|
-
sandboxType: "
|
|
294
|
+
sandboxType: "oci",
|
|
288
295
|
containerKey,
|
|
289
296
|
containerName,
|
|
290
297
|
limitArgCount: limitArgs.length,
|
|
@@ -323,14 +330,8 @@ export class DockerContainerManager {
|
|
|
323
330
|
if (mounts.length === 0)
|
|
324
331
|
return false;
|
|
325
332
|
const expected = this.mountSignature(mounts);
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
"-f",
|
|
329
|
-
`{{index .Config.Labels "${DockerContainerManager.MOUNT_SIGNATURE_LABEL_KEY}"}}`,
|
|
330
|
-
containerName,
|
|
331
|
-
]);
|
|
332
|
-
const actual = this.normalizeDockerValue(stdout.trim());
|
|
333
|
-
return actual !== expected;
|
|
333
|
+
return ((await this.label(containerName, DockerContainerManager.MOUNT_SIGNATURE_LABEL_KEY)) !==
|
|
334
|
+
expected);
|
|
334
335
|
}
|
|
335
336
|
mountSignature(mounts) {
|
|
336
337
|
const payload = mounts
|
|
@@ -354,50 +355,81 @@ export class DockerContainerManager {
|
|
|
354
355
|
return "missing";
|
|
355
356
|
}
|
|
356
357
|
}
|
|
357
|
-
async
|
|
358
|
-
const { stdout } = await this.
|
|
358
|
+
async label(containerName, key) {
|
|
359
|
+
const { stdout } = await this.run([
|
|
359
360
|
"inspect",
|
|
360
361
|
"-f",
|
|
361
|
-
|
|
362
|
+
`{{index .Config.Labels "${key}"}}`,
|
|
362
363
|
containerName,
|
|
363
364
|
]);
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
}
|
|
372
|
-
return [...parsed].toSorted();
|
|
365
|
+
return this.normalizeDockerValue(stdout.trim());
|
|
366
|
+
}
|
|
367
|
+
async inspectBindMounts(containerName) {
|
|
368
|
+
const { stdout } = await this.run(["inspect", "-f", "{{json .Mounts}}", containerName]);
|
|
369
|
+
return parseBindMounts(stdout, containerName)
|
|
370
|
+
.map((mount) => `${mount.source}:${mount.target}${mount.readOnly ? ":ro" : ""}`)
|
|
371
|
+
.toSorted();
|
|
373
372
|
}
|
|
374
373
|
async hasImageDrift(containerName) {
|
|
375
374
|
const desired = await this.localImageId();
|
|
376
375
|
if (!desired)
|
|
377
376
|
return false;
|
|
378
|
-
|
|
377
|
+
return (await this.label(containerName, DockerContainerManager.IMAGE_ID_LABEL_KEY)) !== desired;
|
|
378
|
+
}
|
|
379
|
+
async localImageId() {
|
|
380
|
+
try {
|
|
381
|
+
const { stdout } = await this.run(["image", "inspect", "-f", "{{.Id}}", this.image]);
|
|
382
|
+
return this.normalizeDockerValue(stdout.trim());
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
return undefined;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
prepareImage() {
|
|
389
|
+
this.imagePull ??= this.pullImage();
|
|
390
|
+
return this.imagePull;
|
|
391
|
+
}
|
|
392
|
+
async pullImage() {
|
|
393
|
+
try {
|
|
394
|
+
await this.run(["pull", this.image]);
|
|
395
|
+
log.logInfo(`Sandbox image ${this.image} is current`);
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
log.logWarning(`Could not pull sandbox image ${this.image}; offices keep the local image if there is one`, errorMessage(err));
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async containerImage(containerName) {
|
|
402
|
+
const { stdout } = await this.run([
|
|
379
403
|
"inspect",
|
|
380
404
|
"-f",
|
|
381
|
-
|
|
405
|
+
`{{.Config.Image}}\t{{index .Config.Labels "${DockerContainerManager.IMAGE_ID_LABEL_KEY}"}}`,
|
|
382
406
|
containerName,
|
|
383
407
|
]);
|
|
384
|
-
|
|
408
|
+
const [reference = "", imageId] = stdout.trim().split("\t");
|
|
409
|
+
if (reference !== this.image && isPublishedSandboxImage(reference))
|
|
410
|
+
return reference;
|
|
411
|
+
return this.normalizeDockerValue(imageId);
|
|
385
412
|
}
|
|
386
|
-
async
|
|
413
|
+
async removeImageIfUnused(image) {
|
|
387
414
|
try {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
"inspect",
|
|
391
|
-
"-f",
|
|
392
|
-
"{{.Id}}",
|
|
393
|
-
this.image,
|
|
394
|
-
]);
|
|
395
|
-
return this.normalizeDockerValue(stdout.trim());
|
|
415
|
+
await this.run(["rmi", image]);
|
|
416
|
+
log.logInfo(`Removed sandbox image ${image}, which no container uses`);
|
|
396
417
|
}
|
|
397
418
|
catch {
|
|
398
|
-
return
|
|
419
|
+
return;
|
|
399
420
|
}
|
|
400
421
|
}
|
|
422
|
+
async pulledImageId() {
|
|
423
|
+
await this.imagePull;
|
|
424
|
+
const local = await this.localImageId();
|
|
425
|
+
if (local)
|
|
426
|
+
return local;
|
|
427
|
+
await this.run(["pull", this.image]);
|
|
428
|
+
const pulled = await this.localImageId();
|
|
429
|
+
if (!pulled)
|
|
430
|
+
throw new Error(`Image ${this.image} has no ID after pulling it`);
|
|
431
|
+
return pulled;
|
|
432
|
+
}
|
|
401
433
|
async replaceContainer(containerKey, containerName, mounts, options) {
|
|
402
434
|
const removed = await this.forceRemoveContainer(containerName, `Container ${containerName} removed for replacement`, `Failed to remove container ${containerName} for replacement`);
|
|
403
435
|
if (!removed)
|
|
@@ -406,25 +438,21 @@ export class DockerContainerManager {
|
|
|
406
438
|
}
|
|
407
439
|
async hasNetworkModeDrift(containerKey, containerName) {
|
|
408
440
|
const expected = DockerContainerManager.networkName(containerKey);
|
|
409
|
-
|
|
410
|
-
"inspect",
|
|
411
|
-
"-f",
|
|
412
|
-
"{{.HostConfig.NetworkMode}}",
|
|
413
|
-
containerName,
|
|
414
|
-
]);
|
|
415
|
-
return stdout.trim() !== expected;
|
|
441
|
+
return (await this.label(containerName, DockerContainerManager.NETWORK_LABEL_KEY)) !== expected;
|
|
416
442
|
}
|
|
417
443
|
async ensureNetwork(containerKey) {
|
|
418
444
|
const networkName = DockerContainerManager.networkName(containerKey);
|
|
419
|
-
|
|
420
|
-
|
|
445
|
+
const { stdout } = await this.run([
|
|
446
|
+
"network",
|
|
447
|
+
"ls",
|
|
448
|
+
"--filter",
|
|
449
|
+
`name=^${networkName}$`,
|
|
450
|
+
"--format",
|
|
451
|
+
"{{.Name}}",
|
|
452
|
+
]);
|
|
453
|
+
if (this.parseNameLines(stdout).includes(networkName))
|
|
421
454
|
return networkName;
|
|
422
|
-
|
|
423
|
-
catch (err) {
|
|
424
|
-
if (!isDockerNotFoundError(err))
|
|
425
|
-
throw err;
|
|
426
|
-
}
|
|
427
|
-
await this.execFileImpl("docker", [
|
|
455
|
+
await this.run([
|
|
428
456
|
"network",
|
|
429
457
|
"create",
|
|
430
458
|
"--driver",
|
|
@@ -439,25 +467,26 @@ export class DockerContainerManager {
|
|
|
439
467
|
]);
|
|
440
468
|
return networkName;
|
|
441
469
|
}
|
|
470
|
+
async containerExists(containerName) {
|
|
471
|
+
const { stdout } = await this.run([
|
|
472
|
+
"ps",
|
|
473
|
+
"-a",
|
|
474
|
+
"--filter",
|
|
475
|
+
`name=^${containerName}$`,
|
|
476
|
+
"--format",
|
|
477
|
+
"{{.Names}}",
|
|
478
|
+
]);
|
|
479
|
+
return this.parseNameLines(stdout).includes(containerName);
|
|
480
|
+
}
|
|
442
481
|
async inspectStatus(containerName) {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
"{{.State.Running}}",
|
|
448
|
-
containerName,
|
|
449
|
-
]);
|
|
450
|
-
return stdout.trim() === "true" ? "running" : "stopped";
|
|
451
|
-
}
|
|
452
|
-
catch (err) {
|
|
453
|
-
if (isDockerNotFoundError(err))
|
|
454
|
-
return "missing";
|
|
455
|
-
throw err;
|
|
456
|
-
}
|
|
482
|
+
if (!(await this.containerExists(containerName)))
|
|
483
|
+
return "missing";
|
|
484
|
+
const { stdout } = await this.run(["inspect", "-f", "{{.State.Running}}", containerName]);
|
|
485
|
+
return stdout.trim() === "true" ? "running" : "stopped";
|
|
457
486
|
}
|
|
458
487
|
async listContainerNamesByLabel() {
|
|
459
488
|
try {
|
|
460
|
-
const { stdout } = await this.
|
|
489
|
+
const { stdout } = await this.run([
|
|
461
490
|
"ps",
|
|
462
491
|
"-a",
|
|
463
492
|
"--filter",
|
|
@@ -482,16 +511,17 @@ export class DockerContainerManager {
|
|
|
482
511
|
}
|
|
483
512
|
async inspectContainerDetails(containerName) {
|
|
484
513
|
try {
|
|
485
|
-
const { stdout } = await this.
|
|
514
|
+
const { stdout } = await this.run([
|
|
486
515
|
"inspect",
|
|
487
516
|
"-f",
|
|
488
|
-
`{{.State.Running}}\t{{.State.StartedAt}}\t{{json .
|
|
517
|
+
`{{.State.Running}}\t{{.State.StartedAt}}\t{{json .Mounts}}`,
|
|
489
518
|
containerName,
|
|
490
519
|
]);
|
|
491
|
-
const [runningRaw, startedAtRaw,
|
|
520
|
+
const [runningRaw, startedAtRaw, mountsRaw] = stdout.trim().split("\t");
|
|
492
521
|
const running = runningRaw === "true";
|
|
493
|
-
const startedAtMs = this.
|
|
494
|
-
|
|
522
|
+
const startedAtMs = parseEngineTimestamp(this.normalizeDockerValue(startedAtRaw));
|
|
523
|
+
const bindSources = parseBindMounts(mountsRaw ?? "", containerName).map((mount) => mount.source);
|
|
524
|
+
return { running, startedAtMs, bindSources };
|
|
495
525
|
}
|
|
496
526
|
catch (err) {
|
|
497
527
|
log.logWarning(`Failed to inspect container ${containerName} during reconcile`, errorMessage(err));
|
|
@@ -504,13 +534,6 @@ export class DockerContainerManager {
|
|
|
504
534
|
const trimmed = value.trim();
|
|
505
535
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
506
536
|
}
|
|
507
|
-
parseDockerTimestamp(value) {
|
|
508
|
-
const normalized = this.normalizeDockerValue(value);
|
|
509
|
-
if (!normalized || normalized.startsWith("0001-"))
|
|
510
|
-
return undefined;
|
|
511
|
-
const parsed = Date.parse(normalized);
|
|
512
|
-
return Number.isNaN(parsed) ? undefined : parsed;
|
|
513
|
-
}
|
|
514
537
|
containerKeyFromContainerName(containerName) {
|
|
515
538
|
const prefix = DockerContainerManager.containerName("");
|
|
516
539
|
if (!containerName.startsWith(prefix))
|
|
@@ -520,26 +543,51 @@ export class DockerContainerManager {
|
|
|
520
543
|
}
|
|
521
544
|
async forceRemoveContainer(containerName, successLog, failureLog) {
|
|
522
545
|
try {
|
|
523
|
-
await this.
|
|
546
|
+
if (await this.containerExists(containerName)) {
|
|
547
|
+
await this.run(["kill", containerName]).catch(() => undefined);
|
|
548
|
+
await this.run(["rm", "-f", containerName]);
|
|
549
|
+
}
|
|
524
550
|
log.logInfo(successLog);
|
|
525
551
|
return true;
|
|
526
552
|
}
|
|
527
553
|
catch (err) {
|
|
528
|
-
|
|
529
|
-
if (/no such container/i.test(message))
|
|
530
|
-
return true;
|
|
531
|
-
log.logWarning(failureLog, message);
|
|
554
|
+
log.logWarning(failureLog, errorMessage(err));
|
|
532
555
|
return false;
|
|
533
556
|
}
|
|
534
557
|
}
|
|
535
558
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
559
|
+
const CPU_PERIOD_MICROSECONDS = 100_000;
|
|
560
|
+
function cpuQuotaArgs(cpus) {
|
|
561
|
+
const quota = Math.round(Number(cpus) * CPU_PERIOD_MICROSECONDS);
|
|
562
|
+
if (!Number.isFinite(quota) || quota <= 0)
|
|
563
|
+
throw new Error(`Invalid CPU limit: ${cpus}`);
|
|
564
|
+
return ["--cpu-period", String(CPU_PERIOD_MICROSECONDS), "--cpu-quota", String(quota)];
|
|
565
|
+
}
|
|
566
|
+
function parseBindMounts(raw, containerName) {
|
|
567
|
+
const payload = raw.trim();
|
|
568
|
+
const parsed = JSON.parse(payload.length > 0 ? payload : "null");
|
|
569
|
+
if (parsed === null)
|
|
539
570
|
return [];
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
571
|
+
if (!Array.isArray(parsed)) {
|
|
572
|
+
throw new Error(`Unexpected mount payload for container "${containerName}"`);
|
|
573
|
+
}
|
|
574
|
+
return parsed.flatMap((entry) => {
|
|
575
|
+
if (!isRecord(entry) || entry.Type !== "bind")
|
|
576
|
+
return [];
|
|
577
|
+
const { Source: source, Destination: target, RW: writable } = entry;
|
|
578
|
+
if (typeof source !== "string" || typeof target !== "string")
|
|
579
|
+
return [];
|
|
580
|
+
return [{ source, target, readOnly: writable === false }];
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
const ENGINE_LOCAL_TIMESTAMP = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?) ([+-]\d{2})(\d{2})/;
|
|
584
|
+
function parseEngineTimestamp(value) {
|
|
585
|
+
if (!value || value.startsWith("0001-"))
|
|
586
|
+
return undefined;
|
|
587
|
+
const local = ENGINE_LOCAL_TIMESTAMP.exec(value);
|
|
588
|
+
const iso = local ? `${local[1]}T${local[2]}${local[3]}:${local[4]}` : value;
|
|
589
|
+
const parsed = Date.parse(iso);
|
|
590
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
543
591
|
}
|
|
544
592
|
function mountsWithin(sources, root) {
|
|
545
593
|
return sources.some((source) => {
|