signalk-container 0.2.0 → 1.0.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/NOTICE ADDED
@@ -0,0 +1,22 @@
1
+ signalk-container
2
+ Copyright (c) 2026 Dirk Wahrheit
3
+
4
+ This product includes a third-party icon:
5
+
6
+ icon.svg — Lucide "container" icon
7
+ Source: https://lucide.dev / https://github.com/lucide-icons/lucide
8
+ License: ISC License
9
+ Copyright (c) Lucide Icons and Contributors
10
+
11
+ Permission to use, copy, modify, and/or distribute this software for
12
+ any purpose with or without fee is hereby granted, provided that the
13
+ above copyright notice and this permission notice appear in all copies.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL
16
+ WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED
17
+ WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE
18
+ AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL
19
+ DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR
20
+ PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
21
+ TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
22
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md CHANGED
@@ -13,7 +13,9 @@ Instead of each plugin implementing its own container orchestration, they delega
13
13
  - **Resource limits editor** -- interactive UI in the config panel for setting CPU/memory/PID caps per container. Values are applied live via `podman update` when possible (no downtime), falls back to recreate when needed. Stored overrides are minimized against the consumer plugin's defaults so a future default bump flows through automatically. See the [developer guide](doc/plugin-developer-guide.md#resource-limits).
14
14
  - **Reset to plugin default** -- one-click restore of a container's original resource limits, clearing any user override.
15
15
  - **Image management** -- scheduled pruning of dangling images (weekly/monthly)
16
- - **SELinux support** -- `:Z` volume flags for Podman on Fedora/RHEL
16
+ - **Zero-config data dir sharing** -- `signalkDataMount` mounts the SignalK data directory into any managed container automatically, whether Signal K runs bare-metal, in Docker (named volume), or in Podman (named volume or bind mount). No host paths to configure.
17
+ - **Zero-config container service connectivity** -- `signalkAccessiblePorts` lets the SignalK process connect back to a service running inside a managed container (e.g. an HTTP or TCP server). signalk-container picks the right networking strategy automatically — port binding on the host loopback for bare-metal deployments, or a shared Docker network with DNS for containerised ones. No host ports are exposed unnecessarily.
18
+ - **SELinux support** -- `:Z` volume flags for Podman bind mounts on Fedora/RHEL; named volumes are handled correctly (`:Z` is not applied)
17
19
  - **Podman image qualification** -- automatically prefixes `docker.io/` for short image names
18
20
  - **Cross-plugin API** -- other plugins use `globalThis.__signalk_containerManager`
19
21
 
