@geminixiang/mikan 1.3.0 → 1.4.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 +48 -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 +16 -1
- package/dist/sandbox/provisioner.d.ts.map +1 -1
- package/dist/sandbox/provisioner.js +173 -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 leftBehind = await this.imageLeftBehind(containerName);
|
|
78
77
|
await this.replaceContainer(containerKey, containerName, mounts, options);
|
|
79
78
|
log.logInfo(`Container ${containerName} replaced from image ${this.image}`);
|
|
79
|
+
if (leftBehind)
|
|
80
|
+
await this.removeImageIfUnused(leftBehind);
|
|
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,95 @@ 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 imageLeftBehind(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 = "", label] = stdout.trim().split("\t");
|
|
409
|
+
const imageId = this.normalizeDockerValue(label);
|
|
410
|
+
if (imageId && imageId === (await this.localImageId()))
|
|
411
|
+
return undefined;
|
|
412
|
+
if (reference !== this.image && isPublishedSandboxImage(reference))
|
|
413
|
+
return reference;
|
|
414
|
+
if (imageId && (await this.isUnnamedImage(imageId)))
|
|
415
|
+
return imageId;
|
|
416
|
+
return undefined;
|
|
385
417
|
}
|
|
386
|
-
async
|
|
418
|
+
async isUnnamedImage(imageId) {
|
|
387
419
|
try {
|
|
388
|
-
const { stdout } = await this.
|
|
389
|
-
|
|
390
|
-
"inspect",
|
|
391
|
-
"-f",
|
|
392
|
-
"{{.Id}}",
|
|
393
|
-
this.image,
|
|
394
|
-
]);
|
|
395
|
-
return this.normalizeDockerValue(stdout.trim());
|
|
420
|
+
const { stdout } = await this.run(["image", "inspect", "-f", "{{len .RepoTags}}", imageId]);
|
|
421
|
+
return stdout.trim() === "0";
|
|
396
422
|
}
|
|
397
423
|
catch {
|
|
398
|
-
return
|
|
424
|
+
return false;
|
|
399
425
|
}
|
|
400
426
|
}
|
|
427
|
+
async removeImageIfUnused(image) {
|
|
428
|
+
try {
|
|
429
|
+
await this.run(["rmi", image]);
|
|
430
|
+
log.logInfo(`Removed sandbox image ${image}, which no container uses`);
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
async pulledImageId() {
|
|
437
|
+
await this.imagePull;
|
|
438
|
+
const local = await this.localImageId();
|
|
439
|
+
if (local)
|
|
440
|
+
return local;
|
|
441
|
+
await this.run(["pull", this.image]);
|
|
442
|
+
const pulled = await this.localImageId();
|
|
443
|
+
if (!pulled)
|
|
444
|
+
throw new Error(`Image ${this.image} has no ID after pulling it`);
|
|
445
|
+
return pulled;
|
|
446
|
+
}
|
|
401
447
|
async replaceContainer(containerKey, containerName, mounts, options) {
|
|
402
448
|
const removed = await this.forceRemoveContainer(containerName, `Container ${containerName} removed for replacement`, `Failed to remove container ${containerName} for replacement`);
|
|
403
449
|
if (!removed)
|
|
@@ -406,25 +452,21 @@ export class DockerContainerManager {
|
|
|
406
452
|
}
|
|
407
453
|
async hasNetworkModeDrift(containerKey, containerName) {
|
|
408
454
|
const expected = DockerContainerManager.networkName(containerKey);
|
|
409
|
-
|
|
410
|
-
"inspect",
|
|
411
|
-
"-f",
|
|
412
|
-
"{{.HostConfig.NetworkMode}}",
|
|
413
|
-
containerName,
|
|
414
|
-
]);
|
|
415
|
-
return stdout.trim() !== expected;
|
|
455
|
+
return (await this.label(containerName, DockerContainerManager.NETWORK_LABEL_KEY)) !== expected;
|
|
416
456
|
}
|
|
417
457
|
async ensureNetwork(containerKey) {
|
|
418
458
|
const networkName = DockerContainerManager.networkName(containerKey);
|
|
419
|
-
|
|
420
|
-
|
|
459
|
+
const { stdout } = await this.run([
|
|
460
|
+
"network",
|
|
461
|
+
"ls",
|
|
462
|
+
"--filter",
|
|
463
|
+
`name=^${networkName}$`,
|
|
464
|
+
"--format",
|
|
465
|
+
"{{.Name}}",
|
|
466
|
+
]);
|
|
467
|
+
if (this.parseNameLines(stdout).includes(networkName))
|
|
421
468
|
return networkName;
|
|
422
|
-
|
|
423
|
-
catch (err) {
|
|
424
|
-
if (!isDockerNotFoundError(err))
|
|
425
|
-
throw err;
|
|
426
|
-
}
|
|
427
|
-
await this.execFileImpl("docker", [
|
|
469
|
+
await this.run([
|
|
428
470
|
"network",
|
|
429
471
|
"create",
|
|
430
472
|
"--driver",
|
|
@@ -439,25 +481,26 @@ export class DockerContainerManager {
|
|
|
439
481
|
]);
|
|
440
482
|
return networkName;
|
|
441
483
|
}
|
|
484
|
+
async containerExists(containerName) {
|
|
485
|
+
const { stdout } = await this.run([
|
|
486
|
+
"ps",
|
|
487
|
+
"-a",
|
|
488
|
+
"--filter",
|
|
489
|
+
`name=^${containerName}$`,
|
|
490
|
+
"--format",
|
|
491
|
+
"{{.Names}}",
|
|
492
|
+
]);
|
|
493
|
+
return this.parseNameLines(stdout).includes(containerName);
|
|
494
|
+
}
|
|
442
495
|
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
|
-
}
|
|
496
|
+
if (!(await this.containerExists(containerName)))
|
|
497
|
+
return "missing";
|
|
498
|
+
const { stdout } = await this.run(["inspect", "-f", "{{.State.Running}}", containerName]);
|
|
499
|
+
return stdout.trim() === "true" ? "running" : "stopped";
|
|
457
500
|
}
|
|
458
501
|
async listContainerNamesByLabel() {
|
|
459
502
|
try {
|
|
460
|
-
const { stdout } = await this.
|
|
503
|
+
const { stdout } = await this.run([
|
|
461
504
|
"ps",
|
|
462
505
|
"-a",
|
|
463
506
|
"--filter",
|
|
@@ -482,16 +525,17 @@ export class DockerContainerManager {
|
|
|
482
525
|
}
|
|
483
526
|
async inspectContainerDetails(containerName) {
|
|
484
527
|
try {
|
|
485
|
-
const { stdout } = await this.
|
|
528
|
+
const { stdout } = await this.run([
|
|
486
529
|
"inspect",
|
|
487
530
|
"-f",
|
|
488
|
-
`{{.State.Running}}\t{{.State.StartedAt}}\t{{json .
|
|
531
|
+
`{{.State.Running}}\t{{.State.StartedAt}}\t{{json .Mounts}}`,
|
|
489
532
|
containerName,
|
|
490
533
|
]);
|
|
491
|
-
const [runningRaw, startedAtRaw,
|
|
534
|
+
const [runningRaw, startedAtRaw, mountsRaw] = stdout.trim().split("\t");
|
|
492
535
|
const running = runningRaw === "true";
|
|
493
|
-
const startedAtMs = this.
|
|
494
|
-
|
|
536
|
+
const startedAtMs = parseEngineTimestamp(this.normalizeDockerValue(startedAtRaw));
|
|
537
|
+
const bindSources = parseBindMounts(mountsRaw ?? "", containerName).map((mount) => mount.source);
|
|
538
|
+
return { running, startedAtMs, bindSources };
|
|
495
539
|
}
|
|
496
540
|
catch (err) {
|
|
497
541
|
log.logWarning(`Failed to inspect container ${containerName} during reconcile`, errorMessage(err));
|
|
@@ -504,13 +548,6 @@ export class DockerContainerManager {
|
|
|
504
548
|
const trimmed = value.trim();
|
|
505
549
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
506
550
|
}
|
|
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
551
|
containerKeyFromContainerName(containerName) {
|
|
515
552
|
const prefix = DockerContainerManager.containerName("");
|
|
516
553
|
if (!containerName.startsWith(prefix))
|
|
@@ -520,26 +557,51 @@ export class DockerContainerManager {
|
|
|
520
557
|
}
|
|
521
558
|
async forceRemoveContainer(containerName, successLog, failureLog) {
|
|
522
559
|
try {
|
|
523
|
-
await this.
|
|
560
|
+
if (await this.containerExists(containerName)) {
|
|
561
|
+
await this.run(["kill", containerName]).catch(() => undefined);
|
|
562
|
+
await this.run(["rm", "-f", containerName]);
|
|
563
|
+
}
|
|
524
564
|
log.logInfo(successLog);
|
|
525
565
|
return true;
|
|
526
566
|
}
|
|
527
567
|
catch (err) {
|
|
528
|
-
|
|
529
|
-
if (/no such container/i.test(message))
|
|
530
|
-
return true;
|
|
531
|
-
log.logWarning(failureLog, message);
|
|
568
|
+
log.logWarning(failureLog, errorMessage(err));
|
|
532
569
|
return false;
|
|
533
570
|
}
|
|
534
571
|
}
|
|
535
572
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
573
|
+
const CPU_PERIOD_MICROSECONDS = 100_000;
|
|
574
|
+
function cpuQuotaArgs(cpus) {
|
|
575
|
+
const quota = Math.round(Number(cpus) * CPU_PERIOD_MICROSECONDS);
|
|
576
|
+
if (!Number.isFinite(quota) || quota <= 0)
|
|
577
|
+
throw new Error(`Invalid CPU limit: ${cpus}`);
|
|
578
|
+
return ["--cpu-period", String(CPU_PERIOD_MICROSECONDS), "--cpu-quota", String(quota)];
|
|
579
|
+
}
|
|
580
|
+
function parseBindMounts(raw, containerName) {
|
|
581
|
+
const payload = raw.trim();
|
|
582
|
+
const parsed = JSON.parse(payload.length > 0 ? payload : "null");
|
|
583
|
+
if (parsed === null)
|
|
539
584
|
return [];
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
585
|
+
if (!Array.isArray(parsed)) {
|
|
586
|
+
throw new Error(`Unexpected mount payload for container "${containerName}"`);
|
|
587
|
+
}
|
|
588
|
+
return parsed.flatMap((entry) => {
|
|
589
|
+
if (!isRecord(entry) || entry.Type !== "bind")
|
|
590
|
+
return [];
|
|
591
|
+
const { Source: source, Destination: target, RW: writable } = entry;
|
|
592
|
+
if (typeof source !== "string" || typeof target !== "string")
|
|
593
|
+
return [];
|
|
594
|
+
return [{ source, target, readOnly: writable === false }];
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
const ENGINE_LOCAL_TIMESTAMP = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?) ([+-]\d{2})(\d{2})/;
|
|
598
|
+
function parseEngineTimestamp(value) {
|
|
599
|
+
if (!value || value.startsWith("0001-"))
|
|
600
|
+
return undefined;
|
|
601
|
+
const local = ENGINE_LOCAL_TIMESTAMP.exec(value);
|
|
602
|
+
const iso = local ? `${local[1]}T${local[2]}${local[3]}:${local[4]}` : value;
|
|
603
|
+
const parsed = Date.parse(iso);
|
|
604
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
543
605
|
}
|
|
544
606
|
function mountsWithin(sources, root) {
|
|
545
607
|
return sources.some((source) => {
|