NAS Journald Logs → ClickStack Design
For Claude: This is a design spec. Implement via
superpowers:writing-plans→executing-plans.
Goal: Extend nas-otel-collector to ship the TrueNAS nas host’s journald logs into ClickStack (default.otel_logs, service.name=otelcol-nas), alongside the existing host metrics (unchanged). Today the role ships metrics only — the upstream otel/opentelemetry-collector-contrib image is distroless and lacks journalctl, which the journald receiver shells out to (the receiver has no internal journal reader; confirmed via OTel contrib docs). Adding the receiver to the distroless image crashes collector startup.
Architecture: A custom otelcol-contrib image (debian-slim + systemd for journalctl, + the otelcol-contrib release binary) is built by GitHub Actions to GHCR and keyless-signed with cosign. The Ansible role switches nas_otel_image to that GHCR image and adds a journald receiver + transform/journald body-shaping processor + file_storage cursor extension + a logs pipeline to otel-config.yaml.j2, plus journal/machine-id/cursor mounts to compose.yaml.j2. The TrueNAS custom-app runtime pulls the signed image from the registry as it does for any image — no on-box build step.
Bead: hl-36m6.13 (parent epic hl-36m6).
Problem (authoritative)
Section titled “Problem (authoritative)”The bead states the problem: ship nas journald LOGS to ClickStack. The bead’s “mirror the Firewalla otel-collector role’s Dockerfile.j2 + custom-image build” text is a candidate solution, treated as a hypothesis and validated against root-cause analysis during this design. Findings:
- The Dockerfile recipe (debian-slim + systemd + tarball) is reusable and correct — validated in production by the Firewalla role since 2026-05.
- The build/delivery mechanism (“custom-image build” on-box) does not transfer: the Firewalla role builds via
docker buildon a Linux host and runs under systemddocker-compose@; the nas role deploys via the TrueNASapp.*custom-app model, whose runtime pulls images from a registry and exposes nodocker buildstep. The on-box build approach is incompatible with the nas delivery path. - The repo already has the canonical registry-delivery pattern:
.github/workflows/build-headroom-otel.ymlbuilds a derived otel image to GHCR (per-arch jobs +buildx imagetoolsmulti-arch manifest). That mechanism — not the Firewalla build mechanism — is the right fit for the nas.
Design
Section titled “Design”Component 1 — Custom image (images/nas-otel/Dockerfile)
Section titled “Component 1 — Custom image (images/nas-otel/Dockerfile)”Adapts the Firewalla ansible/roles/otel-collector/templates/Dockerfile.j2 content to a non-templated CI-build form:
# Custom otel-collector-contrib image with journalctl available for the# journaldreceiver. Mirrors the official upstream example at:# https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/receiver/journaldreceiver/examples/container## Why a custom image: otel/opentelemetry-collector-contrib is FROM scratch# (distroless) and ships only the binary + ca-certs + config.# journaldreceiver shells out to `journalctl`, which doesn't exist there.# Bind-mounting the host's journalctl can't work — journalctl is dynamically# linked and resolves its dynamic linker from the CONTAINER's filesystem, not# the host's, so it can't find its shared libraries. (Lesson learned on the# Firewalla role, 2026-05.) Base is debian-slim with systemd installed# (provides journalctl + shared libs); the otelcol-contrib release tarball is# pulled from the project's GitHub releases, pinned to the role's version.
FROM debian:13-slim
ARG OTEL_COL_VERSION=0.119.0
WORKDIR /opt
RUN apt-get update \ && apt-get install -y --no-install-recommends \ systemd \ wget \ ca-certificates \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* /usr/share/doc/* /usr/share/man/*
RUN wget -qO- "https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v${OTEL_COL_VERSION}/otelcol-contrib_${OTEL_COL_VERSION}_linux_amd64.tar.gz" \ | tar -xz otelcol-contrib
ENTRYPOINT ["/opt/otelcol-contrib"]- Pinned to
0.119.0(the role’snas_otel_version); the version is the image tag. debian:13-slim+systemd— identical to the validated Firewalla recipe.- amd64 tarball only — NAS is x86-64 (confirmed:
nas_sandbox_image_name: ubuntu:noble:amd64). Single-arch build avoids the multi-arch manifest complexitybuild-headroom-otel.ymlcarries (which exists only because headroom serves ≥2 arches). A one-line note in the workflow records that headroom’s per-arch split is the template if arm64 is ever needed.
Component 2 — CI workflow (.github/workflows/build-nas-otel.yml)
Section titled “Component 2 — CI workflow (.github/workflows/build-nas-otel.yml)”Modeled on build-headroom-otel.yml, amd64-only, with image signing & attestation (this is the first signed image in the repo — precedent-setting):
- Triggers:
pushtomainonimages/nas-otel/**or the workflow file; tagsnas-otel-*;workflow_dispatch. - Env:
REGISTRY: ghcr.io,IMAGE_NAME: ${{ github.repository_owner }}/nas-otel. - Permissions:
contents: read,packages: write,id-token: write(required for keyless OIDC cosign signing). - Runner:
namespace-profile-linux-amd64-2x4. - Steps:
actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3(SHA-pinned, repo convention).- Set image tag from the Dockerfile
OTEL_COL_VERSIONARG →0.119.0. (Same grep+sed approach as headroom’s version extraction, but the regex targets theARG OTEL_COL_VERSION=line, not the^FROMline headroom parses — the nas version lives in the ARG, not the base image. A copy-paste of headroom’s regex would yield an empty tag.) docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0to GHCR via${{ secrets.GITHUB_TOKEN }}.docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0withcontext: images/nas-otel,push: true,provenance: true,sbom: true, tagsghcr.io/fzymgc-house/nas-otel:0.119.0and:latest.sigstore/cosign-installer(SHA-pinned to latest at implementation time).cosign sign --yeson both:0.119.0and:latest(keyless — uses the GitHub OIDC token; no key material to store in Vault).
The signature, SLSA provenance attestation, and SBOM together form the three-layer OCI attestation stack (who built it / how it was built / what’s in it). All are free under buildkit + keyless cosign.
Verification identity (trust anchor): the signature is bound to the exact workflow path and ref:
--certificate-identity-regexp 'https://github.com/fzymgc-house/selfhosted-cluster/.github/workflows/build-nas-otel.yml@refs/heads/main'--certificate-oidc-issuer https://token.actions.githubusercontent.comFail-closed: an image built by any other workflow (e.g. a future staging workflow publishing to the same package) fails this verify step.
Scope guardrail: signing is specified for this image only. Retrofitting signing onto the other 6 existing build-* workflows is a separate, repo-wide hardening task — out of scope for this bead, flagged as a follow-up so the precedent is intentional.
Component 3 — Role defaults (ansible/roles/nas-otel-collector/defaults/main.yml)
Section titled “Component 3 — Role defaults (ansible/roles/nas-otel-collector/defaults/main.yml)”nas_otel_image: ghcr.io/fzymgc-house/nas-otel # was: otel/opentelemetry-collector-contribnas_otel_version: "0.119.0" # unchanged — matches the GHCR tag
# --- new: journald logs ---nas_otel_journal_dir: /var/log/journal # persistent journal (survives reboot)nas_otel_cursor_dir: "{{ nas_otel_base_dir }}/cursor"nas_otel_journal_units: - sshd.service - midcltd.service # TrueNAS middleware (the appliance's core API) - middleware.service - containerd.service # otelcol-nas itself runs as a custom_app, not a systemd unit; its container # logs are not in journald. Tailing it is a no-op. Tune in host_vars/nas.yml.The unit list is a best-effort inference, not box-verified — the implementation plan includes a discovery step (journalctl --list-boots, systemctl list-units) on the nas before first deploy, with nas_otel_journal_units tuned in host_vars/nas.yml. It is a variable so the operator can tune it without touching the role (same extensibility as the Firewalla role).
Component 4 — Collector config (templates/otel-config.yaml.j2)
Section titled “Component 4 — Collector config (templates/otel-config.yaml.j2)”Four additions to the existing metrics-only config; the metrics pipeline is untouched.
file_storage extension (new, alongside health_check):
extensions: health_check: endpoint: "0.0.0.0:13133" file_storage: directory: {{ nas_otel_cursor_dir }}journald receiver (under receivers:):
journald: directory: {{ nas_otel_journal_dir }} units:{% for unit in nas_otel_journal_units %} - {{ unit }}{% endfor %} priority: info start_at: end storage: file_storagestart_at: end— first-ever run starts at “now” (no back-fill of the historical journal into ClickStack on first deploy).storage: file_storage— subsequent restarts resume from the persisted cursor.directoryis/var/log/journal(persistent journal).
transform/journald processor (body-shaping only — no firewalla Case 3 service.name setter; the nas resource processor already upserts service.name=otelcol-nas on all telemetry):
transform/journald: log_statements: # journald native — body is a Map. Lift fields into attributes, set body # to MESSAGE so ClickStack shows readable log lines. (Firewalla role, # empirically debugged 2026-05-14: IsString(Map) is false, so the prior # IsString(body) gate never matched.) - context: log conditions: - IsMap(body) statements: - merge_maps(attributes, body, "upsert") - set(body, attributes["MESSAGE"]) where attributes["MESSAGE"] != nilCase 2 (defensive JSON-string body parse) is omitted — the nas collector has no OTLP receiver (nothing pushes to it; confirmed in the existing config comment), so no log record can arrive as a JSON-string body. Carrying that branch would be unreachable code.
No filter processor by design. The Firewalla filter/firewalla-noise is Firewalla-specific (dnsmasq audit chatter). TrueNAS chattiness is different and unknown. A wrong filter drops signal (silent data loss — the worst failure mode for a telemetry pipeline); no filter only costs ClickStack storage (recoverable, visible). Ship without a filter; add filter/nas-noise in a follow-up bead once real volume data exists. The unit allowlist is the first volume-control knob.
logs pipeline (under service.pipelines:):
logs: receivers: [journald] processors: [resourcedetection/system, resource, transform/journald, batch] exporters: [otlp/gateway]Pipeline order: resourcedetection/system + resource (populate host.name, service.name=otelcol-nas, deployment.environment) → transform/journald (shape body) → batch. Shares the existing otlp/gateway exporter (mTLS, already configured) — logs and metrics share the gateway + ClickStack path. service.extensions becomes [health_check, file_storage].
Component 5 — Compose (templates/compose.yaml.j2)
Section titled “Component 5 — Compose (templates/compose.yaml.j2)”Three new mounts; image already switched via the variable; no other changes:
services: otelcol: image: {{ nas_otel_image }}:{{ nas_otel_version }} # ...existing user: "0:0", command, restart, /hostfs mount... volumes: - {{ nas_otel_base_dir }}/config.yaml:/etc/otelcol/config.yaml:ro - {{ nas_otel_base_dir }}/certs:/etc/otelcol/certs:ro - /:/hostfs:ro # --- new: journald logs --- - {{ nas_otel_journal_dir }}:{{ nas_otel_journal_dir }}:ro - /etc/machine-id:/etc/machine-id:ro # --- new: cursor persistence (rw — file_storage writes cursors here) --- - {{ nas_otel_cursor_dir }}:{{ nas_otel_cursor_dir }}:rw- Journal
:ro— receiver reads viajournalctl, never writes. /etc/machine-id:ro— journald uses it for host identification; a mismatch makesjournalctlrefuse to read the files.- Cursor
:rw— the only new writable mount, undernas_otel_base_diron the apps pool.
The role continues to run as user: "0:0" (root) — already required for /hostfs access; journalctl reading + cursor writing both work under root. No group_add needed (the Firewalla needed systemd-journal GID only because it ran as a non-root pi user).
Component 6 — Role task flow (tasks/main.yml)
Section titled “Component 6 — Role task flow (tasks/main.yml)”Minimal deltas; the existing metrics-working flow is unchanged:
- The “Ensure base + cert directories” task → add a second
ansible.builtin.filetask (the existing one is single-path, not a loop) creating{{ nas_otel_cursor_dir }}(mode0750,become: true). - The
Dockerfile.j2template is not added to this role — the image is built by CI, not on-box. (Explicit departure from the Firewalla role: Dockerfile content borrowed, build mechanism not.) - Everything downstream (render config → render compose drift marker → build compose string →
app.query→app.create/app.update) is unchanged. The existingapp.updatedrift gate fires on config/compose/cert change, so the new mounts + image are picked up as a compose change → oneapp.updatecycle on first deploy.
Documentation changes
Section titled “Documentation changes”| File | Change |
|---|---|
docs/operations/nas.md | Telemetry section: flip “Logs are a follow-up” block → describe the custom GHCR image + journald receiver + units allowlist + cursor persistence. Add the logs verify query. |
ansible/CLAUDE.md | nas-otel-collector role row: host metrics → ClickStack → host metrics + journald logs → ClickStack (mTLS, signed custom image). Update the “journald logs are a follow-up” sentence. |
Logs verify query (parallel to the existing metrics one):
SELECT count() FROM default.otel_logsWHERE ServiceName='otelcol-nas' AND TimestampTime > now() - INTERVAL 1 HOUR;Acceptance criteria
Section titled “Acceptance criteria”- Image signed:
cosign verify ghcr.io/fzymgc-house/nas-otel:0.119.0 --certificate-identity-regexp 'https://github.com/fzymgc-house/selfhosted-cluster/.github/workflows/build-nas-otel.yml@refs/heads/main' --certificate-oidc-issuer https://token.actions.githubusercontent.comsucceeds. - App deploy:
midclt call app.queryshowsotelcol-nasrunning the GHCR image; app status healthy. - Metrics regression guard:
SELECT count() FROM otel_metrics_sum WHERE ServiceName='otelcol-nas'still returns > 0 (the metrics pipeline must not regress — this is the canary for the original “collector crashes on startup” failure mode). - Logs:
SELECT count() FROM default.otel_logs WHERE ServiceName='otelcol-nas' AND TimestampTime > now() - INTERVAL 1 HOURreturns > 0 after a journald-producing event (e.g. an SSH login to the nas). - No startup crash: container logs show no
journalctl-missing error.
Sequencing — two PRs with a merge gate
Section titled “Sequencing — two PRs with a merge gate”The image must exist in GHCR (and be signed) before the Ansible role pulls it.
- PR 1:
images/nas-otel/Dockerfile+.github/workflows/build-nas-otel.yml. Merge → workflow fires on push tomaintouchingimages/nas-otel/**→ publishes + signs the image. - Gate: confirm the image exists in GHCR and
cosign verify(criterion 1) passes before merging PR 2. - PR 2: role defaults/config/compose changes + docs.
If PR 1’s workflow run fails or the image is unsigned, PR 2 must not merge — the role would point at a non-existent/unsigned image and the TrueNAS app would fail to start (the metrics regression would be the visible symptom).
Rollback: if the deploy regresses metrics (criterion 3 fails), revert PR 2; the TrueNAS app reverts to the upstream distroless image on the next app.update (metrics resume, logs go away — back to the pre-change state). PR 1 (image + workflow) can remain merged; it’s inert if nothing references the image.
Risks & mitigations
Section titled “Risks & mitigations”| Risk | Mitigation |
|---|---|
| Unit allowlist names wrong (inferred, not box-verified) | Plan step: journalctl --list-boots / systemctl list-units on nas before first deploy; tune nas_otel_journal_units in host_vars/nas.yml. |
| First deploy dumps historical journal | start_at: end + storage: file_storage — first run starts at “now”, no back-fill. |
journalctl crashes despite custom image (host journal / in-image systemd version skew) | Recipe validated in prod on the Firewalla since 2026-05. Lighter fallback first: the OTel contrib journaldreceiver README documents a chroot option (root_path + journalctl_path to use the host’s journalctl via the custom image’s own linker) to dodge version skew. Heavier fallback: bump debian:13-slim base or pin systemd to match the TrueNAS journal version — a follow-up, not a design change. |
| ClickStack log volume excessive | No filter shipped by design; follow-up bead adds filter/nas-noise once real volume data exists. |
| Signing precedent drift (only this image signed) | Follow-up note flags repo-wide signing as a separate task; precedent is intentional. |
Follow-ups (out of scope for this bead)
Section titled “Follow-ups (out of scope for this bead)”- Retrofit cosign signing onto the other 6 existing
build-*workflows (repo-wide hardening). - Add a
filter/nas-noiseprocessor once real ClickStack volume data is available.