@norskvideo/ctl-sdk 0.1.58 → 0.1.59
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/docker-runner.d.ts +5 -1
- package/docker-runner.js +21 -5
- package/manifest-fetch.d.ts +7 -1
- package/manifest-fetch.js +13 -4
- package/package.json +1 -1
- package/product-error.d.ts +2 -2
- package/product-service.d.ts +9 -3
- package/product-service.js +44 -29
package/docker-runner.d.ts
CHANGED
|
@@ -58,7 +58,11 @@ export declare function dockerRun(image: string, opts?: ProductRunOpts): Promise
|
|
|
58
58
|
/** The container's address on its first network (the default bridge for a
|
|
59
59
|
* product container), or undefined when docker cannot say. */
|
|
60
60
|
export declare function dockerAddress(containerId: string): Promise<string | undefined>;
|
|
61
|
-
|
|
61
|
+
/** `docker rm -f`, rejecting with DOCKER_RM_FAILED unless the container is
|
|
62
|
+
* gone afterwards. Callers drop a registration once this resolves, so a
|
|
63
|
+
* refused rm must not read as success: the container would keep running with
|
|
64
|
+
* nothing tracking it. Bounded so a wedged dockerd cannot hang shutdown. */
|
|
65
|
+
export declare function dockerRm(containerId: string, timeoutMs?: number): Promise<void>;
|
|
62
66
|
/** Best-effort rename of a running container. The product is tracked by
|
|
63
67
|
* container id, never by name, so a rename failure (e.g. a stale container
|
|
64
68
|
* already holding the name) is non-fatal — the product still works, it just
|
package/docker-runner.js
CHANGED
|
@@ -95,8 +95,9 @@ export async function dockerRun(image, opts = {}) {
|
|
|
95
95
|
const hostPort = await dockerPublishedPort(containerId);
|
|
96
96
|
if (hostPort === undefined) {
|
|
97
97
|
// Nothing to fall back to: the port is docker's to report, and a
|
|
98
|
-
// registration without one cannot be dialled or restarted.
|
|
99
|
-
|
|
98
|
+
// registration without one cannot be dialled or restarted. The rm is
|
|
99
|
+
// best-effort so its failure cannot replace this one.
|
|
100
|
+
await dockerRm(containerId).catch(() => undefined);
|
|
100
101
|
throw new ProductError("DOCKER_RUN_FAILED", `docker did not report a published host port for ${CONTAINER_INTERNAL_PORT}/tcp`);
|
|
101
102
|
}
|
|
102
103
|
return { containerId, hostPort };
|
|
@@ -111,9 +112,24 @@ export async function dockerAddress(containerId) {
|
|
|
111
112
|
return undefined;
|
|
112
113
|
return stdout.split(/\s+/)[0];
|
|
113
114
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
const DOCKER_RM_TIMEOUT_MS = 30_000;
|
|
116
|
+
/** `docker rm -f`, rejecting with DOCKER_RM_FAILED unless the container is
|
|
117
|
+
* gone afterwards. Callers drop a registration once this resolves, so a
|
|
118
|
+
* refused rm must not read as success: the container would keep running with
|
|
119
|
+
* nothing tracking it. Bounded so a wedged dockerd cannot hang shutdown. */
|
|
120
|
+
export async function dockerRm(containerId, timeoutMs = DOCKER_RM_TIMEOUT_MS) {
|
|
121
|
+
const proc = Bun.spawn(["docker", "rm", "-f", containerId], { stdout: "pipe", stderr: "pipe", timeout: timeoutMs });
|
|
122
|
+
const exitCode = await proc.exited;
|
|
123
|
+
const stderr = (await new Response(proc.stderr).text()).trim();
|
|
124
|
+
if (exitCode === 0)
|
|
125
|
+
return;
|
|
126
|
+
// Current docker exits 0 for a missing container under -f; older ones do not.
|
|
127
|
+
if (/no such container/i.test(stderr))
|
|
128
|
+
return;
|
|
129
|
+
if (proc.signalCode !== null) {
|
|
130
|
+
throw new ProductError("DOCKER_RM_FAILED", `docker rm ${containerId} did not finish within ${timeoutMs}ms`);
|
|
131
|
+
}
|
|
132
|
+
throw new ProductError("DOCKER_RM_FAILED", `docker rm ${containerId} failed (exit ${exitCode}): ${stderr || "no stderr"}`);
|
|
117
133
|
}
|
|
118
134
|
/** Best-effort rename of a running container. The product is tracked by
|
|
119
135
|
* container id, never by name, so a rename failure (e.g. a stale container
|
package/manifest-fetch.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { type Manifest } from "./manifest-schema.js";
|
|
2
2
|
import type { ProductRegistration, ProductSpec } from "./product-types.js";
|
|
3
|
+
/** Cap on a one-shot request to a product. Without a signal Bun waits ~360 s
|
|
4
|
+
* on a product that accepts and never answers, all while holding the
|
|
5
|
+
* ProductService mutex. */
|
|
6
|
+
export declare const PRODUCT_FETCH_TIMEOUT_MS = 30000;
|
|
3
7
|
/**
|
|
4
8
|
* Fast liveness probe for a registered product. A dev-mode product is
|
|
5
9
|
* externally owned, so the only way to know it's actually up is to ask it.
|
|
@@ -16,7 +20,9 @@ export declare function specBaseUrl(spec: ProductSpec, port?: number, reachHost?
|
|
|
16
20
|
/** specBaseUrl for a registration — the one call every dial site should make,
|
|
17
21
|
* so a recorded reachHost is never forgotten. */
|
|
18
22
|
export declare function productBaseUrl(reg: Pick<ProductRegistration, "spec" | "port" | "reachHost">): string;
|
|
19
|
-
export declare function fetchManifest(baseUrl: string
|
|
23
|
+
export declare function fetchManifest(baseUrl: string, opts?: {
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
}): Promise<Manifest>;
|
|
20
26
|
/** Poll a product's manifest until it answers.
|
|
21
27
|
*
|
|
22
28
|
* The timeout names `baseUrl`. That is the whole point of the message: the
|
package/manifest-fetch.js
CHANGED
|
@@ -3,7 +3,12 @@ import { ManifestSchema } from "./manifest-schema.js";
|
|
|
3
3
|
import { ProductError } from "./product-error.js";
|
|
4
4
|
const READINESS_TIMEOUT_MS = 60_000;
|
|
5
5
|
const READINESS_INTERVAL_MS = 250;
|
|
6
|
+
const READINESS_ATTEMPT_TIMEOUT_MS = 5_000;
|
|
6
7
|
const LIVENESS_TIMEOUT_MS = 3_000;
|
|
8
|
+
/** Cap on a one-shot request to a product. Without a signal Bun waits ~360 s
|
|
9
|
+
* on a product that accepts and never answers, all while holding the
|
|
10
|
+
* ProductService mutex. */
|
|
11
|
+
export const PRODUCT_FETCH_TIMEOUT_MS = 30_000;
|
|
7
12
|
/**
|
|
8
13
|
* Fast liveness probe for a registered product. A dev-mode product is
|
|
9
14
|
* externally owned, so the only way to know it's actually up is to ask it.
|
|
@@ -38,10 +43,12 @@ export function specBaseUrl(spec, port, reachHost) {
|
|
|
38
43
|
export function productBaseUrl(reg) {
|
|
39
44
|
return specBaseUrl(reg.spec, reg.port, reg.reachHost);
|
|
40
45
|
}
|
|
41
|
-
export async function fetchManifest(baseUrl) {
|
|
46
|
+
export async function fetchManifest(baseUrl, opts = {}) {
|
|
42
47
|
let response;
|
|
43
48
|
try {
|
|
44
|
-
response = await fetch(`${baseUrl}/manifest.json
|
|
49
|
+
response = await fetch(`${baseUrl}/manifest.json`, {
|
|
50
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? PRODUCT_FETCH_TIMEOUT_MS),
|
|
51
|
+
});
|
|
45
52
|
}
|
|
46
53
|
catch (e) {
|
|
47
54
|
throw new ProductError("MANIFEST_FETCH_FAILED", `manifest fetch failed: ${String(e)}`);
|
|
@@ -70,7 +77,9 @@ export async function waitForReady(baseUrl, opts = {}) {
|
|
|
70
77
|
const deadline = Date.now() + timeoutMs;
|
|
71
78
|
while (Date.now() < deadline) {
|
|
72
79
|
try {
|
|
73
|
-
|
|
80
|
+
// One stalled attempt must not outlive the deadline.
|
|
81
|
+
const attemptMs = Math.max(1, Math.min(READINESS_ATTEMPT_TIMEOUT_MS, deadline - Date.now()));
|
|
82
|
+
const r = await fetch(`${baseUrl}/manifest.json`, { signal: AbortSignal.timeout(attemptMs) });
|
|
74
83
|
if (r.ok)
|
|
75
84
|
return;
|
|
76
85
|
}
|
|
@@ -101,7 +110,7 @@ export async function probeConfigScreen(baseUrl, configScreenUrl, headers = {})
|
|
|
101
110
|
const url = `${baseUrl}${path}`;
|
|
102
111
|
let response;
|
|
103
112
|
try {
|
|
104
|
-
response = await fetch(url, { redirect: "follow", headers });
|
|
113
|
+
response = await fetch(url, { redirect: "follow", headers, signal: AbortSignal.timeout(PRODUCT_FETCH_TIMEOUT_MS) });
|
|
105
114
|
}
|
|
106
115
|
catch (e) {
|
|
107
116
|
throw new ProductError("CONFIG_SCREEN_UNREACHABLE", `configScreenUrl '${path}' at ${baseUrl} unreachable: ${String(e)}`);
|
package/package.json
CHANGED
package/product-error.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
export declare class ProductError extends Error {
|
|
2
|
-
code: "DEV_URL_INVALID" | "DEV_URL_NOT_LOCALHOST" | "PORT_EXHAUSTED" | "DOCKER_PULL_FAILED" | "DOCKER_RUN_FAILED" | "READINESS_TIMEOUT" | "MANIFEST_FETCH_FAILED" | "MANIFEST_INVALID" | "CONFIG_SCREEN_UNREACHABLE" | "CONFIG_SCREEN_DEV_SERVER" | "LICENSE_INVALID" | "NAME_CONFLICT" | "NOT_FOUND" | "NOT_RESTARTABLE" | "RUNNER_TOO_OLD" | "PRODUCT_TEMPLATE_FETCH_FAILED"
|
|
2
|
+
code: "DEV_URL_INVALID" | "DEV_URL_NOT_LOCALHOST" | "PORT_EXHAUSTED" | "DOCKER_PULL_FAILED" | "DOCKER_RUN_FAILED" | "DOCKER_RM_FAILED" | "READINESS_TIMEOUT" | "MANIFEST_FETCH_FAILED" | "MANIFEST_INVALID" | "CONFIG_SCREEN_UNREACHABLE" | "CONFIG_SCREEN_DEV_SERVER" | "LICENSE_INVALID" | "NAME_CONFLICT" | "NOT_FOUND" | "NOT_RESTARTABLE" | "RUNNER_TOO_OLD" | "PRODUCT_TEMPLATE_FETCH_FAILED"
|
|
3
3
|
/** Two products declare one bare role name with different `defaultFor`. */
|
|
4
4
|
| "ROLE_CONFLICT"
|
|
5
5
|
/** The host's custom role names could not be read to check for a clash. */
|
|
6
6
|
| "ROLE_NAMES_UNREADABLE";
|
|
7
|
-
constructor(code: "DEV_URL_INVALID" | "DEV_URL_NOT_LOCALHOST" | "PORT_EXHAUSTED" | "DOCKER_PULL_FAILED" | "DOCKER_RUN_FAILED" | "READINESS_TIMEOUT" | "MANIFEST_FETCH_FAILED" | "MANIFEST_INVALID" | "CONFIG_SCREEN_UNREACHABLE" | "CONFIG_SCREEN_DEV_SERVER" | "LICENSE_INVALID" | "NAME_CONFLICT" | "NOT_FOUND" | "NOT_RESTARTABLE" | "RUNNER_TOO_OLD" | "PRODUCT_TEMPLATE_FETCH_FAILED"
|
|
7
|
+
constructor(code: "DEV_URL_INVALID" | "DEV_URL_NOT_LOCALHOST" | "PORT_EXHAUSTED" | "DOCKER_PULL_FAILED" | "DOCKER_RUN_FAILED" | "DOCKER_RM_FAILED" | "READINESS_TIMEOUT" | "MANIFEST_FETCH_FAILED" | "MANIFEST_INVALID" | "CONFIG_SCREEN_UNREACHABLE" | "CONFIG_SCREEN_DEV_SERVER" | "LICENSE_INVALID" | "NAME_CONFLICT" | "NOT_FOUND" | "NOT_RESTARTABLE" | "RUNNER_TOO_OLD" | "PRODUCT_TEMPLATE_FETCH_FAILED"
|
|
8
8
|
/** Two products declare one bare role name with different `defaultFor`. */
|
|
9
9
|
| "ROLE_CONFLICT"
|
|
10
10
|
/** The host's custom role names could not be read to check for a clash. */
|
package/product-service.d.ts
CHANGED
|
@@ -245,6 +245,9 @@ export declare class ProductService {
|
|
|
245
245
|
/** The address to record for a freshly started container: its own, when
|
|
246
246
|
* this daemon cannot dial the host loopback; otherwise nothing. */
|
|
247
247
|
private reachHostFor;
|
|
248
|
+
/** Remove a container on a failure path. Best-effort: the caller is already
|
|
249
|
+
* failing for its own reason, which a refused rm must not replace. */
|
|
250
|
+
private discard;
|
|
248
251
|
private readonly onRolesDeclared?;
|
|
249
252
|
private readonly onRemoved?;
|
|
250
253
|
private readonly onChanged?;
|
|
@@ -292,15 +295,18 @@ export declare class ProductService {
|
|
|
292
295
|
* left alone. Clearing the id keeps the persisted store honest: a stale id
|
|
293
296
|
* left behind would make isRunning() point at a container `--rm` reaped.
|
|
294
297
|
* Best-effort per product — a failing `docker rm` is logged, never thrown,
|
|
295
|
-
* so one stubborn container can't block a clean shutdown.
|
|
298
|
+
* so one stubborn container can't block a clean shutdown. Its id is kept:
|
|
299
|
+
* the container may still be running, and the next boot's restoreAll
|
|
300
|
+
* removes a recorded id before relaunching. */
|
|
296
301
|
stopAll(): Promise<void>;
|
|
297
302
|
private stopAllSerialised;
|
|
298
303
|
/** Relaunch every container-kind product recorded in the store, refreshing
|
|
299
304
|
* its containerId (model B: called once at daemon boot to re-create the
|
|
300
305
|
* control planes stopped on the previous shutdown). Dev-kind products are
|
|
301
306
|
* externally owned — skipped. Best-effort per product: one that fails to
|
|
302
|
-
* come up is logged and left with its containerId cleared, so
|
|
303
|
-
* reports it down rather than pointing at a container that
|
|
307
|
+
* come up is logged, removed, and left with its containerId cleared, so
|
|
308
|
+
* isRunning() reports it down rather than pointing at a container that
|
|
309
|
+
* never started. */
|
|
304
310
|
restoreAll(): Promise<void>;
|
|
305
311
|
private restoreAllSerialised;
|
|
306
312
|
/** Stop (if still present) and relaunch a single container-kind product,
|
package/product-service.js
CHANGED
|
@@ -4,7 +4,7 @@ import { validateDevUrl } from "./dev-url.js";
|
|
|
4
4
|
import { dockerAddress, dockerPull, dockerRename, dockerRm, dockerRun, isDigestRef, productContainerName, } from "./docker-runner.js";
|
|
5
5
|
import { resolveLicenseFile } from "./license-registration.js";
|
|
6
6
|
import { checkProductEntitlement, notV2EnvelopeMessage, parseLicenseContents } from "./license-v2.js";
|
|
7
|
-
import { fetchManifest, isDevUrlAlive, probeConfigScreen, productBaseUrl, specBaseUrl, waitForReady, } from "./manifest-fetch.js";
|
|
7
|
+
import { fetchManifest, isDevUrlAlive, PRODUCT_FETCH_TIMEOUT_MS, probeConfigScreen, productBaseUrl, specBaseUrl, waitForReady, } from "./manifest-fetch.js";
|
|
8
8
|
import { ProductError } from "./product-error.js";
|
|
9
9
|
import { detectProductReach } from "./product-reach.js";
|
|
10
10
|
import { checkRequiredCapabilities } from "./runner-capabilities.js";
|
|
@@ -16,7 +16,7 @@ async function fetchProductTemplateBytes(baseUrl, url, headers = {}) {
|
|
|
16
16
|
const full = `${baseUrl.replace(/\/$/, "")}/${url.replace(/^\//, "")}`;
|
|
17
17
|
let response;
|
|
18
18
|
try {
|
|
19
|
-
response = await fetch(full, { headers });
|
|
19
|
+
response = await fetch(full, { headers, signal: AbortSignal.timeout(PRODUCT_FETCH_TIMEOUT_MS) });
|
|
20
20
|
}
|
|
21
21
|
catch (e) {
|
|
22
22
|
throw new ProductError("PRODUCT_TEMPLATE_FETCH_FAILED", `fetch ${full} threw: ${String(e)}`);
|
|
@@ -102,6 +102,15 @@ export class ProductService {
|
|
|
102
102
|
return undefined;
|
|
103
103
|
return this.containerOps.address(containerId);
|
|
104
104
|
}
|
|
105
|
+
/** Remove a container on a failure path. Best-effort: the caller is already
|
|
106
|
+
* failing for its own reason, which a refused rm must not replace. */
|
|
107
|
+
async discard(containerId) {
|
|
108
|
+
if (!containerId)
|
|
109
|
+
return;
|
|
110
|
+
await this.containerOps.remove(containerId).catch((e) => {
|
|
111
|
+
logger.warn(`Container ${containerId} left running — ${e instanceof Error ? e.message : String(e)}`);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
105
114
|
onRolesDeclared;
|
|
106
115
|
onRemoved;
|
|
107
116
|
onChanged;
|
|
@@ -202,30 +211,25 @@ export class ProductService {
|
|
|
202
211
|
await this.containerOps.waitForReady(baseUrl);
|
|
203
212
|
}
|
|
204
213
|
catch (e) {
|
|
205
|
-
|
|
206
|
-
await this.containerOps.remove(containerId);
|
|
214
|
+
await this.discard(containerId);
|
|
207
215
|
throw e;
|
|
208
216
|
}
|
|
209
217
|
const manifest = await fetchManifest(baseUrl).catch(async (e) => {
|
|
210
|
-
|
|
211
|
-
await this.containerOps.remove(containerId);
|
|
218
|
+
await this.discard(containerId);
|
|
212
219
|
throw e;
|
|
213
220
|
});
|
|
214
221
|
const refusal = this.refuseUnknownCapabilities(manifest);
|
|
215
222
|
if (refusal) {
|
|
216
|
-
|
|
217
|
-
await this.containerOps.remove(containerId);
|
|
223
|
+
await this.discard(containerId);
|
|
218
224
|
throw refusal;
|
|
219
225
|
}
|
|
220
226
|
if (existing.some((p) => p.name === manifest.name)) {
|
|
221
|
-
|
|
222
|
-
await this.containerOps.remove(containerId);
|
|
227
|
+
await this.discard(containerId);
|
|
223
228
|
throw new ProductError("NAME_CONFLICT", `product '${manifest.name}' already registered`);
|
|
224
229
|
}
|
|
225
230
|
const roleConflict = this.refuseRoleConflicts(manifest, existing);
|
|
226
231
|
if (roleConflict) {
|
|
227
|
-
|
|
228
|
-
await this.containerOps.remove(containerId);
|
|
232
|
+
await this.discard(containerId);
|
|
229
233
|
throw roleConflict;
|
|
230
234
|
}
|
|
231
235
|
const warnings = [];
|
|
@@ -241,8 +245,7 @@ export class ProductService {
|
|
|
241
245
|
const resolved = resolveLicenseFile(opts.license.file, this.stageLicense);
|
|
242
246
|
licenseFile = resolved.file;
|
|
243
247
|
if (resolved.fatal) {
|
|
244
|
-
|
|
245
|
-
await this.containerOps.remove(containerId);
|
|
248
|
+
await this.discard(containerId);
|
|
246
249
|
throw new ProductError("LICENSE_INVALID", resolved.fatal);
|
|
247
250
|
}
|
|
248
251
|
if (resolved.warning)
|
|
@@ -267,20 +270,17 @@ export class ProductService {
|
|
|
267
270
|
}
|
|
268
271
|
const parsed = contents !== undefined ? parseLicenseContents(contents) : undefined;
|
|
269
272
|
if (parsed?.kind === "invalid-v2") {
|
|
270
|
-
|
|
271
|
-
await this.containerOps.remove(containerId);
|
|
273
|
+
await this.discard(containerId);
|
|
272
274
|
throw new ProductError("LICENSE_INVALID", parsed.reason);
|
|
273
275
|
}
|
|
274
276
|
if (parsed?.kind === "legacy") {
|
|
275
|
-
|
|
276
|
-
await this.containerOps.remove(containerId);
|
|
277
|
+
await this.discard(containerId);
|
|
277
278
|
throw new ProductError("LICENSE_INVALID", notV2EnvelopeMessage("V1 licenses are no longer accepted at registration"));
|
|
278
279
|
}
|
|
279
280
|
if (parsed?.kind === "v2") {
|
|
280
281
|
const check = checkProductEntitlement(parsed, manifest.name, spec.kind === "container" ? spec.image : undefined);
|
|
281
282
|
if (!check.ok) {
|
|
282
|
-
|
|
283
|
-
await this.containerOps.remove(containerId);
|
|
283
|
+
await this.discard(containerId);
|
|
284
284
|
throw new ProductError("LICENSE_INVALID", check.reason);
|
|
285
285
|
}
|
|
286
286
|
warnings.push(...check.warnings);
|
|
@@ -307,7 +307,7 @@ export class ProductService {
|
|
|
307
307
|
await this.containerOps.waitForReady(baseUrl);
|
|
308
308
|
}
|
|
309
309
|
catch (e) {
|
|
310
|
-
await this.
|
|
310
|
+
await this.discard(containerId);
|
|
311
311
|
throw e;
|
|
312
312
|
}
|
|
313
313
|
}
|
|
@@ -326,8 +326,7 @@ export class ProductService {
|
|
|
326
326
|
await probeConfigScreen(baseUrl, manifest.ui.configScreenUrl, this.probeHeadersFor?.(manifest.name) ?? {});
|
|
327
327
|
}
|
|
328
328
|
catch (e) {
|
|
329
|
-
|
|
330
|
-
await this.containerOps.remove(containerId);
|
|
329
|
+
await this.discard(containerId);
|
|
331
330
|
throw e;
|
|
332
331
|
}
|
|
333
332
|
}
|
|
@@ -451,7 +450,8 @@ export class ProductService {
|
|
|
451
450
|
await this.containerOps.remove(target.containerId);
|
|
452
451
|
}
|
|
453
452
|
catch {
|
|
454
|
-
//
|
|
453
|
+
// A gone container already resolves, so this is docker refusing; a
|
|
454
|
+
// product left down is worse than a stray old container.
|
|
455
455
|
}
|
|
456
456
|
}
|
|
457
457
|
const { containerId, hostPort } = await this.containerOps.run(target.spec.image, this.runEnvFor(name, target.spec.env, target.manifest));
|
|
@@ -517,7 +517,9 @@ export class ProductService {
|
|
|
517
517
|
* left alone. Clearing the id keeps the persisted store honest: a stale id
|
|
518
518
|
* left behind would make isRunning() point at a container `--rm` reaped.
|
|
519
519
|
* Best-effort per product — a failing `docker rm` is logged, never thrown,
|
|
520
|
-
* so one stubborn container can't block a clean shutdown.
|
|
520
|
+
* so one stubborn container can't block a clean shutdown. Its id is kept:
|
|
521
|
+
* the container may still be running, and the next boot's restoreAll
|
|
522
|
+
* removes a recorded id before relaunching. */
|
|
521
523
|
async stopAll() {
|
|
522
524
|
return this.mutations.run(() => this.stopAllSerialised());
|
|
523
525
|
}
|
|
@@ -529,11 +531,11 @@ export class ProductService {
|
|
|
529
531
|
await Promise.all(toStop.map(async (reg) => {
|
|
530
532
|
try {
|
|
531
533
|
await this.containerOps.remove(reg.containerId);
|
|
534
|
+
stopped.add(reg.name);
|
|
532
535
|
}
|
|
533
536
|
catch (e) {
|
|
534
537
|
logger.warn(`Product '${reg.name}': failed to stop container — ${e instanceof Error ? e.message : String(e)}`);
|
|
535
538
|
}
|
|
536
|
-
stopped.add(reg.name);
|
|
537
539
|
}));
|
|
538
540
|
await this.store.update((products) => products.map((p) => (stopped.has(p.name) ? withContainerId(p, undefined) : p)));
|
|
539
541
|
}
|
|
@@ -541,8 +543,9 @@ export class ProductService {
|
|
|
541
543
|
* its containerId (model B: called once at daemon boot to re-create the
|
|
542
544
|
* control planes stopped on the previous shutdown). Dev-kind products are
|
|
543
545
|
* externally owned — skipped. Best-effort per product: one that fails to
|
|
544
|
-
* come up is logged and left with its containerId cleared, so
|
|
545
|
-
* reports it down rather than pointing at a container that
|
|
546
|
+
* come up is logged, removed, and left with its containerId cleared, so
|
|
547
|
+
* isRunning() reports it down rather than pointing at a container that
|
|
548
|
+
* never started. */
|
|
546
549
|
async restoreAll() {
|
|
547
550
|
return this.mutations.run(() => this.restoreAllSerialised());
|
|
548
551
|
}
|
|
@@ -557,8 +560,17 @@ export class ProductService {
|
|
|
557
560
|
restored.set(reg.name, undefined);
|
|
558
561
|
return;
|
|
559
562
|
}
|
|
563
|
+
// A daemon that died without stopAll leaves the recorded container
|
|
564
|
+
// running; relaunching beside it would orphan it.
|
|
565
|
+
if (reg.containerId) {
|
|
566
|
+
await this.containerOps.remove(reg.containerId).catch((e) => {
|
|
567
|
+
logger.warn(`Product '${reg.name}': previous container not removed — ${e instanceof Error ? e.message : String(e)}`);
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
let started;
|
|
560
571
|
try {
|
|
561
572
|
const { containerId, hostPort } = await this.containerOps.run(reg.spec.image, this.runEnvFor(reg.name, reg.spec.env, reg.manifest));
|
|
573
|
+
started = containerId;
|
|
562
574
|
const reachHost = await this.reachHostFor(containerId);
|
|
563
575
|
await this.containerOps.waitForReady(specBaseUrl(reg.spec, hostPort, reachHost));
|
|
564
576
|
await this.containerOps.rename(containerId, productContainerName(reg.name));
|
|
@@ -568,6 +580,8 @@ export class ProductService {
|
|
|
568
580
|
catch (e) {
|
|
569
581
|
restored.set(reg.name, undefined);
|
|
570
582
|
logger.warn(`Product '${reg.name}': failed to restore — ${e instanceof Error ? e.message : String(e)}`);
|
|
583
|
+
// Its id is cleared below, so nothing else would ever reap it.
|
|
584
|
+
await this.discard(started);
|
|
571
585
|
}
|
|
572
586
|
}));
|
|
573
587
|
await this.store.update((products) => products.map((p) => {
|
|
@@ -604,7 +618,8 @@ export class ProductService {
|
|
|
604
618
|
await this.containerOps.remove(target.containerId);
|
|
605
619
|
}
|
|
606
620
|
catch {
|
|
607
|
-
//
|
|
621
|
+
// A gone container already resolves, so this is docker refusing; a
|
|
622
|
+
// product left down is worse than a stray old container.
|
|
608
623
|
}
|
|
609
624
|
}
|
|
610
625
|
const { containerId, hostPort } = await this.containerOps.run(target.spec.image, this.runEnvFor(name, target.spec.env, target.manifest));
|