@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.
@@ -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
- export declare function dockerRm(containerId: string): Promise<void>;
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
- await dockerRm(containerId);
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
- export async function dockerRm(containerId) {
115
- const proc = Bun.spawn(["docker", "rm", "-f", containerId], { stdout: "pipe", stderr: "pipe" });
116
- await proc.exited;
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
@@ -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): Promise<Manifest>;
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
- const r = await fetch(`${baseUrl}/manifest.json`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-sdk",
3
- "version": "0.1.58",
3
+ "version": "0.1.59",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -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. */
@@ -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 isRunning()
303
- * reports it down rather than pointing at a container that never started. */
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,
@@ -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
- if (containerId)
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
- if (containerId)
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
- if (containerId)
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
- if (containerId)
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
- if (containerId)
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
- if (containerId)
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
- if (containerId)
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
- if (containerId)
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
- if (containerId)
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.containerOps.remove(containerId);
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
- if (containerId)
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
- // Already gone (crashed / --rm reaped) — nothing to stop.
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 isRunning()
545
- * reports it down rather than pointing at a container that never started. */
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
- // Already gone (crashed / --rm reaped) — nothing to stop.
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));