@@ -71,12 +73,19 @@ if (!containers) {
71
73
  await containers.ensureRunning("my-service", {
72
74
  image: "myorg/myimage",
73
75
  tag: "latest",
74
- ports: { "8080/tcp": "127.0.0.1:8080" },
75
- volumes: { "/data": app.getDataDirPath() },
76
+ signalkDataMount: "/data", // resolves to the SignalK data dir, regardless of deployment
77
+ signalkAccessiblePorts: [8080], // port 8080 in the container must be reachable by SignalK
76
78
  env: { MY_VAR: "value" },
77
79
  restart: "unless-stopped",
78
80
  });
79
81
 
82
+ // Get the actual address to connect to (resolved after ensureRunning)
83
+ const addr = await containers.resolveContainerAddress("my-service", 8080);
84
+ if (!addr) throw new Error("Container address not available");
85
+ // bare-metal → "127.0.0.1:8080" (or "127.0.0.1:8081" if 8080 was taken)
86
+ // containerised → "sk-my-service:8080" (Docker DNS, no host port exposed)
87
+ const response = await fetch(`http://${addr}/status`);
88
+
80
89
  // Run a one-shot job
81
90
  const result = await containers.runJob({
82
91
  image: "myorg/converter",
@@ -91,31 +100,33 @@ See [doc/plugin-developer-guide.md](doc/plugin-developer-guide.md) for the full
91
100
 
92
101
  ## API
93
102
 
94
- | Method | Description |
95
- | --------------------------------------- | ------------------------------------------------------------ |
96
- | `getRuntime()` | Returns `{ runtime, version, isPodmanDockerShim }` or `null` |
97
- | `pullImage(image, onProgress?)` | Pull a container image (auto-qualifies for Podman) |
98
- | `imageExists(image)` | Check if image exists locally |
99
- | `getImageDigest(imageOrContainer)` | Local image ID (sha256) for an image:tag or container |
100
- | `ensureRunning(name, config, options?)` | Create and start container if not running |
101
- | `start(name)` | Start a stopped container |
102
- | `stop(name)` | Stop a running container |
103
- | `remove(name)` | Stop and remove a container |
104
- | `getState(name)` | Returns `running`, `stopped`, `missing`, or `no-runtime` |
105
- | `runJob(config)` | Execute a one-shot container job |
106
- | `prune()` | Remove dangling images |
107
- | `listContainers()` | List all `sk-` prefixed containers |
108
- | `execInContainer(name, command)` | Run a command inside a running container |
109
- | `ensureNetwork(name)` | Create a Podman/Docker network if it doesn't exist |
110
- | `removeNetwork(name)` | Remove a network |
111
- | `connectToNetwork(container, network)` | Add a container to a network (bridge mode only) |
112
- | `disconnectFromNetwork(container, net)` | Remove a container from a network |
113
- | `updates.register(reg)` | Register a container for update detection |
114
- | `updates.unregister(pluginId)` | Stop tracking updates for a plugin |
115
- | `updates.checkOne(pluginId)` | Force a fresh update check (or coalesce with in-flight) |
116
- | `updates.getLastResult(pluginId)` | Cached last result, no network |
117
- | `updateResources(name, limits)` | Apply new resource limits live, fall back to recreate |
118
- | `getResources(name)` | Currently effective limits (plugin defaults ⊕ user override) |
103
+ | Method | Description |
104
+ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
105
+ | `getRuntime()` | Returns `{ runtime, version, isPodmanDockerShim }` or `null` |
106
+ | `pullImage(image, onProgress?)` | Pull a container image (auto-qualifies for Podman) |
107
+ | `imageExists(image)` | Check if image exists locally |
108
+ | `getImageDigest(imageOrContainer)` | Local image ID (sha256) for an image:tag or container |
109
+ | `ensureRunning(name, config, options?)` | Create and start container if not running |
110
+ | `start(name)` | Start a stopped container |
111
+ | `stop(name)` | Stop a running container |
112
+ | `remove(name)` | Stop and remove a container |
113
+ | `getState(name)` | Returns `running`, `stopped`, `missing`, or `no-runtime` |
114
+ | `runJob(config)` | Execute a one-shot container job |
115
+ | `prune()` | Remove dangling images |
116
+ | `listContainers()` | List all `sk-` prefixed containers |
117
+ | `execInContainer(name, command)` | Run a command inside a running container |
118
+ | `ensureNetwork(name)` | Create a Podman/Docker network if it doesn't exist |
119
+ | `removeNetwork(name)` | Remove a network |
120
+ | `connectToNetwork(container, network)` | Add a container to a network (bridge mode only) |
121
+ | `disconnectFromNetwork(container, net)` | Remove a container from a network |
122
+ | `updates.register(reg)` | Register a container for update detection |
123
+ | `updates.unregister(pluginId)` | Stop tracking updates for a plugin |
124
+ | `updates.checkOne(pluginId)` | Force a fresh update check (or coalesce with in-flight) |
125
+ | `updates.getLastResult(pluginId)` | Cached last result, no network |
126
+ | `updateResources(name, limits)` | Apply new resource limits live, fall back to recreate |
127
+ | `getResources(name)` | Currently effective limits (plugin defaults ⊕ user override) |
128
+ | `resolveSignalkDataMount()` | Resolve the volume name or host path that backs `app.getDataDirPath()` in the current deployment; returns `null` if the runtime is not yet initialised |
129
+ | `resolveContainerAddress(name, port)` | Return the `host:port` string to reach `port` on a managed container from the SignalK process; call after `ensureRunning()` with `signalkAccessiblePorts` set |
119
130
 
120
131
  ## REST Endpoints
121
132
 
@@ -148,6 +159,88 @@ All mounted at `/plugins/signalk-container/api/`:
148
159
  | Background update checks | `true` | Periodically check for updates in the background. Disable on metered connections — manual checks via the UI button still work. |
149
160
  | Container overrides | `{}` | Per-container resource limits (CPU, memory, PIDs). Field-level merged on top of consumer plugin defaults. See dev guide. |
150
161
 
162
+ ## Mounting the SignalK data directory (`signalkDataMount`)
163
+
164
+ When a managed container needs to read or write files that Signal K also accesses (e.g. HLS segments, exports, caches), use `signalkDataMount` instead of computing and hardcoding a host path or volume name.
165
+
166
+ ```typescript
167
+ const SK_MOUNT = "/signalk-data";
168
+
169
+ await containers.ensureRunning("my-worker", {
170
+ image: "myorg/myworker",
171
+ tag: "latest",
172
+ signalkDataMount: SK_MOUNT, // ← mount the SignalK data dir here
173
+ command: ["--output", path.join(SK_MOUNT, "my-plugin/output/result.bin")],
174
+ });
175
+ ```
176
+
177
+ signalk-container resolves the correct source automatically:
178
+
179
+ | Deployment | What gets mounted |
180
+ | ------------------------------ | ----------------------------------------------------------------- |
181
+ | Bare-metal Signal K | `app.getDataDirPath()` as a bind mount (already a host path) |
182
+ | Docker, volume-backed data dir | the named volume (e.g. `mystack_signalk-data`) |
183
+ | Docker, bind-backed data dir | the exact host path, even when a parent directory is bind-mounted |
184
+ | Podman (rootless or root) | same logic; named volumes receive no `:Z` flag |
185
+
186
+ The content at `SK_MOUNT` inside the managed container always corresponds to the root of `app.getDataDirPath()`. Build paths using `path.join`:
187
+
188
+ ```typescript
189
+ // Path inside managed container that corresponds to an absolute SignalK path:
190
+ const containerPath = path.join(
191
+ SK_MOUNT,
192
+ path.relative(app.getDataDirPath(), absSignalkPath),
193
+ );
194
+ ```
195
+
196
+ > [!note]
197
+ > Docker/Podman do not support subpath mounts on named volumes. If your data directory
198
+ > is backed by a named volume, the entire volume is mounted at `SK_MOUNT`. Avoid writing
199
+ > to paths inside `SK_MOUNT` that are also bind-mounted in the Signal K container (e.g.
200
+ > a plugin's own directory if mounted with `./:/home/node/.signalk/node_modules/my-plugin`)
201
+ > — those paths are not visible from inside the managed container.
202
+
203
+ You can also call `containers.resolveSignalkDataMount()` if you need to inspect the resolved source at runtime (e.g. for logging).
204
+
205
+ ## Connecting back to a container service (`signalkAccessiblePorts`)
206
+
207
+ When a managed container exposes an HTTP, TCP, or other service that the SignalK process itself needs to connect to (e.g. a video stream, a database, an inference engine), use `signalkAccessiblePorts` instead of hardcoding port bindings or writing deployment-detection logic in your plugin.
208
+
209
+ ```typescript
210
+ const STREAM_PORT = 8090;
211
+
212
+ await containers.ensureRunning("my-streamer", {
213
+ image: "myorg/streamer",
214
+ tag: "latest",
215
+ signalkAccessiblePorts: [STREAM_PORT],
216
+ restart: "unless-stopped",
217
+ command: ["--listen", String(STREAM_PORT)],
218
+ });
219
+
220
+ const addr = await containers.resolveContainerAddress(
221
+ "my-streamer",
222
+ STREAM_PORT,
223
+ );
224
+ if (!addr) throw new Error("Container address not available");
225
+ // Connect from the SignalK process — addr is always the right host:port:
226
+ http.get(`http://${addr}/stream`, handleResponse);
227
+ ```
228
+
229
+ signalk-container resolves the correct networking strategy automatically:
230
+
231
+ | Deployment | Strategy | Address returned |
232
+ | -------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------- |
233
+ | Bare-metal Signal K | Port bound to `127.0.0.1` (first free port ≥ declared value) | `127.0.0.1:8090` (or `127.0.0.1:8091` if 8090 was taken) |
234
+ | Containerised, user-defined network | Container attached to SignalK's own Docker/Podman network; no host port exposed | `sk-my-streamer:8090` (Docker DNS) |
235
+ | Containerised, default bridge (no DNS) | Container shares SignalK's network namespace | `127.0.0.1:8090` |
236
+
237
+ The allocated address is cached for the lifetime of the plugin session, so repeated `ensureRunning()` calls never trigger an unwanted container recreate due to a port number change.
238
+
239
+ > [!note]
240
+ > `signalkAccessiblePorts` sets up networking automatically. Do not combine it with
241
+ > a manual `ports` or `networkMode` entry for the same container — the field takes
242
+ > full ownership of those concerns.
243
+
151
244
  ## Setting Resource Limits
152
245
 
153
246
  On a boat with limited compute (typically a Pi 4/5 or low-power x86 mini PC), one runaway container can starve Signal K, raise NMEA decode latency, trigger thermal throttling, or even take the host down via OOM. signalk-container exposes podman/docker resource flags so consumer plugins can set sensible defaults — and you, as the user, can tune them per-container in two ways: **the config panel UI (recommended)** or direct JSON edit (for scripted/automated setups).
@@ -340,3 +433,9 @@ ecosystem (signalk-questdb, signalk-grafana) are designed for this case.
340
433
  ## License
341
434
 
342
435
  MIT
436
+
437
+ ## Acknowledgements
438
+
439
+ The plugin icon (`icon.svg`) is the **container** glyph from
440
+ [Lucide Icons](https://lucide.dev), used under the ISC License. See
441
+ [`NOTICE`](NOTICE) for the full attribution.
@@ -1,4 +1,16 @@
1
1
  import { ContainerConfig, ContainerInfo, ContainerRuntimeInfo, ContainerState, HealthCheckOptions } from "./types";
2
+ /**
3
+ * Build the value for a `-v <source>:<dest>[:flags]` argument with the
4
+ * correct SELinux relabel suffix for the runtime.
5
+ *
6
+ * `:Z` is for SELinux relabelling of bind-mount host paths under Podman
7
+ * on Fedora/RHEL. Named volumes (no leading '/' or '.') reject `:Z` with
8
+ * "invalid option z for named volume", so we omit the flag for them.
9
+ *
10
+ * Used by both ContainerConfig.volumes (containers.ts) and JobConfig
11
+ * inputs/outputs (jobs.ts) so the named-volume guard stays in one place.
12
+ */
13
+ export declare function volumeArg(hostPath: string, containerPath: string, runtime: ContainerRuntimeInfo, readOnly?: boolean): string;
2
14
  export declare function qualifyImage(image: string, runtime: ContainerRuntimeInfo): string;
3
15
  export declare function imageExists(runtime: ContainerRuntimeInfo, image: string): Promise<boolean>;
4
16
  /**
@@ -63,6 +75,66 @@ export declare function ensureNetwork(runtime: ContainerRuntimeInfo, name: strin
63
75
  export declare function removeNetwork(runtime: ContainerRuntimeInfo, name: string): Promise<void>;
64
76
  export declare function connectToNetwork(runtime: ContainerRuntimeInfo, containerName: string, networkName: string): Promise<void>;
65
77
  export declare function disconnectFromNetwork(runtime: ContainerRuntimeInfo, containerName: string, networkName: string): Promise<void>;
78
+ /**
79
+ * Resolve what to mount in a managed container to give it access to
80
+ * the SignalK data directory, regardless of how SignalK itself is deployed.
81
+ *
82
+ * Returns the string to use as the LEFT side of a `-v <source>:<dest>` flag:
83
+ * - Bare-metal SignalK: returns dataDir directly (it is already a host path).
84
+ * - SignalK in Docker, volume-backed dataDir: returns the named volume.
85
+ * - SignalK in Docker, bind-backed dataDir: returns the exact host path
86
+ * (computing the subpath when a parent directory is bind-mounted).
87
+ * - Fallback (mount not found): returns dataDir — the caller's `-v` will
88
+ * fail gracefully at container-create time with a clear Docker error.
89
+ *
90
+ * The result can be used directly as `volumes: { [mountPoint]: source }` in
91
+ * a ContainerConfig. The content visible at mountPoint inside the managed
92
+ * container will always correspond to the root of dataDir.
93
+ */
94
+ export declare function resolveSignalkDataSource(dataDir: string, runtime: ContainerRuntimeInfo, debug?: (msg: string) => void): Promise<string>;
95
+ /**
96
+ * Release a port that was reserved by `findAvailablePort()`.
97
+ * Must be called after the container runtime has successfully bound the port
98
+ * (so the OS-level bind now prevents collisions), or when the container
99
+ * creation failed (so the next attempt can re-probe freely).
100
+ */
101
+ export declare function releaseReservedPort(port: number): void;
102
+ /**
103
+ * Find the lowest available TCP port on 127.0.0.1 starting at `preferred`.
104
+ *
105
+ * Probes by briefly binding a server socket and also skips ports that are
106
+ * already reserved in-process by a concurrent `findAvailablePort()` call,
107
+ * eliminating the TOCTOU window between the probe and the container create.
108
+ *
109
+ * The chosen port is added to the process-local `reservedPorts` set before
110
+ * this function resolves. The caller is responsible for releasing it via
111
+ * `releaseReservedPort()` once the runtime holds the binding or the attempt
112
+ * fails.
113
+ *
114
+ * Used by the `signalkAccessiblePorts` bare-metal path to prefer the
115
+ * declared port number while gracefully stepping over conflicts.
116
+ */
117
+ export declare function findAvailablePort(preferred: number): Promise<number>;
118
+ /**
119
+ * Return the user-defined Docker/Podman networks that the current SignalK
120
+ * container is connected to (i.e. networks other than the default `bridge`,
121
+ * `host`, or `none`).
122
+ *
123
+ * Used by the `signalkAccessiblePorts` containerized path to attach a
124
+ * managed container to SignalK's own network so the two can communicate
125
+ * via DNS name without exposing any host port.
126
+ *
127
+ * Returns:
128
+ * - `null` when running bare-metal, HOSTNAME is unset, or `docker inspect`
129
+ * fails (e.g. host-network mode where HOSTNAME is the machine
130
+ * name, not a container ID). Callers should treat this like
131
+ * bare-metal and publish ports instead.
132
+ * - `string[]` (possibly empty) when inspect succeeds. An empty array means
133
+ * SignalK is only on the default bridge — callers should fall
134
+ * back to `networkMode: container:<HOSTNAME>`. A non-empty
135
+ * array contains the user-defined network names to attach to.
136
+ */
137
+ export declare function resolveSignalkNetworks(runtime: ContainerRuntimeInfo, debug?: (msg: string) => void): Promise<string[] | null>;
66
138
  export declare function waitForReady(url: string, timeoutMs?: number, intervalMs?: number): Promise<void>;
67
139
  export {};
68
140
  //# sourceMappingURL=containers.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"containers.d.ts","sourceRoot":"","sources":["../src/containers.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"containers.d.ts","sourceRoot":"","sources":["../src/containers.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,39 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.volumeArg = volumeArg;
3
37
  exports.qualifyImage = qualifyImage;
4
38
  exports.imageExists = imageExists;
5
39
  exports.getImageDigest = getImageDigest;
@@ -17,7 +51,12 @@ exports.ensureNetwork = ensureNetwork;
17
51
  exports.removeNetwork = removeNetwork;
18
52
  exports.connectToNetwork = connectToNetwork;
19
53
  exports.disconnectFromNetwork = disconnectFromNetwork;
54
+ exports.resolveSignalkDataSource = resolveSignalkDataSource;
55
+ exports.releaseReservedPort = releaseReservedPort;
56
+ exports.findAvailablePort = findAvailablePort;
57
+ exports.resolveSignalkNetworks = resolveSignalkNetworks;
20
58
  exports.waitForReady = waitForReady;
59
+ const net = __importStar(require("net"));
21
60
  const runtime_1 = require("./runtime");
22
61
  const resources_1 = require("./resources");
23
62
  const CONTAINER_PREFIX = "sk-";
@@ -26,6 +65,27 @@ function prefixedName(name) {
26
65
  ? name
27
66
  : `${CONTAINER_PREFIX}${name}`;
28
67
  }
68
+ /**
69
+ * Build the value for a `-v <source>:<dest>[:flags]` argument with the
70
+ * correct SELinux relabel suffix for the runtime.
71
+ *
72
+ * `:Z` is for SELinux relabelling of bind-mount host paths under Podman
73
+ * on Fedora/RHEL. Named volumes (no leading '/' or '.') reject `:Z` with
74
+ * "invalid option z for named volume", so we omit the flag for them.
75
+ *
76
+ * Used by both ContainerConfig.volumes (containers.ts) and JobConfig
77
+ * inputs/outputs (jobs.ts) so the named-volume guard stays in one place.
78
+ */
79
+ function volumeArg(hostPath, containerPath, runtime, readOnly = false) {
80
+ const isNamedVolume = !hostPath.startsWith("/") && !hostPath.startsWith(".");
81
+ const flags = [];
82
+ if (readOnly)
83
+ flags.push("ro");
84
+ if (runtime.runtime === "podman" && !isNamedVolume)
85
+ flags.push("Z");
86
+ const suffix = flags.length > 0 ? `:${flags.join(",")}` : "";
87
+ return `${hostPath}:${containerPath}${suffix}`;
88
+ }
29
89
  function qualifyImage(image, runtime) {
30
90
  // Podman requires fully qualified image names when unqualified-search
31
91
  // registries are not configured. Prefix docker.io/ if missing.
@@ -244,8 +304,7 @@ function buildRunArgs(name, config, runtime) {
244
304
  }
245
305
  if (config.volumes) {
246
306
  for (const [containerPath, hostPath] of Object.entries(config.volumes)) {
247
- const suffix = runtime.runtime === "podman" ? ":Z" : "";
248
- args.push("-v", `${hostPath}:${containerPath}${suffix}`);
307
+ args.push("-v", volumeArg(hostPath, containerPath, runtime));
249
308
  }
250
309
  }
251
310
  if (config.env) {
@@ -440,6 +499,180 @@ async function disconnectFromNetwork(runtime, containerName, networkName) {
440
499
  throw new Error(`Failed to disconnect ${fullName} from ${networkName}: ${result.stderr}`);
441
500
  }
442
501
  }
502
+ /**
503
+ * Resolve what to mount in a managed container to give it access to
504
+ * the SignalK data directory, regardless of how SignalK itself is deployed.
505
+ *
506
+ * Returns the string to use as the LEFT side of a `-v <source>:<dest>` flag:
507
+ * - Bare-metal SignalK: returns dataDir directly (it is already a host path).
508
+ * - SignalK in Docker, volume-backed dataDir: returns the named volume.
509
+ * - SignalK in Docker, bind-backed dataDir: returns the exact host path
510
+ * (computing the subpath when a parent directory is bind-mounted).
511
+ * - Fallback (mount not found): returns dataDir — the caller's `-v` will
512
+ * fail gracefully at container-create time with a clear Docker error.
513
+ *
514
+ * The result can be used directly as `volumes: { [mountPoint]: source }` in
515
+ * a ContainerConfig. The content visible at mountPoint inside the managed
516
+ * container will always correspond to the root of dataDir.
517
+ */
518
+ async function resolveSignalkDataSource(dataDir, runtime, debug = () => { }) {
519
+ if (!(0, runtime_1.isContainerized)()) {
520
+ // Running bare-metal: dataDir is already a host filesystem path.
521
+ return dataDir;
522
+ }
523
+ // Running inside a container. Docker/Podman set HOSTNAME to the
524
+ // (short) container ID, which is enough for `inspect`.
525
+ const selfId = process.env.HOSTNAME ?? "";
526
+ if (!selfId) {
527
+ debug(`resolveSignalkDataSource: HOSTNAME unset, falling back to dataDir=${dataDir}`);
528
+ return dataDir;
529
+ }
530
+ const result = await (0, runtime_1.execRuntime)(runtime, [
531
+ "inspect",
532
+ "--format",
533
+ "{{range .Mounts}}{{.Type}}|{{.Name}}|{{.Source}}|{{.Destination}}\n{{end}}",
534
+ selfId,
535
+ ]);
536
+ if (result.exitCode !== 0) {
537
+ debug(`resolveSignalkDataSource: inspect ${selfId} failed (exit=${result.exitCode}): ${result.stderr.trim()}; falling back to dataDir=${dataDir}`);
538
+ return dataDir;
539
+ }
540
+ const mounts = result.stdout
541
+ .split("\n")
542
+ .filter(Boolean)
543
+ .map((line) => {
544
+ const [type, name, source, dest] = line.split("|");
545
+ return { type, name, source, dest };
546
+ });
547
+ // Find the mount whose Destination is the longest prefix of dataDir
548
+ // (handles both exact matches and parent-directory bind mounts).
549
+ let best = null;
550
+ for (const m of mounts) {
551
+ if (dataDir === m.dest || dataDir.startsWith(m.dest + "/")) {
552
+ if (!best || m.dest.length > best.dest.length) {
553
+ best = m;
554
+ }
555
+ }
556
+ }
557
+ if (!best) {
558
+ debug(`resolveSignalkDataSource: no mount covers dataDir=${dataDir}; mounts=${JSON.stringify(mounts)}; falling back to dataDir`);
559
+ return dataDir;
560
+ }
561
+ if (best.type === "volume") {
562
+ // Named volume. Docker doesn't support subpath mounts on volumes,
563
+ // so we return the volume name as-is. The consumer's mount point
564
+ // will correspond to best.dest; if that equals dataDir (the common
565
+ // case) the consumer can use mountPoint directly. If best.dest is a
566
+ // parent of dataDir, the consumer must append the relative suffix —
567
+ // signalk-container surfaces this via ContainerManagerApi if needed.
568
+ return best.name;
569
+ }
570
+ // Bind mount. Compute the exact host path that corresponds to dataDir,
571
+ // even when the bind covers a parent directory.
572
+ return best.source + dataDir.slice(best.dest.length);
573
+ }
574
+ /**
575
+ * Process-local set of ports that are currently reserved by an in-flight
576
+ * `findAvailablePort()` call. Prevents two concurrent `ensureRunning()`
577
+ * calls from probing and claiming the same host port before either
578
+ * container has actually been created.
579
+ *
580
+ * Ports are added here just before `findAvailablePort()` resolves and
581
+ * removed via `releaseReservedPort()` once the container runtime holds the
582
+ * binding (successful create) or the attempt fails.
583
+ */
584
+ const reservedPorts = new Set();
585
+ /**
586
+ * Release a port that was reserved by `findAvailablePort()`.
587
+ * Must be called after the container runtime has successfully bound the port
588
+ * (so the OS-level bind now prevents collisions), or when the container
589
+ * creation failed (so the next attempt can re-probe freely).
590
+ */
591
+ function releaseReservedPort(port) {
592
+ reservedPorts.delete(port);
593
+ }
594
+ /**
595
+ * Test whether `port` is bindable on 127.0.0.1.
596
+ * Returns `true` if the socket can be opened and closed without error.
597
+ */
598
+ function isPortAvailable(port) {
599
+ return new Promise((resolve) => {
600
+ const server = net.createServer();
601
+ server.once("error", () => resolve(false));
602
+ server.once("listening", () => server.close(() => resolve(true)));
603
+ server.listen(port, "127.0.0.1");
604
+ });
605
+ }
606
+ /**
607
+ * Find the lowest available TCP port on 127.0.0.1 starting at `preferred`.
608
+ *
609
+ * Probes by briefly binding a server socket and also skips ports that are
610
+ * already reserved in-process by a concurrent `findAvailablePort()` call,
611
+ * eliminating the TOCTOU window between the probe and the container create.
612
+ *
613
+ * The chosen port is added to the process-local `reservedPorts` set before
614
+ * this function resolves. The caller is responsible for releasing it via
615
+ * `releaseReservedPort()` once the runtime holds the binding or the attempt
616
+ * fails.
617
+ *
618
+ * Used by the `signalkAccessiblePorts` bare-metal path to prefer the
619
+ * declared port number while gracefully stepping over conflicts.
620
+ */
621
+ async function findAvailablePort(preferred) {
622
+ for (let port = preferred; port <= 65535; port++) {
623
+ if (reservedPorts.has(port))
624
+ continue;
625
+ if (await isPortAvailable(port)) {
626
+ reservedPorts.add(port);
627
+ return port;
628
+ }
629
+ }
630
+ throw new Error("No available port found in range 1024–65535");
631
+ }
632
+ /**
633
+ * Return the user-defined Docker/Podman networks that the current SignalK
634
+ * container is connected to (i.e. networks other than the default `bridge`,
635
+ * `host`, or `none`).
636
+ *
637
+ * Used by the `signalkAccessiblePorts` containerized path to attach a
638
+ * managed container to SignalK's own network so the two can communicate
639
+ * via DNS name without exposing any host port.
640
+ *
641
+ * Returns:
642
+ * - `null` when running bare-metal, HOSTNAME is unset, or `docker inspect`
643
+ * fails (e.g. host-network mode where HOSTNAME is the machine
644
+ * name, not a container ID). Callers should treat this like
645
+ * bare-metal and publish ports instead.
646
+ * - `string[]` (possibly empty) when inspect succeeds. An empty array means
647
+ * SignalK is only on the default bridge — callers should fall
648
+ * back to `networkMode: container:<HOSTNAME>`. A non-empty
649
+ * array contains the user-defined network names to attach to.
650
+ */
651
+ async function resolveSignalkNetworks(runtime, debug = () => { }) {
652
+ if (!(0, runtime_1.isContainerized)())
653
+ return null;
654
+ const selfId = process.env.HOSTNAME ?? "";
655
+ if (!selfId) {
656
+ debug("resolveSignalkNetworks: HOSTNAME unset, returning null");
657
+ return null;
658
+ }
659
+ const result = await (0, runtime_1.execRuntime)(runtime, [
660
+ "inspect",
661
+ "--format",
662
+ "{{range $k,$v := .NetworkSettings.Networks}}{{$k}}\n{{end}}",
663
+ selfId,
664
+ ]);
665
+ if (result.exitCode !== 0) {
666
+ debug(`resolveSignalkNetworks: inspect ${selfId} failed (exit=${result.exitCode}): ${result.stderr.trim()} — treating as bare-metal`);
667
+ return null;
668
+ }
669
+ const all = result.stdout.split("\n").filter(Boolean);
670
+ // The default bridge network does not support container-name DNS
671
+ // resolution, so exclude it along with the virtual modes.
672
+ const userDefined = all.filter((n) => n !== "bridge" && n !== "host" && n !== "none");
673
+ debug(`resolveSignalkNetworks: all=${all.join(",")} userDefined=${userDefined.join(",")}`);
674
+ return userDefined;
675
+ }
443
676
  async function waitForReady(url, timeoutMs = 30000, intervalMs = 500) {
444
677
  const deadline = Date.now() + timeoutMs;
445
678
  while (Date.now() < deadline) {