signalk-container 1.14.0 → 1.15.0-beta.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.
Files changed (59) hide show
  1. package/AGENTS.md +19 -28
  2. package/README.md +61 -61
  3. package/dist/client.d.ts +119 -0
  4. package/dist/client.d.ts.map +1 -0
  5. package/dist/client.js +207 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/containers.d.ts +44 -48
  8. package/dist/containers.d.ts.map +1 -1
  9. package/dist/containers.js +632 -566
  10. package/dist/containers.js.map +1 -1
  11. package/dist/doctor.d.ts +27 -17
  12. package/dist/doctor.d.ts.map +1 -1
  13. package/dist/doctor.js +188 -206
  14. package/dist/doctor.js.map +1 -1
  15. package/dist/errors.d.ts +21 -0
  16. package/dist/errors.d.ts.map +1 -0
  17. package/dist/errors.js +93 -0
  18. package/dist/errors.js.map +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +5 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/jobs.d.ts +24 -14
  23. package/dist/jobs.d.ts.map +1 -1
  24. package/dist/jobs.js +211 -169
  25. package/dist/jobs.js.map +1 -1
  26. package/dist/log-stream-broker.d.ts.map +1 -1
  27. package/dist/log-stream-broker.js +10 -9
  28. package/dist/log-stream-broker.js.map +1 -1
  29. package/dist/manifest/resolver.d.ts +5 -5
  30. package/dist/manifest/resolver.d.ts.map +1 -1
  31. package/dist/manifest/resolver.js.map +1 -1
  32. package/dist/resources.d.ts +49 -18
  33. package/dist/resources.d.ts.map +1 -1
  34. package/dist/resources.js +91 -65
  35. package/dist/resources.js.map +1 -1
  36. package/dist/runtime.d.ts +34 -104
  37. package/dist/runtime.d.ts.map +1 -1
  38. package/dist/runtime.js +117 -540
  39. package/dist/runtime.js.map +1 -1
  40. package/dist/types.d.ts +46 -19
  41. package/dist/types.d.ts.map +1 -1
  42. package/doc/plugin-developer-guide.md +12 -9
  43. package/doc/run-in-container.md +14 -19
  44. package/package.json +3 -1
  45. package/public/chunks/_virtual_mf-localSharedImportMap___mfe_internal__signalk_container-BEsDhqjG.js +1 -0
  46. package/public/chunks/{dist-BH59CfQQ.js → dist-B5XYILWI.js} +2 -2
  47. package/public/chunks/hostInit-B3-ZMCBI.js +1 -0
  48. package/public/chunks/{module-runner-CbJauOmk.js → module-runner-DgpG11zr.js} +1 -1
  49. package/public/chunks/ssrEntryLoader-LO6v5LYY.js +2 -0
  50. package/public/chunks/virtualExposes-BvqLyGjU.js +1 -0
  51. package/public/chunks/virtual_mf-exposes-ssr___mfe_internal__signalk_container__remoteEntry_js-BM72a-CN.js +1 -0
  52. package/public/remoteEntry.js +1 -1
  53. package/public/remoteEntry.ssr.js +1 -1
  54. package/public/chunks/_virtual_mf-localSharedImportMap___mfe_internal__signalk_container-B5ZtI5V1.js +0 -1
  55. package/public/chunks/hostInit-CRh9gWaN.js +0 -1
  56. package/public/chunks/ssrEntryLoader-BhCQG3xs.js +0 -2
  57. package/public/chunks/virtualExposes-DCpxtclj.js +0 -1
  58. package/public/chunks/virtual_mf-exposes-ssr___mfe_internal__signalk_container__remoteEntry_js-gIva5E95.js +0 -1
  59. /package/public/chunks/{preload-helper-2rxlSZO5.js → vite-preload-helper-2rxlSZO5.js} +0 -0
package/AGENTS.md CHANGED
@@ -5,10 +5,11 @@ Shared container runtime management (Podman/Docker) for Signal K plugins. This p
5
5
  Key components:
6
6
 
7
7
  - **`src/index.ts`** — Signal K plugin entrypoint. Wires the runtime probe, exposes the `ContainerManagerApi` on `globalThis`, owns the REST endpoints (`/plugins/signalk-container/api/...`) and the React config panel mount.
