signalk-container 1.15.0-beta.0 → 1.15.1

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/README.md CHANGED
@@ -27,6 +27,255 @@ Instead of each plugin implementing its own container orchestration, they delega
27
27
  - **Docker `host.containers.internal` parity** -- signalk-container adds the `host-gateway` mapping for Docker automatically (Podman has it natively). User-supplied `extraHosts` overrides are respected.
28
28
  - **Cross-plugin API** -- other plugins use `globalThis.__signalk_containerManager`
29
29
 
30
+ ## Requirements
31
+
32
+ - Node.js >= 22
33
+ - Podman or Docker installed on the host
34
+ - Signal K server
35
+
36
+ ## Running Signal K in a Container
37
+
38
+ If your Signal K server itself runs inside a container (Docker, Podman),
39
+ this plugin needs access to the host's container runtime to manage other
40
+ containers. The plugin auto-detects this scenario via `/.dockerenv` or
41
+ `/run/.containerenv` and prefixes the status with `(in-container)`.
42
+
43
+ For the plugin to work, two things must be true inside the Signal K
44
+ container:
45
+
46
+ 1. **A matching runtime CLI is available** — the CLI inside the SK
47
+ container must match the daemon on the host (Docker host → `docker`;
48
+ Podman host → `podman`). End users typically bind-mount the host
49
+ binary; image maintainers can bake it into a custom image.
50
+ 2. **The matching runtime socket is bind-mounted** from the host
51
+ (rootless or rootful podman, or docker).
52
+
53
+ Concrete platform-specific commands — for both end-user and
54
+ image-maintainer setups — are emitted by the deployment doctor (see
55
+ `/api/doctor/deployment` and the snippet generator below). Use those
56
+ as the source of truth; they always reflect the running plugin
57
+ version.
58
+
59
+ ### Quick check: `/api/doctor/deployment`
60
+
61
+ The plugin ships a self-diagnostic. After starting, hit:
62
+
63
+ ```bash
64
+ curl http://<signalk-host>:3000/plugins/signalk-container/api/doctor/deployment
65
+ ```
66
+
67
+ The response includes a `status` field (`ok` / `no-runtime` /
68
+ `socket-unreachable` / `permission-denied` / `self-id-unresolved` /
69
+ `cgroup-controllers-incomplete`) and a `remediation` array of
70
+ copy-pasteable lines for whichever failure mode applies. When startup
71
+ detection fails, the same remediation is also logged to the Signal K
72
+ server log.
73
+
74
+ A separate `cgroupControllers` field on the response reports the
75
+ delegated cgroup v2 controllers and which expected ones are missing —
76
+ see [Cgroup controller delegation](#cgroup-controller-delegation) for
77
+ what missing controllers mean for resource limits.
78
+
79
+ ### Generate a starter snippet
80
+
81
+ To bootstrap a new deployment, ask the plugin for a ready-to-paste
82
+ compose fragment or shell command tailored to the detected runtime:
83
+
84
+ ```bash
85
+ curl 'http://<signalk-host>:3000/plugins/signalk-container/api/doctor/snippet?format=compose' > docker-compose.yml
86
+ curl 'http://<signalk-host>:3000/plugins/signalk-container/api/doctor/snippet?format=run' > run-signalk.sh
87
+ ```
88
+
89
+ The endpoint returns plain text by default; pass
90
+ `Accept: application/json` to get the structured
91
+ `SetupSnippetResult` (snippet + Dockerfile sidecar + operator notes)
92
+ for programmatic consumers.
93
+
94
+ > [!note]
95
+ > The per-runtime examples below illustrate the shape of a working setup.
96
+ > For an actual deployment, prefer the doctor-generated snippet above — it
97
+ > reflects your detected runtime and the running plugin version, so it
98
+ > stays correct as these details evolve.
99
+
100
+ ### Rootless Podman (recommended)
101
+
102
+ The cleanest setup. Runs as your user, not root, so the security
103
+ exposure is limited to your user account rather than the entire host —
104
+ and matches signalk-container's default behaviour.
105
+
106
+ On the host, ensure the user-scoped podman socket is enabled:
107
+
108
+ ```bash
109
+ systemctl --user enable --now podman.socket
110
+ ```
111
+
112
+ Then in your compose / `podman run`:
113
+
114
+ ```yaml
115
+ services:
116
+ signalk:
117
+ image: your-signalk-image-with-podman-remote
118
+ user: "${UID}:${GID}" # match the uid that owns the host's podman socket
119
+ volumes:
120
+ - /run/user/${UID}/podman/podman.sock:/run/user/${UID}/podman/podman.sock
121
+ environment:
122
+ - CONTAINER_HOST=unix:///run/user/${UID}/podman/podman.sock
123
+ ```
124
+
125
+ Your image's Dockerfile should include `podman` or `podman-remote`:
126
+
127
+ ```dockerfile
128
+ RUN apt-get update && apt-get install -y podman # Debian/Ubuntu
129
+ # or:
130
+ RUN dnf install -y podman-remote # Fedora/RHEL
131
+ ```
132
+
133
+ ### Rootful Podman
134
+
135
+ ```yaml
136
+ services:
137
+ signalk:
138
+ image: your-signalk-image-with-podman
139
+ volumes:
140
+ - /run/podman/podman.sock:/run/podman/podman.sock
141
+ environment:
142
+ - CONTAINER_HOST=unix:///run/podman/podman.sock
143
+ ```
144
+
145
+ ### Docker
146
+
147
+ ```yaml
148
+ services:
149
+ signalk:
150
+ image: your-signalk-image-with-docker-cli
151
+ volumes:
152
+ - /var/run/docker.sock:/var/run/docker.sock
153
+ group_add:
154
+ - "<docker-gid-from-host>" # `getent group docker | cut -d: -f3`
155
+ ```
156
+
157
+ > [!warning]
158
+ > Mounting `/var/run/docker.sock` gives the container **root-equivalent
159
+ > access to the host**. Anyone who compromises Signal K (including via
160
+ > a malicious plugin) can take over the entire host. Prefer rootless
161
+ > Podman for production.
162
+
163
+ ### Networking caveats
164
+
165
+ When Signal K runs in a container, containers spawned by this plugin
166
+ are **siblings** on the host's container network, not inside Signal K's
167
+ network namespace. This affects:
168
+
169
+ - The shared `sk-network` works only if Signal K is also attached to it
170
+ (add it externally or via the same compose file)
171
+ - `host.containers.internal` from spawned containers points to the host
172
+ itself, not the Signal K container — use Signal K's container name
173
+ for direct communication. signalk-container 1.8.0+ adds this hostname
174
+ to Docker containers automatically (Podman already provides it); set
175
+ `ContainerConfig.extraHosts` to override it or to add other hostnames.
176
+
177
+ ### Cgroup controller delegation
178
+
179
+ When Signal K runs inside a rootless container, the kernel only enforces
180
+ those resource limits whose **cgroup controller has been delegated** down
181
+ to the SK container's cgroup. Anything else passed to `podman run` is
182
+ silently ignored — your `--memory 1g` request reaches the runtime, but
183
+ the cgroup never gets a memory cap and the container can grow without
184
+ bound.
185
+
186
+ The most common culprit is `memory`: many distros delegate `cpu`,
187
+ `cpuset`, `io`, and `pids` to user sessions by default, but `memory`
188
+ must be explicitly added.
189
+
190
+ signalk-container 1.9.0+ probes the available controllers via
191
+ `/sys/fs/cgroup/cgroup.controllers` and **silently drops** unsupported
192
+ limit fields before invoking podman — better than crashing the
193
+ container, but the user-visible effect is "I set memory to 1 GB and
194
+ nothing happened." The drop is logged at `debug` level with the reason
195
+ (`cgroup controller 'memory' not delegated to podman (available: cpuset,
196
+ cpu, io, pids)`); the live `effective` resource response also reflects
197
+ only what's actually in place.
198
+
199
+ **The deployment doctor flags this automatically** with `status:
200
+ cgroup-controllers-incomplete` and a ready-to-paste remediation block.
201
+ Hit `/api/doctor/deployment` (see above) and check the `status` and
202
+ `cgroupControllers` fields.
203
+
204
+ **Manual check from a shell:**
205
+
206
+ ```bash
207
+ podman exec <sk-container> cat /sys/fs/cgroup/cgroup.controllers
208
+ # cpuset cpu io memory pids ← memory delegated, all limits work
209
+ # cpuset cpu io pids ← memory missing, --memory is dropped
210
+ ```
211
+
212
+ **Enable memory delegation on the host** (one-time, requires sudo):
213
+
214
+ ```bash
215
+ sudo mkdir -p /etc/systemd/system/user@.service.d
216
+ sudo tee /etc/systemd/system/user@.service.d/delegate.conf <<'EOF'
217
+ [Service]
218
+ Delegate=cpu cpuset io memory pids
219
+ EOF
220
+ sudo systemctl daemon-reload
221
+ # Log the SK-owning user out and back in (or reboot) so a fresh user@.service starts.
222
+ ```
223
+
224
+ After re-login, re-running the consumer plugin's `ensureRunning` (or
225
+ just restarting Signal K) recreates the managed container with the
226
+ memory cap actually applied. Verify with `podman inspect sk-<name>
227
+ --format '{{.HostConfig.Memory}}'` — a non-zero value confirms the cap
228
+ is in cgroup state, not just on the command line.
229
+
230
+ This is purely a host-side prerequisite; signalk-container cannot
231
+ override the kernel's controller delegation.
232
+
233
+ #### Raspberry Pi OS: `cgroup_disable=memory` in the kernel cmdline
234
+
235
+ If you're on a Raspberry Pi 4/5 running Raspberry Pi OS Trixie (and
236
+ likely earlier Pi OS releases) and the systemd `Delegate=memory` snippet
237
+ above **doesn't work** — `cat /sys/fs/cgroup/cgroup.controllers` still
238
+ shows `cpuset cpu io pids` after a reboot — the cause is one level
239
+ deeper. The Pi's GPU firmware injects `cgroup_disable=memory` into the
240
+ kernel boot cmdline, so the memory controller never reaches systemd.
241
+
242
+ Quick check:
243
+
244
+ ```bash
245
+ grep -o "cgroup_disable=memory" /proc/cmdline
246
+ # Prints "cgroup_disable=memory" → you're hit.
247
+ ```
248
+
249
+ Full runbook with copy-pasteable commands, verification steps, and
250
+ revert instructions: **[doc/cgroup-memory-on-raspberry-pi-os.md](doc/cgroup-memory-on-raspberry-pi-os.md)**.
251
+
252
+ The deployment doctor at `/api/doctor/deployment` also detects this
253
+ scenario and surfaces the same kernel-cmdline fix in its `remediation`
254
+ array, so you don't have to guess which layer is broken first.
255
+
256
+ ### Watch out for systemd auto-restart (Quadlet / `Restart=always`)
257
+
258
+ If you run Signal K via a podman Quadlet (`*.container` in
259
+ `~/.config/containers/systemd/`) or a systemd unit with
260
+ `Restart=always`, the unit silently restarts the SK container within
261
+ `RestartSec` seconds of any stop — including operator-initiated
262
+ `podman stop`. This races with manually-started replacement containers
263
+ on the same port.
264
+
265
+ For test/diagnostic swaps, temporarily disable the unit's
266
+ restart/recovery policy before stopping the container and re-enable it
267
+ afterward. With a `--user` Quadlet (substitute your actual unit name):
268
+
269
+ ```bash
270
+ systemctl --user mask <your-signalk-unit>.service # suppress auto-restart
271
+ # … run your test container on port 3000 …
272
+ systemctl --user unmask <your-signalk-unit>.service # re-enable
273
+ systemctl --user start <your-signalk-unit>.service
274
+ ```
275
+
276
+ This is purely an operator-side consideration; signalk-container has no
277
+ visibility into systemd-managed lifecycles.
278
+
30
279
  ## Config Panel
