signalk-container 1.35.0 → 1.35.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { existsSync, promises as fsp } from "node:fs";
3
- import { clampCpusToHost, fieldsRequiringRecreateForUnset, filterUnsupportedLimits, mergeResourceLimits, minimizeOverride, resourceLimitsEqual, tryLiveUpdate, } from "./resources.js";
3
+ import { clampCpusToHost, fieldsRequiringRecreateForUnset, filterUnsupportedLimits, mergeResourceLimits, minimizeOverride, resourceLimitsEqual, tryLiveUpdate, withoutDefaultMemorySwap, withoutInjectedResources, } from "./resources.js";
4
4
  import { detectRuntime, isContainerized, setDisableUserns } from "./runtime.js";
5
5
  import { setNamespace } from "./namespace.js";
6
6
  import { makeDegradationEmitter, } from "./notifications.js";
@@ -1443,7 +1443,38 @@ export default (app) => {
1443
1443
  // detection in ensureRunning recreates the container with
1444
1444
  // filteredMerged already applied) does not trigger a spurious
1445
1445
  // tryLiveUpdate against an already-correct container.
1446
- const postLimits = await getLiveResources(runtimeInfo, name);
1446
+ //
1447
+ // Provenance ladder — what was actually *requested*, so a field
1448
+ // present in the live limits that never appeared in any request is
1449
+ // recognized as a runtime artifact, not a user unset (notably the
1450
+ // `oom_score_adj` rootless Podman clamps onto a child container
1451
+ // when signalk-server's own oom_score_adj is non-zero):
1452
+ //
1453
+ // 1. Warm cache: the PRIOR ensureRunning's post-filter
1454
+ // `filteredMerged` (cached in `lastConfigs`, read into
1455
+ // `priorConfig` above before this call overwrote it). Using
1456
+ // the prior request (not the current `filteredMerged`) still
1457
+ // flags a genuine unset: when a user removes a `memory` cap
1458
+ // from `containerOverrides`, the field was in the prior
1459
+ // request and the recreate warning fires.
1460
+ // 2. Cold cache (fresh server process): the requested-resources
1461
+ // label stamped on the container at create time — durable
1462
+ // provenance that survives restarts.
1463
+ // 3. Neither (container predates the label): `undefined`, NOT
1464
+ // `{}` — an empty object would assert "nothing was ever
1465
+ // requested" and silently suppress a real stale
1466
+ // `memory`/`memorySwap` cap the current config removes.
1467
+ // `undefined` falls back to the plain current-vs-live check
1468
+ // minus runtime-injected fields, so a genuine memory unset
1469
+ // is still surfaced and an inherited oom_score_adj is not
1470
+ // treated as an unset.
1471
+ //
1472
+ // The runtime-injected fields nobody requested are left out of
1473
+ // `postLimits` altogether, so an inherited oom_score_adj alone does
1474
+ // not count as a difference and send a live update on every start.
1475
+ const createdWith = await getRequestedResources(name);
1476
+ const priorRequested = priorConfig?.resources ?? createdWith;
1477
+ const postLimits = withoutInjectedResources(withoutDefaultMemorySwap(await getLiveResources(runtimeInfo, name), filteredMerged, createdWith), filteredMerged, priorRequested);
1447
1478
  if (!resourceLimitsEqual(postLimits, filteredMerged)) {
1448
1479
  const fullName = prefixedName(name);
1449
1480
  // Bug E: if any field is being UNSET and it can't be unset
@@ -1452,32 +1483,6 @@ export default (app) => {
1452
1483
  // ensureRunning's "already running" branch — that would
1453
1484
  // surprise the consumer plugin. Instead, log a clear warning
1454
1485
  // pointing the user to the explicit recreate path.
1455
- //
1456
- // Provenance ladder — what was actually *requested*, so a field
1457
- // present in `postLimits` that never appeared in any request is
1458
- // recognized as a runtime artifact, not a user unset (notably the
1459
- // `oom_score_adj` rootless Podman clamps onto a child container
1460
- // when signalk-server's own oom_score_adj is non-zero):
1461
- //
1462
- // 1. Warm cache: the PRIOR ensureRunning's post-filter
1463
- // `filteredMerged` (cached in `lastConfigs`, read into
1464
- // `priorConfig` above before this call overwrote it). Using
1465
- // the prior request (not the current `filteredMerged`) still
1466
- // flags a genuine unset: when a user removes a `memory` cap
1467
- // from `containerOverrides`, the field was in the prior
1468
- // request and the recreate warning fires.
1469
- // 2. Cold cache (fresh server process): the requested-resources
1470
- // label stamped on the container at create time — durable
1471
- // provenance that survives restarts.
1472
- // 3. Neither (container predates the label): `undefined`, NOT
1473
- // `{}` — an empty object would assert "nothing was ever
1474
- // requested" and silently suppress a real stale
1475
- // `memory`/`memorySwap` cap the current config removes.
1476
- // `undefined` falls back to the plain current-vs-live check
1477
- // minus runtime-injected fields, so a genuine memory unset
1478
- // is still surfaced while the inherited oom_score_adj no
1479
- // longer warns on every server start (#216).
1480
- const priorRequested = priorConfig?.resources ?? (await getRequestedResources(name));
1481
1486
  const cannotUnset = fieldsRequiringRecreateForUnset(postLimits, filteredMerged, priorRequested);
1482
1487
  if (cannotUnset.length > 0) {
1483
1488
  app.error(`ensureRunning(${name}): cannot live-unset fields ${cannotUnset.join(", ")} on already-running container. ` +
@@ -1485,12 +1490,6 @@ export default (app) => {
1485
1490
  `Use POST /plugins/signalk-container/api/containers/${name}/resources to force a recreate.`);
1486
1491
  // Still try to apply the OTHER (settable) fields via live update.
1487
1492
  }
1488
- else if (priorRequested === undefined &&
1489
- postLimits.oomScoreAdj != null &&
1490
- filteredMerged.oomScoreAdj == null) {
1491
- app.debug(`ensureRunning(${name}): ignoring live oomScoreAdj=${postLimits.oomScoreAdj} with no provenance — ` +
1492
- `runtime-injected (rootless podman inherits the server's oom_score_adj), not a consumer request`);
1493
- }
1494
1493
  const live = await tryLiveUpdate(runtimeInfo, fullName, filteredMerged, restartPolicyFor(effectiveConfig));
1495
1494
  if (!live.ok) {
1496
1495
  // Live update failed (e.g. cpuset on a host that doesn't
@@ -1609,15 +1608,21 @@ export default (app) => {
1609
1608
  if (!runtimeInfo)
1610
1609
  throw new Error("No container runtime available");
1611
1610
  // Always attempt remove. `removeContainer` is idempotent on truly-
1612
- // missing containers (stop errors are swallowed, `rm -f` returns 0),
1613
- // and api.remove's cache evictions / broker teardown are no-ops on
1614
- // a name nobody registered. No need to precondition on getState —
1615
- // that would only add a TOCTOU window before the actual remove.
1616
- // If the runtime does report a real failure, re-check state: a
1617
- // benign disappearance (someone else removed it concurrently) is
1618
- // acceptable; anything else propagates.
1611
+ // missing containers (a failed stop of a container that is gone is
1612
+ // tolerated, and `rm -f` treats not-found as success), and
1613
+ // afterContainerRemoved's cache evictions / broker teardown are
1614
+ // no-ops on a name nobody registered. No need to precondition on
1615
+ // getState — that would only add a TOCTOU window before the actual
1616
+ // remove. If the runtime does report a real failure, re-check state:
1617
+ // a benign disappearance (someone else removed it concurrently) is
1618
+ // acceptable; anything else propagates, including a graceful stop
1619
+ // the runtime refused while the container kept running. The stop is
1620
+ // graceful because the new container takes over the old one's data.
1619
1621
  try {
1620
- await api.remove(name);
1622
+ await removeContainer(runtimeInfo, name, undefined, {
1623
+ graceful: true,
1624
+ });
1625
+ afterContainerRemoved(name);
1621
1626
  }
1622
1627
  catch (err) {
1623
1628
  const now = await getContainerState(runtimeInfo, name);
@@ -1921,7 +1926,16 @@ export default (app) => {
1921
1926
  // the user's intent but the container has the old value
1922
1927
  // - manual `podman update` from outside Signal K isn't tracked
1923
1928
  // Always compare against truth.
1924
- const liveBefore = await getLiveResources(runtimeInfo, name);
1929
+ //
1930
+ // Provenance guard: only force a recreate for a field the consumer
1931
+ // actually requested before this update (plugin default ⊕ the
1932
+ // stored override). A field that shows up live purely because the
1933
+ // runtime injected it (a rootless-Podman-inherited oom_score_adj)
1934
+ // was never the user's to unset, so it is left out of `liveBefore`:
1935
+ // it neither breaks the no-op check below nor triggers a (futile,
1936
+ // on rootless) recreate when the override is cleared.
1937
+ const priorRequested = mergeResourceLimits(pluginDefault, currentOverrides[name]);
1938
+ const liveBefore = withoutInjectedResources(withoutDefaultMemorySwap(await getLiveResources(runtimeInfo, name), filteredLimits, await getRequestedResources(name)), filteredLimits, priorRequested);
1925
1939
  // No-op when the live container already matches what's being
1926
1940
  // requested. Verify existence by way of getLiveResources returning
1927
1941
  // an empty object — if liveBefore is {}, either the container is
@@ -1946,14 +1960,6 @@ export default (app) => {
1946
1960
  // update". Memory limits and oom-score-adj are the offenders —
1947
1961
  // podman/docker can lower or raise them, but not return them to
1948
1962
  // the unlimited/default state without a recreate.
1949
- //
1950
- // Provenance guard: only force a recreate for a field the consumer
1951
- // actually requested before this update (plugin default ⊕ the
1952
- // stored override). A field that shows up in `liveBefore` purely
1953
- // because the runtime injected it (a rootless-Podman-inherited
1954
- // oom_score_adj) was never the user's to unset, so clearing the
1955
- // override must not trigger a (futile, on rootless) recreate.
1956
- const priorRequested = mergeResourceLimits(pluginDefault, currentOverrides[name]);
1957
1963
  const mustRecreateForUnset = fieldsRequiringRecreateForUnset(liveBefore, filteredLimits, priorRequested);
1958
1964
  const forceRecreate = mustRecreateForUnset.length > 0;
1959
1965
  if (forceRecreate) {
@@ -2012,10 +2018,33 @@ export default (app) => {
2012
2018
  // target — it's what the consumer plugin most recently asked
2013
2019
  // for, minus our new resources. (Bug A fix.)
2014
2020
  try {
2015
- await removeContainer(runtimeInfo, name);
2021
+ await removeContainer(runtimeInfo, name, undefined, {
2022
+ graceful: true,
2023
+ });
2016
2024
  }
2017
2025
  catch (err) {
2018
- warnings.push(`remove during recreate: ${err instanceof Error ? err.message : String(err)}`);
2026
+ const removeMsg = err instanceof Error ? err.message : String(err);
2027
+ // A container that is still there keeps its old limits: resources
2028
+ // are not a drift axis, so the ensureRunning below would leave it
2029
+ // as it is and this would report a recreate that never happened.
2030
+ // When the stop went through and only the remove failed, it is
2031
+ // started again so a failed update does not leave it down.
2032
+ const state = await getContainerState(runtimeInfo, name);
2033
+ if (state !== "missing") {
2034
+ let restartNote = "";
2035
+ if (state === "stopped") {
2036
+ try {
2037
+ await startContainer(runtimeInfo, name);
2038
+ }
2039
+ catch (startErr) {
2040
+ restartNote = ` Starting it again also failed: ${startErr instanceof Error ? startErr.message : String(startErr)}`;
2041
+ }
2042
+ }
2043
+ throw new Error(`Failed to apply new resources for ${name}: ${removeMsg}. ` +
2044
+ `The container was not removed and keeps its previous limits.` +
2045
+ restartNote, { cause: err });
2046
+ }
2047
+ warnings.push(`remove during recreate: ${removeMsg}`);
2019
2048
  }
2020
2049
  // A resource-update recreate re-runs ensureRunning, which re-fires
2021
2050
  // device-passthrough events (a device may have been unplugged since