8
- - **`src/containers.ts`** — Thin runtime layer. Pure functions over `execRuntime` for lifecycle (`ensureRunning`, `removeContainer`, `getContainerState`), config-drift detection (`getLiveContainerConfig`, `diffContainerConfig`), and live-state probes (`getLiveResources`, `getActualPortBindings`).
8
+ - **`src/client.ts`** — The dockerode socket client. Owns socket selection (`resolveClient`), the shared `Docker` singleton (`getClient`), the `ContainerClient` injection interface, the `safe()`/`safeInspect()` error-normalizing wrappers, and the log-stream demux helpers. Every runtime call in the plugin goes through here.
9
+ - **`src/containers.ts`** — Thin runtime layer over the dockerode `ContainerClient` for lifecycle (`ensureRunning`, `removeContainer`, `getContainerState`), config-drift detection (`getLiveContainerConfig`, `diffContainerConfig`), and live-state probes (`getLiveResources`, `getActualPortBindings`).
9
10
  - **`src/jobs.ts`** — One-shot helper containers via `runJob`. Used by chart-provider and similar plugins that need short-lived workers (GDAL, tippecanoe, etc.).
10
11
  - **`src/resources.ts`** — cgroup-limit flag emission + live-update path via `podman/docker update`. The "Bug D" precedent for diff-on-already-running lives here.
11
- - **`src/runtime.ts`** — Runtime detection (`podman` vs `docker`), version probing, `execRuntime`/`execRuntimeLong` dispatch, `isContainerized()` self-detection.
12
+ - **`src/runtime.ts`** — Runtime detection over the socket (`detectRuntime` resolves a socket via `client.ts`, classifies `podman` vs `docker` from `version()`, reads `isRootless`/`cgroupControllers`), the `userMappingFlags` UID-mapping decision matrix, and `isContainerized()` self-detection.
12
13
  - **`src/updates/`** — Centralized image-update detection (digest drift for floating tags, version comparison for semver). Used by all consumer plugins via `containers.updates.register(...)`.
13
14
  - **`src/configpanel/`** — React config panel source. Built with Vite + `@module-federation/vite` (see `vite.config.ts`); build artifacts land in `public/`, served via Module Federation into the Signal K Admin UI.
14
15
 
@@ -42,7 +43,7 @@ Do not add error handling, fallbacks, or validation for scenarios that cannot ha
42
43
  - `npm run test:integration` — integration only (`dist/test/integration/*.test.js`). Requires podman or docker; tests still self-skip on Windows and when no runtime is found.
43
44
  - `npm run test:all` — both. The pre-PR full sweep on a dev box.
44
45
  - All new code requires tests. Test behavior at the function boundary, not internal control flow.
45
- - Inject `exec: ExecFn = execRuntime` rather than calling the runtime directly. Tests stub via `fakeExec`. See `src/test/getLiveResources.test.ts` for the canonical pattern: synthetic `{stdout, stderr, exitCode}`, no real podman invocations.
46
+ - Inject `client: ContainerClient = getClient()` rather than calling dockerode directly. Tests stub via `makeMockClient(spec)` from `src/test/helpers/mockClient.ts`. See `src/test/getLiveResources.test.ts` for the canonical pattern: a mock whose `getContainer().inspect()` returns the JSON object under test, no real podman/docker invocations.
46
47
  - Container-integration tests (those that actually pull `alpine:3.19`) live under `src/test/integration/` and gate on `hasContainerRuntime()` which returns `null` on Windows. Do not add new tests that pull real Linux images without putting them under `src/test/integration/` AND gating on the helper.
47
48
  - All tests must pass on every commit. Run `npm run build:all:integration` (= `build && test:all`) locally before opening a PR; CI's `npm test` covers the unit half and you cover the integration half.
48
49
 
@@ -70,11 +71,10 @@ The injection is in `defaultHomeForConfigRoot()` (`src/containers.ts`) and only
70
71
 
71
72
  ### Container log streaming
72
73
 
73
- Three-layer structure (`src/runtime.ts` → `src/containers.ts` → `src/log-stream-broker.ts`):
74
+ Two-layer structure (`src/containers.ts` → `src/log-stream-broker.ts`):
74
75
 