31
280
 
32
281
  The plugin embeds a React config panel in the Signal K Admin UI (via Module Federation). It's the recommended way to manage containers — you shouldn't need to edit JSON directly.
@@ -42,7 +291,7 @@ The plugin embeds a React config panel in the Signal K Admin UI (via Module Fede
42
291
  - **Auto-prune images** -- off, weekly, or monthly scheduled cleanup of dangling images
43
292
  - **Update check interval** -- how often to check consumer plugins for new container images (1h to 1 week, default 24h)
44
293
  - **Background update checks** -- toggle for metered connections; manual checks still work when off
45
- - **Disable user-namespace remap (ZFS escape hatch)** -- off by default. Secondary fix for ZFS / id-map-less hosts; prefer the host-side `fuse-overlayfs` storage driver first ([ZFS host notes](#zfs-host-notes)). Enable only if container creation fails with `crun: writing file /proc/<pid>/gid_map: Invalid argument` and you cannot switch storage drivers. With the flag on, signalk-container stops emitting `--userns=keep-id` for rootless Podman; bind-mount file ownership still lands on the host caller for root-by-default images (questdb, grafana, mayara), but non-root images lose host-caller ownership in exchange for being able to start at all.
294
+ - **Disable user-namespace remap (ZFS escape hatch)** -- off by default. Secondary fix for ZFS / id-map-less hosts; prefer the host-side `fuse-overlayfs` storage driver first ([ZFS host notes](#zfs-and-other-idmap-incompatible-filesystems)). Enable only if container creation fails with `crun: writing file /proc/<pid>/gid_map: Invalid argument` and you cannot switch storage drivers. With the flag on, signalk-container stops emitting `--userns=keep-id` for rootless Podman; bind-mount file ownership still lands on the host caller for root-by-default images (questdb, grafana, mayara), but non-root images lose host-caller ownership in exchange for being able to start at all.
46
295
 
47
296
  ### Managed Containers (one card per running or stopped container)
48
297
 
@@ -70,141 +319,127 @@ The plugin embeds a React config panel in the Signal K Admin UI (via Module Fede
70
319
 
71
320
  - **Prune Dangling Images** button with before/after space reclaimed summary
72
321
 
73
- ## How Other Plugins Use It
322
+ ## Setting Resource Limits
74
323
 
75
- ```typescript
76
- const containers = (globalThis as any).__signalk_containerManager;
77
- if (!containers) {
78
- app.setPluginError("signalk-container plugin required");
79
- return;
80
- }
324
+ 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).
81
325
 
82
- // Wait for runtime detection to settle, then verify a runtime was found.
83
- await containers.whenReady();
84
- if (!containers.getRuntime()) {
85
- app.setPluginError("No container runtime detected");
86
- return;
326
+ ### How it works
327
+
328
+ Each consumer plugin (signalk-questdb, signalk-grafana, mayara, etc.) declares default CPU/memory limits when it starts its container. Your override is **merged field-by-field** on top of the plugin's defaults, and only the fields that actually differ from the default get stored. This means if a future plugin version bumps its memory default from 512m to 1g, your override for just `cpus` will automatically pick up the new memory value — no manual edit needed.
329
+
330
+ ### Using the Config Panel (recommended)
331
+
332
+ 1. Open the Signal K admin UI → Plugin Config → **Container Manager**
333
+ 2. Find the container you want to tune in the "Managed Containers" list
334
+ 3. Click **Edit Limits ▸** on the row
335
+ 4. Edit the CPU cores, Memory, Memory+swap, or Max processes fields. Use the × button next to a field to explicitly unset a limit the plugin set. Click **Advanced** to access cpuShares, cpusetCpus, memoryReservation, and oomScoreAdj.
336
+ 5. Click **Apply** — live updated where possible (no downtime), recreated where needed. The result box shows which method was used plus any warnings.
337
+ 6. To restore the plugin's default: click **Reset to default** (amber button). This clears your override and applies the pristine default to the running container.
338
+
339
+ The form re-seeds from the server's fresh state after every Apply or Reset, so the displayed values always match what's actually running.
340
+
341
+ ### Available fields
342
+
343
+ | Field | Example | What it does |
344
+ | ------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
345
+ | `cpus` | `1.5` | Hard CPU cap. `1.5` = max 1.5 cores. The most important field for stability. |
346
+ | `cpuShares` | `512` | Soft CPU weight under contention (default 1024). Lower = lower priority. |
347
+ | `cpusetCpus` | `"1,2"` | Pin to specific cores. Useful to keep heavy containers off core 0 where Signal K runs. May force a recreate on hosts where the cpuset cgroup controller isn't delegated. |
348
+ | `memory` | `"512m"`, `"2g"` | Hard memory cap. Container is OOM-killed if exceeded. |
349
+ | `memorySwap` | `"512m"` | Memory + swap total. **Set equal to `memory` to disable swap entirely** — recommended on Pi/eMMC where swap is slow. |
350
+ | `memoryReservation` | `"256m"` | Soft memory floor. Kernel reclaims first from containers above this. |
351
+ | `pidsLimit` | `200` | Cap on processes/threads. Prevents fork bombs and thread leaks. |
352
+ | `oomScoreAdj` | `500` | OOM kill priority, -1000..1000. Higher = killed first when host runs out of memory. Set at container create time only — forces a recreate when changed. |
353
+
354
+ ### Direct JSON (scripted/advanced)
355
+
356
+ The UI writes to a `containerOverrides` map in `plugin-config-data/signalk-container.json`. You can edit this directly if you prefer — useful for automation or bulk configuration:
357
+
358
+ ```json
359
+ {
360
+ "configuration": {
361
+ "containerOverrides": {
362
+ "mayara-server": {
363
+ "cpus": 1.5,
364
+ "memory": "512m",
365
+ "memorySwap": "512m"
366
+ }
367
+ }
368
+ }
87
369
  }
370
+ ```
88
371
 
89
- // Start a long-running service container. ensureRunning compares this
90
- // config against the live container and recreates on drift — no per-
91
- // plugin hash file or remove() dance needed.
92
- await containers.ensureRunning("my-service", {
93
- image: "myorg/myimage",
94
- tag: "latest",
95
- signalkDataMount: "/data", // resolves to the SignalK data dir, regardless of deployment
96
- signalkAccessiblePorts: [8080], // port 8080 in the container must be reachable by SignalK
97
- env: { MY_VAR: "value" },
98
- restart: "unless-stopped",
99
- });
372
+ The key (`mayara-server`) is the container name **without** the `sk-` prefix that signalk-container adds internally. Use `null` for a field to explicitly remove a limit set by the plugin:
100
373
 
101
- // Get the actual address to connect to (resolved after ensureRunning)
102
- const addr = await containers.resolveContainerAddress("my-service", 8080);
103
- if (!addr) throw new Error("Container address not available");
104
- // bare-metal → "127.0.0.1:8080" (or "127.0.0.1:8081" if 8080 was taken)
105
- // containerised → "sk-my-service:8080" (Docker DNS, no host port exposed)
106
- const response = await fetch(`http://${addr}/status`);
374
+ ```json
375
+ {
376
+ "mayara-server": { "memory": null }
377
+ }
378
+ ```
107
379
 
108
- // Run a one-shot job
109
- const result = await containers.runJob({
110
- image: "myorg/converter",
111
- command: ["convert", "--input", "/in/data.csv"],
112
- inputs: { "/in": "/host/path/input" },
113
- outputs: { "/out": "/host/path/output" },
114
- timeout: 120,
115
- });
380
+ After editing the file, restart the Container Manager plugin from the Signal K admin UI (or run the REST calls below) for the changes to take effect on running containers.
381
+
382
+ ### REST API (for scripts or external tools)
383
+
384
+ ```bash
385
+ # Read current state
386
+ curl http://localhost:3000/plugins/signalk-container/api/containers/mayara-server/resources
387
+
388
+ # Apply a new override (live or recreate as needed)
389
+ curl -X POST http://localhost:3000/plugins/signalk-container/api/containers/mayara-server/resources \
390
+ -H 'Content-Type: application/json' \
391
+ -d '{"cpus": 2}'
392
+
393
+ # Reset to plugin default (clear the override)
394
+ curl -X DELETE http://localhost:3000/plugins/signalk-container/api/containers/mayara-server/resources
116
395
  ```
117
396
 
118
- See [doc/plugin-developer-guide.md](doc/plugin-developer-guide.md) for the full integration guide with gotchas and patterns.
397
+ ### When changes take effect
119
398
 
120
- ## API
399
+ - **Immediately via the UI or REST API** (`updateResources`): signalk-container tries `podman update` / `docker update` first (instantaneous, no downtime). Falls back to stop+remove+create if the runtime can't apply the change live (e.g. unsetting memory limits, or changing `cpusetCpus` / `oomScoreAdj` which are set at container create time only).
400
+ - **On next consumer plugin restart**: the merge happens automatically inside `ensureRunning` — useful for installations that manage via JSON edits and don't want to use the REST API.
401
+ - **Persistence**: overrides applied via the UI or REST API are auto-persisted to `plugin-config-data/signalk-container.json` — they survive Signal K restarts without any extra action.
121
402
 
122
- | Method | Description |
123
- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
124
- | `getRuntime()` | Returns `{ runtime, version, isRootless, socketPath, ... }` (a `ContainerRuntimeInfo`) or `null` |
125
- | `whenReady()` | Resolves once runtime detection settles (success OR failure). Replaces the polling-loop pattern; check `getRuntime()` after the await |
126
- | `pullImage(image, onProgress?)` | Pull a container image (auto-qualifies for Podman) |
127
- | `imageExists(image)` | Check if image exists locally |
128
- | `getImageDigest(imageOrContainer)` | Local image ID (sha256) for an image:tag or container |
129
- | `ensureRunning(name, config, options?)` | Create and start container if not running; auto-recreates on config drift across `image`, `tag`, `command`, `networkMode`, `env`, `volumes`, `ports` |
130
- | `recreate(name, config, options?)` | Force-recreate: remove (if present) + ensureRunning. Always replaces an existing container (running or stopped) — use this for "update now" / plugin-startup self-heal flows where correctness must not depend on drift detection (1.12.0+) |
131
- | `start(name)` | Start a stopped container |
132
- | `stop(name)` | Stop a running container |
133
- | `remove(name)` | Stop and remove a container |
134
- | `getState(name)` | Returns `running`, `stopped`, `missing`, or `no-runtime` |
135
- | `runJob(config)` | Execute a one-shot container job |
136
- | `cleanupOrphanedJobs(filter)` | Reap `sk-job-*` containers leaked by a previous server lifecycle, filtered by the caller's `ownerPluginId`. Idempotent — returns `{ reaped: OrphanJobInfo[] }` so the plugin can roll back any per-job state it had written |
137
- | `getLogs(name, options?)` | One-shot fetch of the last N lines of a container's combined stdout+stderr log. `tail` defaults to 200, max 10000; `since` is unix-epoch seconds |
138
- | `prune()` | Remove dangling images |
139
- | `listContainers()` | List all `sk-` prefixed containers |
140
- | `execInContainer(name, command)` | Run a command inside a running container |
141
- | `ensureNetwork(name)` | Create a Podman/Docker network if it doesn't exist |
142
- | `removeNetwork(name)` | Remove a network |
143
- | `connectToNetwork(container, network)` | Add a container to a network (bridge mode only) |
144
- | `disconnectFromNetwork(container, net)` | Remove a container from a network |
145
- | `updates.register(reg)` | Register a container for update detection |
146
- | `updates.unregister(pluginId)` | Stop tracking updates for a plugin |
147
- | `updates.checkOne(pluginId)` | Force a fresh update check (or coalesce with in-flight) |
148
- | `updates.getLastResult(pluginId)` | Cached last result, no network |
149
- | `manifest.get(pluginId)` | Read the persisted manifest for one consumer plugin, or `null` if none. Writes happen automatically after successful `ensureRunning` calls — this is read-only |
150
- | `manifest.list()` | Return every persisted manifest in the data directory. Order is unspecified |
151
- | `manifest.getContainerHistory(containerName)` | Bounded history (max 20 entries) of digest changes for a specific container. Throws "Ambiguous container history" if more than one manifest references the same `containerName` — disambiguate via `manifest.get(pluginId)` |
152
- | `updateResources(name, limits)` | Apply new resource limits live, fall back to recreate |
153
- | `getResources(name)` | Currently effective limits (plugin defaults ⊕ user override) |
154
- | `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 |
155
- | `resolveHostPath(absPath)` | Translate an arbitrary absolute path into the `{ source, subPath }` pair the runtime needs to mount it; handles bare-metal, bind, and named-volume topologies |
156
- | `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 |
157
- | `doctor.imageRunsAsUser(image, user?)` | Probe whether `image` runs cleanly under the host-UID mapping signalk-container will emit (1.8.0+). Never throws — returns `{ ok, output, error? }` |
158
- | `doctor.selfDeployment()` | Diagnose the Signal K deployment itself: socket resolution, daemon reachability, rootless/rootful detection, and (when containerised) self-container ID. Returns `{ status, remediation, ... }` — see `SelfDeploymentResult` in `src/types.ts` |
159
- | `doctor.generateSetupSnippet(format?, result?)` | Generate a ready-to-paste compose fragment (`format: "compose"`, default) or `podman/docker run` command (`format: "run"`) tailored to the detected runtime — wires up the socket bind-mount. Pure templating over `SelfDeploymentResult`; includes a `dockerfile` note (no image change needed) and operator notes. |
403
+ ### Verifying limits are applied
160
404
 
161
- ## REST Endpoints
405
+ Check the live container directly:
162
406
 
163
- All mounted at `/plugins/signalk-container/api/`:
407
+ ```bash
408
+ podman inspect sk-mayara-server --format '
409
+ cpus={{.HostConfig.NanoCpus}}
410
+ memory={{.HostConfig.Memory}}
411
+ pids={{.HostConfig.PidsLimit}}
412
+ '
413
+ ```
164
414
 
165
- | Method | Path | Description |
166
- | ------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
167
- | GET | `/runtime` | Detected runtime info |
168
- | GET | `/containers` | List managed containers |
169
- | GET | `/containers/:name/state` | Container state |
170
- | POST | `/containers/:name/start` | Start a stopped container |
171
- | POST | `/containers/:name/stop` | Stop a running container |
172
- | POST | `/containers/:name/remove` | Stop and remove a container |
173
- | GET | `/containers/:name/logs?tail=N&since=ts` | Last N lines of the container's combined stdout+stderr log (one-shot). `tail` defaults 200, max 10000 |
174
- | GET | `/containers/:name/logs/stream` | Server-Sent Events stream of live log lines. Closes when the container is removed or the client disconnects |
175
- | POST | `/prune` | Prune dangling images |
176
- | GET | `/updates` | List last update-check results |
177
- | GET | `/updates/:pluginId` | Last update-check result for one plugin |
178
- | POST | `/updates/:pluginId/check` | Force a fresh update check (HTTP 200 even when offline) |
179
- | GET | `/containers/:name/resources` | Effective resource limits + user override |
180
- | POST | `/containers/:name/resources` | Apply new resource limits (live or recreate). Body is a `ContainerResourceLimits` diff against the consumer plugin's default. |
181
- | DELETE | `/containers/:name/resources` | Clear any user override and restore the consumer plugin's pristine default limits to the running container. |
182
- | POST | `/doctor/image` | Probe whether an image runs cleanly under the live host-UID mapping. Body: `{ image, tag?, user? }`. Never 5xx for a failed probe — `{ ok: false, error }` is a successful response (1.8.0+). |
183
- | GET | `/doctor/deployment` | Diagnose this Signal K deployment: socket resolution, daemon reachability, rootless/rootful detection, self-container ID cascade. Returns a `SelfDeploymentResult` with `status` and copy-pasteable `remediation` lines. |
184
- | GET | `/doctor/snippet?format=compose\|run` | Generate a ready-to-paste compose fragment or shell command for setting up Signal K with this runtime. `text/plain` by default; pass `Accept: application/json` for the structured `SetupSnippetResult`. |
415
+ `NanoCpus` is in CPU-nanoseconds per second; `1500000000` = 1.5 cores. Memory is in bytes.
185
416
 
186
- ## Configuration
417
+ Or via the REST API:
187
418
 
188
- | Setting | Default | Description |
189
- | ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
190
- | Preferred runtime | `auto` | Auto-detect, or force `podman`/`docker` |
191
- | Auto-prune images | `weekly` | `off`, `weekly`, or `monthly` |
192
- | Max concurrent jobs | `2` | Limit parallel one-shot job executions |
193
- | Update check interval | `24h` | How often to check for container image updates (e.g. `24h`, `12h`, `1h`). Min 1h. |
194
- | Background update checks | `true` | Periodically check for updates in the background. Disable on metered connections — manual checks via the UI button still work. |
195
- | Disable user-namespace remap | `false` | Suppress rootless-Podman `--userns=keep-id` on filesystems that cannot be id-mapped (ZFS, some encrypted FS). Secondary escape hatch only — the recommended primary fix is host-side `fuse-overlayfs` storage (see [ZFS host notes](#zfs-host-notes)). |
196
- | Container overrides | `{}` | Per-container resource limits (CPU, memory, PIDs). Field-level merged on top of consumer plugin defaults. See dev guide. |
419
+ ```bash
420
+ curl http://localhost:3000/plugins/signalk-container/api/containers/mayara-server/resources | jq
421
+ # {
422
+ # "name": "mayara-server",
423
+ # "effective": { "cpus": 1.5, "memory": "512m", ... }, // what's actually applied
424
+ # "override": { "cpus": 1.5 } // only what the user changed
425
+ # }
426
+ ```
197
427
 
198
- ### ZFS and other idmap-incompatible filesystems
428
+ Note that `override` contains only the fields that differ from the consumer plugin's default — this minimization is automatic and lets future plugin default bumps flow through without you having to re-edit your override.
199
429
 
200
- Rootless Podman uses `--userns=keep-id` so files written into bind mounts land owned by the Signal K host user. On filesystems the Linux kernel cannot id-map — ZFS is the canonical case, some encrypted filesystems behave the same way — this mapping either fails at container create or triggers a multi-minute per-file `chown` sweep across the image layers.
430
+ ### Picking the right values
201
431
 
202
- The doctor (`GET /plugins/signalk-container/api/doctor/deployment`) flags this proactively when Podman is rootless and the storage root sits on a known-hazard filesystem; the response's `containerStorage.advice` array carries the up-to-date remediation steps. Two fixes exist, in order of preference:
432
+ 1. Run the container without overrides for a typical workload
433
+ 2. Watch resource use: `podman stats sk-mayara-server`
434
+ 3. Note peak CPU% and peak memory
435
+ 4. Set `cpus` ≈ peak / 100 + 25% headroom; `memory` ≈ peak rounded up + 25% headroom
436
+ 5. Re-test under load to make sure the container still functions inside its caps
203
437
 
204
- 1. **Switch the host's rootless Podman storage driver to `fuse-overlayfs`.** Recommended whenever possible. Avoids the chown sweep entirely while preserving correct bind-mount ownership for every image. See [Podman's storage configuration docs](https://github.com/containers/storage/blob/main/docs/containers-storage.conf.5.md) for the exact `storage.conf` form and migration steps; modern Podman releases also document `fuse-overlayfs` in `man containers-storage.conf`.
205
- 2. **Enable the "Disable user-namespace remap" setting** in this plugin's config panel. Escape hatch for hosts that cannot switch storage drivers. Bind-mount ownership stays correct for root-by-default managed containers (questdb, grafana, mayara); non-root images give up host-caller ownership in exchange for being able to start at all.
438
+ The plugin developer guide has a detailed walk-through in [doc/plugin-developer-guide.md#resource-limits](doc/plugin-developer-guide.md#resource-limits).
206
439
 
207
- If the host runs a recent-enough kernel + Podman + ZFS combination that supports kernel-level idmapped mounts natively, neither workaround is required — the doctor advisory will fall silent on its own once the hazard heuristic no longer matches.
440
+ > If you're running Signal K inside a container and a memory limit
441
+ > appears to be ignored, the host's cgroup controller delegation is
442
+ > almost certainly the cause — see [Cgroup controller delegation](#cgroup-controller-delegation) above.
208
443
 
209
444
  ## Mounting the SignalK data directory (`signalkDataMount`)
210
445
 
@@ -342,370 +577,149 @@ The allocated address is cached for the lifetime of the plugin session, so repea
342
577
  > a manual `ports` or `networkMode` entry for the same container — the field takes
343
578
  > full ownership of those concerns.
344
579
 
345
- ## Setting Resource Limits
346
-
347
- 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).
348
-
349
- ### How it works
350
-
351
- Each consumer plugin (signalk-questdb, signalk-grafana, mayara, etc.) declares default CPU/memory limits when it starts its container. Your override is **merged field-by-field** on top of the plugin's defaults, and only the fields that actually differ from the default get stored. This means if a future plugin version bumps its memory default from 512m to 1g, your override for just `cpus` will automatically pick up the new memory value — no manual edit needed.
352
-
353
- ### Using the Config Panel (recommended)
354
-
355
- 1. Open the Signal K admin UI → Plugin Config → **Container Manager**
356
- 2. Find the container you want to tune in the "Managed Containers" list
357
- 3. Click **Edit Limits ▸** on the row
358
- 4. Edit the CPU cores, Memory, Memory+swap, or Max processes fields. Use the × button next to a field to explicitly unset a limit the plugin set. Click **Advanced** to access cpuShares, cpusetCpus, memoryReservation, and oomScoreAdj.
359
- 5. Click **Apply** — live updated where possible (no downtime), recreated where needed. The result box shows which method was used plus any warnings.
360
- 6. To restore the plugin's default: click **Reset to default** (amber button). This clears your override and applies the pristine default to the running container.
361
-
362
- The form re-seeds from the server's fresh state after every Apply or Reset, so the displayed values always match what's actually running.
363
-
364
- ### Available fields
580
+ ---
365
581
 
366
- | Field | Example | What it does |
367
- | ------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
368
- | `cpus` | `1.5` | Hard CPU cap. `1.5` = max 1.5 cores. The most important field for stability. |
369
- | `cpuShares` | `512` | Soft CPU weight under contention (default 1024). Lower = lower priority. |
370
- | `cpusetCpus` | `"1,2"` | Pin to specific cores. Useful to keep heavy containers off core 0 where Signal K runs. May force a recreate on hosts where the cpuset cgroup controller isn't delegated. |
371
- | `memory` | `"512m"`, `"2g"` | Hard memory cap. Container is OOM-killed if exceeded. |
372
- | `memorySwap` | `"512m"` | Memory + swap total. **Set equal to `memory` to disable swap entirely** — recommended on Pi/eMMC where swap is slow. |
373
- | `memoryReservation` | `"256m"` | Soft memory floor. Kernel reclaims first from containers above this. |
374
- | `pidsLimit` | `200` | Cap on processes/threads. Prevents fork bombs and thread leaks. |
375
- | `oomScoreAdj` | `500` | OOM kill priority, -1000..1000. Higher = killed first when host runs out of memory. Set at container create time only — forces a recreate when changed. |
582
+ # Developer / plugin-author reference
376
583
 
377
- ### Direct JSON (scripted/advanced)
584
+ The sections below are for plugin authors integrating with signalk-container. End users deploying the plugin do not need them.
378
585
 
379
- The UI writes to a `containerOverrides` map in `plugin-config-data/signalk-container.json`. You can edit this directly if you prefer — useful for automation or bulk configuration:
586
+ ## How Other Plugins Use It
380
587
 
381
- ```json
382
- {
383
- "configuration": {
384
- "containerOverrides": {
385
- "mayara-server": {
386
- "cpus": 1.5,
387
- "memory": "512m",
388
- "memorySwap": "512m"
389
- }
390
- }
391
- }
588
+ ```typescript
589
+ const containers = (globalThis as any).__signalk_containerManager;
590
+ if (!containers) {
591
+ app.setPluginError("signalk-container plugin required");
592
+ return;
392
593
  }
393
- ```
394
-
395
- The key (`mayara-server`) is the container name **without** the `sk-` prefix that signalk-container adds internally. Use `null` for a field to explicitly remove a limit set by the plugin:
396
594
 
397
- ```json
398
- {
399
- "mayara-server": { "memory": null }
595
+ // Wait for runtime detection to settle, then verify a runtime was found.
596
+ await containers.whenReady();
597
+ if (!containers.getRuntime()) {
598
+ app.setPluginError("No container runtime detected");
599
+ return;
400
600
  }
401
- ```
402
601
 
403
- After editing the file, restart the Container Manager plugin from the Signal K admin UI (or run the REST calls below) for the changes to take effect on running containers.
404
-
405
- ### REST API (for scripts or external tools)
406
-
407
- ```bash
408
- # Read current state
409
- curl http://localhost:3000/plugins/signalk-container/api/containers/mayara-server/resources
410
-
411
- # Apply a new override (live or recreate as needed)
412
- curl -X POST http://localhost:3000/plugins/signalk-container/api/containers/mayara-server/resources \
413
- -H 'Content-Type: application/json' \
414
- -d '{"cpus": 2}'
415
-
416
- # Reset to plugin default (clear the override)
417
- curl -X DELETE http://localhost:3000/plugins/signalk-container/api/containers/mayara-server/resources
418
- ```
419
-
420
- ### When changes take effect
421
-
422
- - **Immediately via the UI or REST API** (`updateResources`): signalk-container tries `podman update` / `docker update` first (instantaneous, no downtime). Falls back to stop+remove+create if the runtime can't apply the change live (e.g. unsetting memory limits, or changing `cpusetCpus` / `oomScoreAdj` which are set at container create time only).
423
- - **On next consumer plugin restart**: the merge happens automatically inside `ensureRunning` — useful for installations that manage via JSON edits and don't want to use the REST API.
424
- - **Persistence**: overrides applied via the UI or REST API are auto-persisted to `plugin-config-data/signalk-container.json` — they survive Signal K restarts without any extra action.
425
-
426
- ### Verifying limits are applied
427
-
428
- Check the live container directly:
429
-
430
- ```bash
431
- podman inspect sk-mayara-server --format '
432
- cpus={{.HostConfig.NanoCpus}}
433
- memory={{.HostConfig.Memory}}
434
- pids={{.HostConfig.PidsLimit}}
435
- '
436
- ```
437
-
438
- `NanoCpus` is in CPU-nanoseconds per second; `1500000000` = 1.5 cores. Memory is in bytes.
439
-
440
- Or via the REST API:
441
-
442
- ```bash
443
- curl http://localhost:3000/plugins/signalk-container/api/containers/mayara-server/resources | jq
444
- # {
445
- # "name": "mayara-server",
446
- # "effective": { "cpus": 1.5, "memory": "512m", ... }, // what's actually applied
447
- # "override": { "cpus": 1.5 } // only what the user changed
448
- # }
449
- ```
450
-
451
- Note that `override` contains only the fields that differ from the consumer plugin's default — this minimization is automatic and lets future plugin default bumps flow through without you having to re-edit your override.
452
-
453
- ### Picking the right values
454
-
455
- 1. Run the container without overrides for a typical workload
456
- 2. Watch resource use: `podman stats sk-mayara-server`
457
- 3. Note peak CPU% and peak memory
458
- 4. Set `cpus` ≈ peak / 100 + 25% headroom; `memory` ≈ peak rounded up + 25% headroom
459
- 5. Re-test under load to make sure the container still functions inside its caps
460
-
461
- The plugin developer guide has a detailed walk-through in [doc/plugin-developer-guide.md#resource-limits](doc/plugin-developer-guide.md#resource-limits).
462
-
463
- > If you're running Signal K inside a container and a memory limit
464
- > appears to be ignored, the host's cgroup controller delegation is
465
- > almost certainly the cause — see [Cgroup controller delegation](#cgroup-controller-delegation) below.
466
-
467
- ## Requirements
468
-
469
- - Node.js >= 22
470
- - Podman or Docker installed on the host
471
- - Signal K server
472
-
473
- ## Running Signal K in a Container
474
-
475
- If your Signal K server itself runs inside a container (Docker, Podman),
476
- this plugin needs access to the host's container runtime to manage other
477
- containers. The plugin auto-detects this scenario via `/.dockerenv` or
478
- `/run/.containerenv` and prefixes the status with `(in-container)`.
479
-
480
- For the plugin to work, two things must be true inside the Signal K
481
- container:
482
-
483
- 1. **A matching runtime CLI is available** — the CLI inside the SK
484
- container must match the daemon on the host (Docker host → `docker`;
485
- Podman host → `podman`). End users typically bind-mount the host
486
- binary; image maintainers can bake it into a custom image.
487
- 2. **The matching runtime socket is bind-mounted** from the host
488
- (rootless or rootful podman, or docker).
489
-
490
- Concrete platform-specific commands — for both end-user and
491
- image-maintainer setups — are emitted by the deployment doctor (see
492
- `/api/doctor/deployment` and the snippet generator below). Use those
493
- as the source of truth; they always reflect the running plugin
494
- version.
495
-
496
- ### Quick check: `/api/doctor/deployment`
497
-
498
- The plugin ships a self-diagnostic. After starting, hit:
499
-
500
- ```bash
501
- curl http://<signalk-host>:3000/plugins/signalk-container/api/doctor/deployment
502
- ```
503
-
504
- The response includes a `status` field (`ok` / `no-runtime` /
505
- `socket-unreachable` / `permission-denied` / `self-id-unresolved` /
506
- `cgroup-controllers-incomplete`) and a `remediation` array of
507
- copy-pasteable lines for whichever failure mode applies. When startup
508
- detection fails, the same remediation is also logged to the Signal K
509
- server log.
510
-
511
- A separate `cgroupControllers` field on the response reports the
512
- delegated cgroup v2 controllers and which expected ones are missing —
513
- see [Cgroup controller delegation](#cgroup-controller-delegation) for
514
- what missing controllers mean for resource limits.
515
-
516
- ### Generate a starter snippet
517
-
518
- To bootstrap a new deployment, ask the plugin for a ready-to-paste
519
- compose fragment or shell command tailored to the detected runtime:
520
-
521
- ```bash
522
- curl 'http://<signalk-host>:3000/plugins/signalk-container/api/doctor/snippet?format=compose' > docker-compose.yml
523
- curl 'http://<signalk-host>:3000/plugins/signalk-container/api/doctor/snippet?format=run' > run-signalk.sh
524
- ```
525
-
526
- The endpoint returns plain text by default; pass
527
- `Accept: application/json` to get the structured
528
- `SetupSnippetResult` (snippet + Dockerfile sidecar + operator notes)
529
- for programmatic consumers.
530
-
531
- ### Rootless Podman (recommended)
532
-
533
- The cleanest setup. Runs as your user, not root, so the security
534
- exposure is limited to your user account rather than the entire host —
535
- and matches signalk-container's default behaviour.
536
-
537
- On the host, ensure the user-scoped podman socket is enabled:
538
-
539
- ```bash
540
- systemctl --user enable --now podman.socket
541
- ```
542
-
543
- Then in your compose / `podman run`:
544
-
545
- ```yaml
546
- services:
547
- signalk:
548
- image: your-signalk-image-with-podman-remote
549
- user: "${UID}:${GID}" # match the uid that owns the host's podman socket
550
- volumes:
551
- - /run/user/${UID}/podman/podman.sock:/run/user/${UID}/podman/podman.sock
552
- environment:
553
- - CONTAINER_HOST=unix:///run/user/${UID}/podman/podman.sock
554
- ```
555
-
556
- Your image's Dockerfile should include `podman` or `podman-remote`:
557
-
558
- ```dockerfile
559
- RUN apt-get update && apt-get install -y podman # Debian/Ubuntu
560
- # or:
561
- RUN dnf install -y podman-remote # Fedora/RHEL
562
- ```
563
-
564
- ### Rootful Podman
565
-
566
- ```yaml
567
- services:
568
- signalk:
569
- image: your-signalk-image-with-podman
570
- volumes:
571
- - /run/podman/podman.sock:/run/podman/podman.sock
572
- environment:
573
- - CONTAINER_HOST=unix:///run/podman/podman.sock
574
- ```
575
-
576
- ### Docker
577
-
578
- ```yaml
579
- services:
580
- signalk:
581
- image: your-signalk-image-with-docker-cli
582
- volumes:
583
- - /var/run/docker.sock:/var/run/docker.sock
584
- group_add:
585
- - "<docker-gid-from-host>" # `getent group docker | cut -d: -f3`
586
- ```
587
-
588
- > [!warning]
589
- > Mounting `/var/run/docker.sock` gives the container **root-equivalent
590
- > access to the host**. Anyone who compromises Signal K (including via
591
- > a malicious plugin) can take over the entire host. Prefer rootless
592
- > Podman for production.
593
-
594
- ### Networking caveats
595
-
596
- When Signal K runs in a container, containers spawned by this plugin
597
- are **siblings** on the host's container network, not inside Signal K's
598
- network namespace. This affects:
599
-
600
- - The shared `sk-network` works only if Signal K is also attached to it
601
- (add it externally or via the same compose file)
602
- - `host.containers.internal` from spawned containers points to the host
603
- itself, not the Signal K container — use Signal K's container name
604
- for direct communication. signalk-container 1.8.0+ adds this hostname
605
- to Docker containers automatically (Podman already provides it); set
606
- `ContainerConfig.extraHosts` to override it or to add other hostnames.
607
-
608
- ### Cgroup controller delegation
609
-
610
- When Signal K runs inside a rootless container, the kernel only enforces
611
- those resource limits whose **cgroup controller has been delegated** down
612
- to the SK container's cgroup. Anything else passed to `podman run` is
613
- silently ignored — your `--memory 1g` request reaches the runtime, but
614
- the cgroup never gets a memory cap and the container can grow without
615
- bound.
616
-
617
- The most common culprit is `memory`: many distros delegate `cpu`,
618
- `cpuset`, `io`, and `pids` to user sessions by default, but `memory`
619
- must be explicitly added.
620
-
621
- signalk-container 1.9.0+ probes the available controllers via
622
- `/sys/fs/cgroup/cgroup.controllers` and **silently drops** unsupported
623
- limit fields before invoking podman — better than crashing the
624
- container, but the user-visible effect is "I set memory to 1 GB and
625
- nothing happened." The drop is logged at `debug` level with the reason
626
- (`cgroup controller 'memory' not delegated to podman (available: cpuset,
627
- cpu, io, pids)`); the live `effective` resource response also reflects
628
- only what's actually in place.
629
-
630
- **The deployment doctor flags this automatically** with `status:
631
- cgroup-controllers-incomplete` and a ready-to-paste remediation block.
632
- Hit `/api/doctor/deployment` (see above) and check the `status` and
633
- `cgroupControllers` fields.
634
-
635
- **Manual check from a shell:**
636
-
637
- ```bash
638
- podman exec <sk-container> cat /sys/fs/cgroup/cgroup.controllers
639
- # cpuset cpu io memory pids ← memory delegated, all limits work
640
- # cpuset cpu io pids ← memory missing, --memory is dropped
641
- ```
602
+ // Start a long-running service container. ensureRunning compares this
603
+ // config against the live container and recreates on drift — no per-
604
+ // plugin hash file or remove() dance needed.
605
+ await containers.ensureRunning("my-service", {
606
+ image: "myorg/myimage",
607
+ tag: "latest",
608
+ signalkDataMount: "/data", // resolves to the SignalK data dir, regardless of deployment
609
+ signalkAccessiblePorts: [8080], // port 8080 in the container must be reachable by SignalK
610
+ env: { MY_VAR: "value" },
611
+ restart: "unless-stopped",
612
+ });
642
613
 
643
- **Enable memory delegation on the host** (one-time, requires sudo):
614
+ // Get the actual address to connect to (resolved after ensureRunning)
615
+ const addr = await containers.resolveContainerAddress("my-service", 8080);
616
+ if (!addr) throw new Error("Container address not available");
617
+ // bare-metal → "127.0.0.1:8080" (or "127.0.0.1:8081" if 8080 was taken)
618
+ // containerised → "sk-my-service:8080" (Docker DNS, no host port exposed)
619
+ const response = await fetch(`http://${addr}/status`);
644
620
 
645
- ```bash
646
- sudo mkdir -p /etc/systemd/system/user@.service.d
647
- sudo tee /etc/systemd/system/user@.service.d/delegate.conf <<'EOF'
648
- [Service]
649
- Delegate=cpu cpuset io memory pids
650
- EOF
651
- sudo systemctl daemon-reload
652
- # Log the SK-owning user out and back in (or reboot) so a fresh user@.service starts.
621
+ // Run a one-shot job
622
+ const result = await containers.runJob({
623
+ image: "myorg/converter",
624
+ command: ["convert", "--input", "/in/data.csv"],
625
+ inputs: { "/in": "/host/path/input" },
626
+ outputs: { "/out": "/host/path/output" },
627
+ timeout: 120,
628
+ });
653
629
  ```
654
630
 
655
- After re-login, re-running the consumer plugin's `ensureRunning` (or
656
- just restarting Signal K) recreates the managed container with the
657
- memory cap actually applied. Verify with `podman inspect sk-<name>
658
- --format '{{.HostConfig.Memory}}'` — a non-zero value confirms the cap
659
- is in cgroup state, not just on the command line.
631
+ See [doc/plugin-developer-guide.md](doc/plugin-developer-guide.md) for the full integration guide with gotchas and patterns.
660
632
 
661
- This is purely a host-side prerequisite; signalk-container cannot
662
- override the kernel's controller delegation.
633
+ ## API
663
634
 
664
- #### Raspberry Pi OS: `cgroup_disable=memory` in the kernel cmdline
635
+ | Method | Description |
636
+ | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
637
+ | `getRuntime()` | Returns `{ runtime, version, isRootless, socketPath, ... }` (a `ContainerRuntimeInfo`) or `null` |
638
+ | `whenReady()` | Resolves once runtime detection settles (success OR failure). Replaces the polling-loop pattern; check `getRuntime()` after the await |
639
+ | `pullImage(image, onProgress?)` | Pull a container image (auto-qualifies for Podman) |
640
+ | `imageExists(image)` | Check if image exists locally |
641
+ | `getImageDigest(imageOrContainer)` | Local image ID (sha256) for an image:tag or container |
642
+ | `ensureRunning(name, config, options?)` | Create and start container if not running; auto-recreates on config drift across `image`, `tag`, `command`, `networkMode`, `env`, `volumes`, `ports` |
643
+ | `recreate(name, config, options?)` | Force-recreate: remove (if present) + ensureRunning. Always replaces an existing container (running or stopped) — use this for "update now" / plugin-startup self-heal flows where correctness must not depend on drift detection (1.12.0+) |
644
+ | `start(name)` | Start a stopped container |
645
+ | `stop(name)` | Stop a running container |
646
+ | `remove(name)` | Stop and remove a container |
647
+ | `getState(name)` | Returns `running`, `stopped`, `missing`, or `no-runtime` |
648
+ | `runJob(config)` | Execute a one-shot container job |
649
+ | `cleanupOrphanedJobs(filter)` | Reap `sk-job-*` containers leaked by a previous server lifecycle, filtered by the caller's `ownerPluginId`. Idempotent — returns `{ reaped: OrphanJobInfo[] }` so the plugin can roll back any per-job state it had written |
650
+ | `getLogs(name, options?)` | One-shot fetch of the last N lines of a container's combined stdout+stderr log. `tail` defaults to 200, max 10000; `since` is unix-epoch seconds |
651
+ | `prune()` | Remove dangling images |
652
+ | `listContainers()` | List all `sk-` prefixed containers |
653
+ | `execInContainer(name, command)` | Run a command inside a running container |
654
+ | `ensureNetwork(name)` | Create a Podman/Docker network if it doesn't exist |
655
+ | `removeNetwork(name)` | Remove a network |
656
+ | `connectToNetwork(container, network)` | Add a container to a network (bridge mode only) |
657
+ | `disconnectFromNetwork(container, net)` | Remove a container from a network |
658
+ | `updates.register(reg)` | Register a container for update detection |
659
+ | `updates.unregister(pluginId)` | Stop tracking updates for a plugin |
660
+ | `updates.checkOne(pluginId)` | Force a fresh update check (or coalesce with in-flight) |
661
+ | `updates.checkAll()` | Force a fresh update check for every registered plugin; resolves to the array of results |
662
+ | `updates.getLastResult(pluginId)` | Cached last result, no network |
663
+ | `updates.sources` | Built-in version-source factories — `sources.githubReleases(repo, options?)` and `sources.dockerHubTags(image, options?)` — convenience for building an `UpdateRegistration.source` |
664
+ | `manifest.get(pluginId)` | Read the persisted manifest for one consumer plugin, or `null` if none. Writes happen automatically after successful `ensureRunning` calls — this is read-only |
665
+ | `manifest.list()` | Return every persisted manifest in the data directory. Order is unspecified |
666
+ | `manifest.getContainerHistory(containerName)` | Bounded history (max 20 entries) of digest changes for a specific container. Throws "Ambiguous container history" if more than one manifest references the same `containerName` — disambiguate via `manifest.get(pluginId)` |
667
+ | `updateResources(name, limits)` | Apply new resource limits live, fall back to recreate |
668
+ | `getResources(name)` | Currently effective limits (plugin defaults ⊕ user override) |
669
+ | `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 |
670
+ | `resolveHostPath(absPath)` | Translate an arbitrary absolute path into the `{ source, subPath }` pair the runtime needs to mount it; handles bare-metal, bind, and named-volume topologies |
671
+ | `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 |
672
+ | `doctor.imageRunsAsUser(image, user?)` | Probe whether `image` runs cleanly under the host-UID mapping signalk-container will emit (1.8.0+). Never throws — returns `{ ok, output, error? }` |
673
+ | `doctor.selfDeployment()` | Diagnose the Signal K deployment itself: socket resolution, daemon reachability, rootless/rootful detection, and (when containerised) self-container ID. Returns `{ status, remediation, ... }` — see `SelfDeploymentResult` in `src/types.ts` |
674
+ | `doctor.generateSetupSnippet(format?, result?)` | Generate a ready-to-paste compose fragment (`format: "compose"`, default) or `podman/docker run` command (`format: "run"`) tailored to the detected runtime — wires up the socket bind-mount. Pure templating over `SelfDeploymentResult`; includes a `dockerfile` note (no image change needed) and operator notes. |
665
675
 
666
- If you're on a Raspberry Pi 4/5 running Raspberry Pi OS Trixie (and
667
- likely earlier Pi OS releases) and the systemd `Delegate=memory` snippet
668
- above **doesn't work** — `cat /sys/fs/cgroup/cgroup.controllers` still
669
- shows `cpuset cpu io pids` after a reboot — the cause is one level
670
- deeper. The Pi's GPU firmware injects `cgroup_disable=memory` into the
671
- kernel boot cmdline, so the memory controller never reaches systemd.
676
+ ## REST Endpoints
672
677
 
673
- Quick check:
678
+ All mounted at `/plugins/signalk-container/api/`:
674
679
 
675
- ```bash
676
- grep -o "cgroup_disable=memory" /proc/cmdline
677
- # Prints "cgroup_disable=memory" you're hit.
678
- ```
680
+ | Method | Path | Description |
681
+ | ------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
682
+ | GET | `/runtime` | Detected runtime info |
683
+ | GET | `/containers` | List managed containers |
684
+ | GET | `/containers/:name/state` | Container state |
685
+ | POST | `/containers/:name/start` | Start a stopped container |
686
+ | POST | `/containers/:name/stop` | Stop a running container |
687
+ | POST | `/containers/:name/remove` | Stop and remove a container |
688
+ | GET | `/containers/:name/logs?tail=N&since=ts` | Last N lines of the container's combined stdout+stderr log (one-shot). `tail` defaults 200, max 10000 |
689
+ | GET | `/containers/:name/logs/stream` | Server-Sent Events stream of live log lines. Closes when the container is removed or the client disconnects |
690
+ | POST | `/prune` | Prune dangling images |
691
+ | GET | `/updates` | List last update-check results |
692
+ | GET | `/updates/:pluginId` | Last update-check result for one plugin |
693
+ | POST | `/updates/:pluginId/check` | Force a fresh update check (HTTP 200 even when offline) |
694
+ | GET | `/containers/:name/resources` | Effective resource limits + user override |
695
+ | POST | `/containers/:name/resources` | Apply new resource limits (live or recreate). Body is a `ContainerResourceLimits` diff against the consumer plugin's default. |
696
+ | DELETE | `/containers/:name/resources` | Clear any user override and restore the consumer plugin's pristine default limits to the running container. |
697
+ | POST | `/doctor/image` | Probe whether an image runs cleanly under the live host-UID mapping. Body: `{ image, tag?, user? }`. Never 5xx for a failed probe — `{ ok: false, error }` is a successful response (1.8.0+). |
698
+ | GET | `/doctor/deployment` | Diagnose this Signal K deployment: socket resolution, daemon reachability, rootless/rootful detection, self-container ID cascade. Returns a `SelfDeploymentResult` with `status` and copy-pasteable `remediation` lines. |
699
+ | GET | `/doctor/snippet?format=compose\|run` | Generate a ready-to-paste compose fragment or shell command for setting up Signal K with this runtime. `text/plain` by default; pass `Accept: application/json` for the structured `SetupSnippetResult`. |
679
700
 
680
- Full runbook with copy-pasteable commands, verification steps, and
681
- revert instructions: **[doc/cgroup-memory-on-raspberry-pi-os.md](doc/cgroup-memory-on-raspberry-pi-os.md)**.
701
+ ## Configuration
682
702
 
683
- The deployment doctor at `/api/doctor/deployment` also detects this
684
- scenario and surfaces the same kernel-cmdline fix in its `remediation`
685
- array, so you don't have to guess which layer is broken first.
703
+ | Setting | Default | Description |
704
+ | ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
705
+ | Preferred runtime | `auto` | Auto-detect, or force `podman`/`docker` |
706
+ | Auto-prune images | `weekly` | `off`, `weekly`, or `monthly` |
707
+ | Max concurrent jobs | `2` | Limit parallel one-shot job executions |
708
+ | Update check interval | `24h` | How often to check for container image updates (e.g. `24h`, `12h`, `1h`). Min 1h. |
709
+ | Background update checks | `true` | Periodically check for updates in the background. Disable on metered connections — manual checks via the UI button still work. |
710
+ | Disable user-namespace remap | `false` | Suppress rootless-Podman `--userns=keep-id` on filesystems that cannot be id-mapped (ZFS, some encrypted FS). Secondary escape hatch only — the recommended primary fix is host-side `fuse-overlayfs` storage (see [ZFS host notes](#zfs-and-other-idmap-incompatible-filesystems)). |
711
+ | Container overrides | `{}` | Per-container resource limits (CPU, memory, PIDs). Field-level merged on top of consumer plugin defaults. See dev guide. |
686
712
 
687
- ### Watch out for systemd auto-restart (Quadlet / `Restart=always`)
713
+ ### ZFS and other idmap-incompatible filesystems
688
714
 
689
- If you run Signal K via a podman Quadlet (`*.container` in
690
- `~/.config/containers/systemd/`) or a systemd unit with
691
- `Restart=always`, the unit silently restarts the SK container within
692
- `RestartSec` seconds of any stop — including operator-initiated
693
- `podman stop`. This races with manually-started replacement containers
694
- on the same port.
715
+ Rootless Podman uses `--userns=keep-id` so files written into bind mounts land owned by the Signal K host user. On filesystems the Linux kernel cannot id-map — ZFS is the canonical case, some encrypted filesystems behave the same way — this mapping either fails at container create or triggers a multi-minute per-file `chown` sweep across the image layers.
695
716
 
696
- For test/diagnostic swaps, temporarily disable the unit's
697
- restart/recovery policy before stopping the container and re-enable it
698
- afterward. With a `--user` Quadlet (substitute your actual unit name):
717
+ The doctor (`GET /plugins/signalk-container/api/doctor/deployment`) flags this proactively when Podman is rootless and the storage root sits on a known-hazard filesystem; the response's `containerStorage.advice` array carries the up-to-date remediation steps. Two fixes exist, in order of preference:
699
718
 
700
- ```bash
701
- systemctl --user mask <your-signalk-unit>.service # suppress auto-restart
702
- # … run your test container on port 3000 …
703
- systemctl --user unmask <your-signalk-unit>.service # re-enable
704
- systemctl --user start <your-signalk-unit>.service
705
- ```
719
+ 1. **Switch the host's rootless Podman storage driver to `fuse-overlayfs`.** Recommended whenever possible. Avoids the chown sweep entirely while preserving correct bind-mount ownership for every image. See [Podman's storage configuration docs](https://github.com/containers/storage/blob/main/docs/containers-storage.conf.5.md) for the exact `storage.conf` form and migration steps; modern Podman releases also document `fuse-overlayfs` in `man containers-storage.conf`.
720
+ 2. **Enable the "Disable user-namespace remap" setting** in this plugin's config panel. Escape hatch for hosts that cannot switch storage drivers. Bind-mount ownership stays correct for root-by-default managed containers (questdb, grafana, mayara); non-root images give up host-caller ownership in exchange for being able to start at all.
706
721
 
707
- This is purely an operator-side consideration; signalk-container has no
708
- visibility into systemd-managed lifecycles.
722
+ If the host runs a recent-enough kernel + Podman + ZFS combination that supports kernel-level idmapped mounts natively, neither workaround is required — the doctor advisory will fall silent on its own once the hazard heuristic no longer matches.
709
723
 
710
724
  ## License
711
725