75
- 1. `spawnRuntimeStreaming` (`src/runtime.ts`) — primitive that wraps `child_process.spawn(podman/docker, args)` with a `stop()` handle and a `makeLineSplitter`-fed `onLine` callback. Used for any long-running runtime command that needs streaming output; `tailContainerLogs` is its only current caller. Returns synchronously with a stop-handle unlike `execRuntimeLong` which awaits process exit.
76
- 2. `tailContainerLogs` (`src/containers.ts`) — thin helper that composes `["logs", "-f", "--tail", N, prefixedName(name)]` and delegates to `spawnRuntimeStreaming`. `getContainerLogs` is the one-shot sibling (no `-f`, returns a `string[]`).
77
- 3. `LogStreamBroker` (`src/log-stream-broker.ts`) — per-container fan-out. First subscribe spawns the tail; last unsubscribe stops it. On tail exit with subscribers still attached the broker auto-respawns with exponential backoff (constants `RESPAWN_DELAY_MS` / `MAX_RESPAWN_DELAY_MS`); a delivered line resets the counter. This is what lets SSE-only consumers recover from auto-recreate or daemon glitches without waiting for a fresh subscribe call. Brokers are keyed by container name on the wrapper and auto-recreate cancels the prior subscription before installing a new one.
76
+ 1. `tailContainerLogs` (`src/containers.ts`) — calls `getContainer(name).logs({ follow: true, stdout: true, stderr: true, tail: N })` and pipes the stream through `client.modem.demuxStream` into a `makeLineSplitter`-fed `onLine` callback. Containers run without a TTY (the default), so the log stream is **multiplexed** — every stdout/stderr chunk carries an 8-byte frame header; reading raw bytes leaks that header as binary garbage into rendered lines, so demuxing is mandatory. Returns a `StreamingProcessHandle` whose `stop()` calls `stream.destroy()`. The handle's `pid` is always `undefined` (a socket stream has no process); the broker checks `spawnFailed` instead. `getContainerLogs` is the one-shot sibling (`follow: false`) — it ALSO demuxes (the API returns a multiplexed Buffer; the CLI used to pre-demux for us).
77
+ 2. `LogStreamBroker` (`src/log-stream-broker.ts`) — per-container fan-out. First subscribe opens the tail; last unsubscribe destroys it. On stream end/error with subscribers still attached the broker auto-respawns with exponential backoff (constants `RESPAWN_DELAY_MS` / `MAX_RESPAWN_DELAY_MS`); a delivered line resets the counter. A synchronously-failed tail is detected via `handle.spawnFailed` (NOT `pid === undefined` — dockerode handles never carry a pid). This is what lets SSE-only consumers recover from auto-recreate or daemon glitches without waiting for a fresh subscribe call. Brokers are keyed by container name on the wrapper and auto-recreate cancels the prior subscription before installing a new one.
78
78
 
79
79
  Consumer surfaces:
80
80
 
@@ -88,21 +88,15 @@ Lifecycle:
88
88
  - `safeInvokeContainerLog` mirrors `safeInvokeVolumeIssue` — sync throws and async rejections from plugin handlers route to `app.error` and never propagate.
89
89
  - Combined stdout+stderr (matches `podman logs <name>` semantics). Per-stream separation is out of scope for v1.
90
90
 
91
- See `src/runtime.ts`, `src/containers.ts`, `src/log-stream-broker.ts`, and the tests in `src/test/` for exact timing and lifecycle mechanics.
91
+ See `src/client.ts`, `src/containers.ts`, `src/log-stream-broker.ts`, and the tests in `src/test/` for exact timing and lifecycle mechanics.
92
92
 
93
93
  ### Podman image qualification
94
94
 
95
- `qualifyImage("foo/bar:tag", podmanRuntime)` prefixes `docker.io/` when needed (podman requires fully qualified names unless `unqualified-search-registries` is set). Docker passes through. Use this everywhere we feed an image string to a runtime command.
95
+ `qualifyImage("foo/bar:tag", podmanRuntime)` prefixes `docker.io/` when needed (podman requires fully qualified names unless `unqualified-search-registries` is set; this holds over the API too). Docker passes through. Use this everywhere we feed an image string to a dockerode call.
96
96
 
97
97
  ### Inspect-format diff pattern
98
98
 
99
- When we need to read live container state, we use a single `inspect --format` call with a pipe-delimited Go-template format string:
100
-
101
- ```gotemplate
102
- {{.HostConfig.NanoCpus}}|{{.HostConfig.Memory}}|...
103
- ```
104
-
105
- This works uniformly across podman and docker, parses cheaply, and avoids the JSON-shape divergence between the two runtimes. `getLiveResources` and `getLiveContainerConfig` are the canonical examples. Do not introduce new live-state probes that parse full `inspect` JSON output.
99
+ When we need to read live container state, call `getContainer(name).inspect()` once (through `safeInspect`, which returns `null` on a 404 instead of throwing) and read the JSON fields directly. dockerode returns the same field shapes on podman and docker (verified live: `HostConfig.NanoCpus`, `HostConfig.Memory`, `NetworkSettings.Ports`, `Mounts[].{Type,Source,Destination}`, `Config.{Image,Cmd,Env,Healthcheck}`, etc.), so there's no Go-template parsing and no podman-vs-docker text divergence to guard against. `getLiveResources` and `getLiveContainerConfig` are the canonical examples. `diffContainerConfig` is a pure function over those inspect-derived values — keep new live-state probes reading inspect JSON directly so the diff stays uniform across runtimes.
106
100
 
107
101
  ### networkMode canonicalization
108
102
 
@@ -110,13 +104,13 @@ Docker reports `HostConfig.NetworkMode` as `"default"` or `"bridge"` when no `--
110
104
 
111
105
  ### `disableUserNamespaceRemap` (rootless-Podman + idmap-incompatible storage)
112
106
 
113
- Some filesystems (ZFS is the canonical case) cannot be id-mapped by the kernel. `--userns=keep-id` either fails outright on container create or triggers Podman's `storage-chown-by-maps` sweep, which is catastrophically slow on CoW metadata. The README's user-facing section documents the host-side primary fix (storage driver swap to `fuse-overlayfs`); the plugin-side escape hatch below covers the case where the operator cannot or will not change storage drivers.
107
+ Some filesystems (ZFS is the canonical case) cannot be id-mapped by the kernel. `HostConfig.UsernsMode: "keep-id"` either fails outright on container create or triggers Podman's `storage-chown-by-maps` sweep, which is catastrophically slow on CoW metadata. The README's user-facing section documents the host-side primary fix (storage driver swap to `fuse-overlayfs`); the plugin-side escape hatch below covers the case where the operator cannot or will not change storage drivers.
114
108
 
115
109
  Invariants:
116
110
 
117
111
  - `disableUserNamespaceRemap` is a plugin-config boolean; default `false` preserves the historical `keep-id` behaviour for every existing deployment.
118
- - The flag affects **only** the rootless-Podman branch of `userMappingFlags()`. Docker and rootful-Podman paths are untouched because they never emit `keep-id` to begin with.
119
- - When the flag is active, `userMappingFlags()` returns `[]` for rootless-Podman — no `--userns` and no `--user`. The default rootless mapping (in-image UID 0 → host caller's UID) then drives bind-mount ownership for root-by-default images. Non-root images lose host-caller ownership; that's the documented trade-off.
112
+ - The flag affects **only** the rootless-Podman branch of `userMappingFlags()`. Docker and rootful-Podman paths are untouched because they use `User` (a `--user`-equivalent), never `keep-id`. (Aside: `keep-id` is meaningless under rootful Podman — there is no user namespace to map into, so podman silently no-ops it and runs as in-image root. We use `User` on the rootful branches precisely to get caller ownership; this is why accurate `isRootless` detection is correctness-critical, since a misread would silently produce root-owned bind-mount files rather than failing loudly.)
113
+ - `userMappingFlags()` returns a create-payload fragment, not CLI flags: `{ HostConfig: { UsernsMode: "keep-id:uid=N,gid=N" } }` for rootless podman, `{ User: "N:N" }` for docker/rootful podman, `{}` for opt-out. When `disableUserNamespaceRemap` is active it returns `{}` for rootless-Podman — no `UsernsMode` and no `User`. The default rootless mapping (in-image UID 0 → host caller's UID) then drives bind-mount ownership for root-by-default images. Non-root images lose host-caller ownership; that's the documented trade-off.
120
114
  - The toggle lives in module state in `src/runtime.ts`, set by `setDisableUserns()` from `plugin.start()` and reset to `false` in `plugin.stop()`. Stop/start cycles must not strand a previous run's setting.
121
115
  - Drift detection in `ensureRunning` composes through `userMappingFlags()`, so the toggle is automatically reflected; never add a parallel codepath that re-derives the same decision.
122
116
 
@@ -156,17 +150,14 @@ When a consumer plugin sets a restart policy explicitly, it overrides the defaul
156
150
 
157
151
  ### In-container signalk-server + host-side rootless Podman
158
152
 
159
- The signalk-universal-installer's deployment runs signalk-server itself as a Podman container, with the host's rootless podman socket bind-mounted to `/var/run/docker.sock` inside. The in-container side ships both `podman` and `docker` CLIs (the latter is what consumer plugins effectively reach, since the bind socket is on the docker path). This topology has five sharp edges the runtime detection layer must handle. Future contributors maintaining the runtime layer should know about them as one story:
153
+ The signalk-universal-installer's deployment runs signalk-server itself as a Podman container, with the host's rootless podman socket bind-mounted to `/var/run/docker.sock` inside. signalk-container talks to that socket directly via dockerode — **the socket IS the channel to the host daemon**, so there is no CLI binary, no client-side flag validation, and no `podman --remote` dance. That collapses most of the sharp edges the CLI era had to work around (the docker-shim reclassification, `--remote --url` promotion, `cleanEnv`/`XDG_RUNTIME_DIR` backfill, and `runtimeCmd` binary-flipping are all gone). Two edges remain, in changed form:
160
154
 
161
- | Concern | Code |
162
- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
163
- | Rootless Podman daemon discovery under system-scoped systemd units (no `XDG_RUNTIME_DIR` in the inherited env), without breaking the in-container case where `/run/user` doesn't exist at all (podman `lstat`s `XDG_RUNTIME_DIR` at startup before honouring `--url <socket>`, so pointing it at a missing directory makes every `podman --remote` probe fail before it can run) | `cleanEnv()` in `runtime.ts` backfills `XDG_RUNTIME_DIR=/run/user/<uid>` only when that path exists |
164
- | Detecting that the in-container `docker` CLI is actually talking to a host-side podman daemon (default `docker --version` doesn't reveal it) | `tryRuntime()` probes `docker info --format {{.DefaultRuntime}}` `crun` reclassify to podman |
165
- | Keeping podman-flag semantics (`--userns=keep-id:uid=X,gid=Y`, etc.) once we've reclassified a docker-shim to podman — the in-container docker CLI client-validates podman-specific values and rejects them before forwarding to the daemon | `tryRuntime()` promotes the shim to `podman --remote --url <socket>` when the bind-mounted socket is reachable; `runtimeCmd()` then flips the binary back to podman |
166
- | Finding our own container id when HOSTNAME is empty, cgroup is `0::/`, and `Network=host` makes `/etc/hostname` return the host machine name | `parseSelfContainerIdsFromMountinfo()` matches the 64-hex id the runtime stamps into bindfs source paths (`/etc/hostname`, `/etc/resolv.conf`, `/run/.containerenv`) |
167
- | Detecting rootless mode when `podman info --format {{.Host.Security.Rootless}}` fails (in-container podman can't reach a daemon at its default socket) | `probeRootless()` falls back to `docker info --format {{.SecurityOptions}}` and looks for the `name=rootless` token |
155
+ | Concern | Code |
156
+ | -------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
157
+ | Finding our own container id when HOSTNAME is empty, cgroup is `0::/`, and `Network=host` makes `/etc/hostname` return the host machine name | `parseSelfContainerIdsFromMountinfo()` matches the 64-hex id the runtime stamps into bindfs source paths (`/etc/hostname`, `/etc/resolv.conf`, `/run/.containerenv`); each candidate is validated via `getContainer(id).inspect()` |
158
+ | Detecting rootless mode over the socket (dockerode reaches podman's docker-compat `/info`, which does NOT expose `host.security.rootless`) | `rootlessFromInfo()` in `runtime.ts` reads the compat shape: the top-level `Rootless` boolean, falling back to `SecurityOptions` containing `name=rootless` |
168
159
 
169
- The thread that runs through all five: **the in-container signalk-server has no native podman channel back to the host daemon**. Every detection that worked on bare-metal has to be re-derived from artefacts that survive the bind-mount-only topology — env vars set by the runtime, mountinfo paths, the docker compat API, the bind-mounted socket via `podman --remote --url`. When debugging future "signalk-container thinks we're not rootless" / "thinks we're not in a container" / "can't find our own id" / "consumer plugin still failing on `--userns=keep-id`" reports, check whether you're in this deployment shape first.
160
+ When debugging "signalk-container thinks we're not rootless" / "can't find our own id" reports, check whether you're in this deployment shape first and remember that under the socket the cgroup-controller view comes from the kernel FS (`/sys/fs/cgroup/cgroup.controllers`), not `info()`, because the compat endpoint doesn't expose `CgroupControllers`.
170
161
 
171
162
  ## Workflow Conventions
172
163
 
package/README.md CHANGED
@@ -6,7 +6,7 @@ Instead of each plugin implementing its own container orchestration, they delega
6
6
 
7
7
  ## Features
8
8
 
9
- - **Runtime detection** -- Podman preferred, Docker fallback, podman-shim aware. Consumer plugins `await containers.whenReady()` once instead of polling in a loop — resolves when the probe settles in either direction.
9
+ - **Runtime detection** -- resolves the container runtime over its Docker-API socket (Podman preferred, Docker fallback); no podman/docker CLI binary is needed. Consumer plugins `await containers.whenReady()` once instead of polling in a loop — resolves when the probe settles in either direction.
10
10
  - **Container lifecycle** -- pull, create, start, stop, remove with `sk-` prefix naming
11
11
  - **Automatic config-drift recreation** -- `ensureRunning` compares the requested `ContainerConfig` against the live container on every call. If `image`, `tag`, `command`, `networkMode`, `env`, `volumes`, or `ports` differ, the container is removed and recreated transparently. Consumer plugins no longer need a per-plugin hash file to detect "config changed since last start." See the [developer guide](doc/plugin-developer-guide.md#container-config-changes).
12
12
  - **Healthcheck handling** -- image-declared healthchecks are made to work reliably across runtimes. For images that ship **no** healthcheck, consumer plugins set `ContainerConfig.healthcheck` to supply a probe, so the container reports a real health status instead of being stuck in `starting`. See the [developer guide](doc/plugin-developer-guide.md#healthcheck-for-images-that-ship-none).
@@ -20,7 +20,7 @@ Instead of each plugin implementing its own container orchestration, they delega
20
20
  - **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.
21
21
  - **SELinux support** -- `:Z` volume flags for Podman bind mounts on Fedora/RHEL; named volumes are handled correctly (`:Z` is not applied)
22
22
  - **Per-volume host-source policy** -- volumes accept `{ source, ifMissing: "skip" | "abort" }` for user-managed (USB drives, NFS) or deployment-required (TLS certs) mounts. Plugins subscribe to `onVolumeIssue` events for `'skipped'`, `'aborted'`, and `'recovered'` actions; signalk-container auto-recreates the container when a previously-missing source reappears. See the [developer guide](doc/plugin-developer-guide.md#optional-and-required-volumes).
23
- - **Container log streaming** -- click **Logs** on any managed-container card to open a live-streaming popup of the container's stdout+stderr (combined, the same shape `podman logs <name>` produces). Plugin authors can also wire `onContainerLog` in `ensureRunning` options to forward the same stream into their plugin's `app.debug` channel — visible in the Signal K server log when debug is enabled. Multiple subscribers share a single underlying tail process. See the [developer guide](doc/plugin-developer-guide.md#streaming-container-logs-into-your-plugins-debug-channel).
23
+ - **Container log streaming** -- click **Logs** on any managed-container card to open a live-streaming popup of the container's stdout+stderr (combined, the same shape `podman logs <name>` produces). Plugin authors can also wire `onContainerLog` in `ensureRunning` options to forward the same stream into their plugin's `app.debug` channel — visible in the Signal K server log when debug is enabled. Multiple subscribers share a single underlying log stream. See the [developer guide](doc/plugin-developer-guide.md#streaming-container-logs-into-your-plugins-debug-channel).
24
24
  - **Host-UID ownership alignment** -- managed containers run by default under the Signal K host user's UID/GID (via `--user host:host` on Docker/rootful Podman, `--userns=keep-id` on rootless Podman). Files created on bind mounts are owned by the same identity that runs Signal K, with no `chmod` sweeps. Override per container via `ContainerConfig.user` for images with a non-root `USER` directive, or `user: false` to opt out. See the [developer guide](doc/plugin-developer-guide.md#host-uid-ownership).
25
25
  - **Image compliance probes** -- `containers.doctor.imageRunsAsUser(image, user?)` runs the image under the live UID mapping and verifies it can write `/tmp` as the host caller. Surfaces UID-compatibility problems _before_ a container wedges in a restart loop. See the [developer guide](doc/plugin-developer-guide.md#containersdoctorimagerunsasuserimage-user-promiseimageproberesult).
26
26
  - **Podman image qualification** -- automatically prefixes `docker.io/` for short image names
@@ -119,69 +119,69 @@ See [doc/plugin-developer-guide.md](doc/plugin-developer-guide.md) for the full
119
119
 
120
120
  ## API
121
121
 
122
- | Method | Description |
123
- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
124
- | `getRuntime()` | Returns `{ runtime, version, isPodmanDockerShim }` 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: binary discovery, daemon reachability, rootless/rootful detection, and (when containerized) 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. Pure templating over `SelfDeploymentResult`; bundles a minimal Dockerfile sidecar and operator notes. |
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. |
160
160
 
161
161
  ## REST Endpoints
162
162
 
163
163
  All mounted at `/plugins/signalk-container/api/`:
164
164
 
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: binary discovery, 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`. |
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`. |
185
185
 
186
186
  ## Configuration
187
187
 
@@ -301,7 +301,7 @@ services:
301
301
 
302
302
  You'll see "could not detect self container id" in the SignalK log when the cascade has failed; the override resolves it without code changes.
303
303
 
304
- For a full walkthrough of the in-container deployment (socket bind-mount, `DOCKER_HOST` / podman remote-mode fallback, reference quadlet, troubleshooting table) see **[doc/run-in-container.md](doc/run-in-container.md)**.
304
+ For a full walkthrough of the in-container deployment (socket bind-mount, self-container-id override, reference quadlet, troubleshooting table) see **[doc/run-in-container.md](doc/run-in-container.md)**.
305
305
 
306
306
  ## Connecting back to a container service (`signalkAccessiblePorts`)
307
307
 
@@ -0,0 +1,119 @@
1
+ /**
2
+ * dockerode client: socket selection, the shared singleton, and the `safe()`
3
+ * error-normalizing wrapper. Every runtime call in the plugin goes through the
4
+ * `ContainerClient` returned by `getClient()`.
5
+ *
6
+ * Why a unix socket and not the podman/docker CLI: the plugin runs inside the
7
+ * Signal K server container, where the host runtime socket is bind-mounted
8
+ * (`/var/run/docker.sock` in the universal-installer topology). Talking the
9
+ * Docker API directly over that socket removes the need for any runtime CLI
10
+ * binary in the Signal K base image, and removes the whole class of
11
+ * podman-docker-shim flag-validation workarounds the CLI path needed — the API
12
+ * accepts podman-specific create fields (e.g. `HostConfig.UsernsMode:
13
+ * "keep-id"`) with no client-side validation.
14
+ *
15
+ * Adapted from signalk-updater-server/src/podman/client.ts (the proven dockerode
16
+ * wrapper shared across the engine containers).
17
+ */
18
+ import Docker from "dockerode";
19
+ import { type CategorizedError } from "./errors.js";
20
+ /**
21
+ * The dockerode surface the plugin actually uses. Production passes the real
22
+ * `Docker` instance; tests pass a hand-rolled mock implementing only the
23
+ * methods in play. This is the injection seam that replaced the CLI-era
24
+ * `ExecFn` stub: instead of feeding canned `{stdout, exitCode}` strings, tests
25
+ * stub typed dockerode methods and assert object-field access.
26
+ */
27
+ export interface ContainerClient {
28
+ getContainer(id: string): Docker.Container;
29
+ getImage(name: string): Docker.Image;
30
+ getNetwork(id: string): Docker.Network;
31
+ createContainer(opts: Docker.ContainerCreateOptions): Promise<Docker.Container>;
32
+ createNetwork(opts: Docker.NetworkCreateOptions): Promise<Docker.Network>;
33
+ listContainers(opts?: Docker.ContainerListOptions): Promise<Docker.ContainerInfo[]>;
34
+ pull(repoTag: string, opts?: object): Promise<NodeJS.ReadableStream>;
35
+ pruneImages(opts?: object): Promise<Docker.PruneImagesInfo>;
36
+ version(): Promise<Docker.DockerVersion>;
37
+ info(): Promise<unknown>;
38
+ /** docker-modem; typed `any` upstream. Used for followProgress + demuxStream. */
39
+ modem: {
40
+ followProgress(stream: NodeJS.ReadableStream, onFinished: (error: Error | null, result: unknown[]) => void, onProgress?: (obj: unknown) => void): void;
41
+ demuxStream(stream: NodeJS.ReadableStream, stdout: NodeJS.WritableStream, stderr: NodeJS.WritableStream): void;
42
+ };
43
+ }
44
+ /** Runtime the caller prefers when more than one socket is reachable. */
45
+ export type SocketPreference = "auto" | "podman" | "docker";
46
+ export interface ResolvedClient {
47
+ /**
48
+ * The resolved dockerode client, typed as the narrow `ContainerClient`
49
+ * surface the plugin uses (a real `Docker` instance satisfies it). Consumers
50
+ * get a usable client with no `as` cast.
51
+ */
52
+ client: ContainerClient;
53
+ socketPath: string;
54
+ }
55
+ /**
56
+ * Resolve (once) and cache the dockerode client + its socket path. Returns
57
+ * `null` when no socket answers — the caller surfaces this as "no container
58
+ * runtime" via the doctor. Re-resolves after `resetClient()`.
59
+ */
60
+ export declare function resolveClient(preference?: SocketPreference): Promise<ResolvedClient | null>;
61
+ /**
62
+ * The shared client for code paths that have already resolved a runtime (every
63
+ * consumer call after `detectRuntime` succeeded). Throws if called before
64
+ * resolution — that's a programming error, not a runtime condition.
65
+ */
66
+ export declare function getClient(): ContainerClient;
67
+ /** Path of the resolved socket, for doctor/diagnostic display. */
68
+ export declare function getSocketPath(): string | undefined;
69
+ /**
70
+ * Drop the cached client. Called from `plugin.stop()` so a stop/start cycle
71
+ * re-probes the socket, and from tests to reset between cases.
72
+ */
73
+ export declare function resetClient(): void;
74
+ /**
75
+ * Test-only: install a specific client + socket path as the resolved singleton,
76
+ * bypassing socket probing. Pass `null` to clear.
77
+ */
78
+ export declare function _setClientForTesting(client: ContainerClient | null, socketPath?: string): void;
79
+ /**
80
+ * Run a dockerode op and normalize any throw into a categorized error. The one
81
+ * place the `{stdout, stderr, exitCode}` CLI contract is replaced: callers
82
+ * branch on `result.ok` instead of inspecting an exit code.
83
+ */
84
+ export declare function safe<T>(op: () => Promise<T>): Promise<{
85
+ ok: true;
86
+ value: T;
87
+ } | {
88
+ ok: false;
89
+ error: CategorizedError;
90
+ }>;
91
+ /**
92
+ * `inspect`-style convenience: returns `null` when the resource is absent (404)
93
+ * and rethrows anything else. Replaces the CLI-era "exitCode !== 0 → missing"
94
+ * idiom, which couldn't distinguish "not found" from "daemon broken".
95
+ */
96
+ export declare function safeInspect<T>(op: () => Promise<T>): Promise<T | null>;
97
+ /**
98
+ * Demultiplex a dockerode logs/exec stream into combined plain text.
99
+ *
100
+ * Containers started without a TTY (the dockerode default, and how every
101
+ * managed container starts) deliver logs as a multiplexed stream: each
102
+ * stdout/stderr chunk is prefixed with an 8-byte header
103
+ * (`[stream-type, 0, 0, 0, len32]`). Reading the raw bytes as UTF-8 leaks those
104
+ * headers into rendered log lines as binary garbage. `modem.demuxStream` splits
105
+ * the framing into two writable sinks; we merge both into one text callback
106
+ * because the plugin surfaces combined stdout+stderr (matching `podman logs`
107
+ * semantics). The CLI used to pre-demux for us — over the API we must do it.
108
+ *
109
+ * Returns a function that, given a finished/destroyed source stream, has piped
110
+ * all demuxed text into `onText`. Caller wires `onText` to a line splitter.
111
+ */
112
+ export declare function demuxToText(modem: ContainerClient["modem"], source: NodeJS.ReadableStream, onText: (chunk: string) => void): void;
113
+ /**
114
+ * Read a finished (non-follow) logs/exec stream fully and return the demuxed
115
+ * combined text. Used by one-shot `getContainerLogs` and `execInContainer`,
116
+ * which under the CLI got pre-demuxed text and now must demux a buffer.
117
+ */
118
+ export declare function demuxBufferToText(modem: ContainerClient["modem"], source: NodeJS.ReadableStream): Promise<string>;
119
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,MAAM,MAAM,WAAW,CAAC;AAG/B,OAAO,EAAmB,KAAK,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAErE;;;;;;GAMG;AACH,MAAM,WAAW,eAAe;IAC9B,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAAC,SAAS,CAAC;IAC3C,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC;IACrC,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAAC,OAAO,CAAC;IACvC,eAAe,CACb,IAAI,EAAE,MAAM,CAAC,sBAAsB,GAClC,OAAO,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;IAC7B,aAAa,CAAC,IAAI,EAAE,MAAM,CAAC,oBAAoB,GAAG,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IAC1E,cAAc,CACZ,IAAI,CAAC,EAAE,MAAM,CAAC,oBAAoB,GACjC,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,CAAC,CAAC;IACnC,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,cAAc,CAAC,CAAC;IACrE,WAAW,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC;IAC5D,OAAO,IAAI,OAAO,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC;IACzC,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;IACzB,iFAAiF;IACjF,KAAK,EAAE;QACL,cAAc,CACZ,MAAM,EAAE,MAAM,CAAC,cAAc,EAC7B,UAAU,EAAE,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,IAAI,EAC5D,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,GAClC,IAAI,CAAC;QACR,WAAW,CACT,MAAM,EAAE,MAAM,CAAC,cAAc,EAC7B,MAAM,EAAE,MAAM,CAAC,cAAc,EAC7B,MAAM,EAAE,MAAM,CAAC,cAAc,GAC5B,IAAI,CAAC;KACT,CAAC;CACH;AAED,yEAAyE;AACzE,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAuF5D,MAAM,WAAW,cAAc;IAC7B;;;;OAIG;IACH,MAAM,EAAE,eAAe,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;CACpB;AAID;;;;GAIG;AACH,wBAAsB,aAAa,CACjC,UAAU,GAAE,gBAAyB,GACpC,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAMhC;AAED;;;;GAIG;AACH,wBAAgB,SAAS,IAAI,eAAe,CAO3C;AAED,kEAAkE;AAClE,wBAAgB,aAAa,IAAI,MAAM,GAAG,SAAS,CAElD;AAED;;;GAGG;AACH,wBAAgB,WAAW,IAAI,IAAI,CAElC;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,eAAe,GAAG,IAAI,EAC9B,UAAU,SAAsB,GAC/B,IAAI,CAEN;AAED;;;;GAIG;AACH,wBAAsB,IAAI,CAAC,CAAC,EAC1B,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACnB,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,gBAAgB,CAAA;CAAE,CAAC,CAM1E;AAED;;;;GAIG;AACH,wBAAsB,WAAW,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAK5E;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,WAAW,CACzB,KAAK,EAAE,eAAe,CAAC,OAAO,CAAC,EAC/B,MAAM,EAAE,MAAM,CAAC,cAAc,EAC7B,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,GAC9B,IAAI,CAON;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,eAAe,CAAC,OAAO,CAAC,EAC/B,MAAM,EAAE,MAAM,CAAC,cAAc,GAC5B,OAAO,CAAC,MAAM,CAAC,CAUjB"}