Repository navigation
Architecture
┌─────────────────────────────────────────────────────────────────────┐
│ Browser (vanilla JS, no build step) │
│ state + reactive store: subscribe(keys, renderFn) / storeSet() │
│ boot: loadAll() parallel REST fetches → startSSE() EventSource │
│ SSE drops → poll loadAll() every 30 s, retry SSE after 5 s │
└──────────────────────────┬──────────────────────────────────────────┘
│ HTTP(S) :8080 (REST + SSE)
▼
┌─────────────────────────────────────────────────────────────────────┐
│ main.go │
│ • startup: zfs, zpool, ansible-playbook in PATH; playbooks/ and │
│ static/ exist — otherwise refuse to start │
│ • middleware: otelhttp → RequestLogger (req_id, metrics) → auth │
│ • / → http.FileServer (static/) │
│ • /api/* → internal/api handlers │
│ • scheduler (auto-snapshot, replication), jobs manager, poller │
└───────┬──────────────────────────┬──────────────────────────┬───────┘
│ │ │
│ GET │ writes │ SSE
▼ ▼ ▼
┌───────────────┐ ┌────────────────────────────────┐ ┌────────────────┐
│ Reads │ │ Writes — three lanes │ │ internal/broker│
│ internal/zfs │ │ │ │ pub/sub + SSE │
│ system, smart │ │ internal/ops single argv cmd │ │ │
│ iscsi, smb │ │ zpool/zfs, no shell, ~ms │ │ poller: 10 s, │
│ blockdev │ │ internal/ansible playbooks │ │ publish only │
│ │ │ config files, users, ACLs │ │ on change │
│ exec zpool/ │ │ internal/jobs background │ │ │
│ zfs/smartctl │ │ zfs send|recv, zfs rewrite │ │ writes stream │
│ no Python │ │ │ │ op-log steps │
└───────────────┘ └────────────────────────────────┘ └────────────────┘
When dumpstore manages a service it takes full ownership of that service's config file. The config is rendered from a Go template on every write — no block-patching, no lineinfile, no partial edits. If you manually edit a managed config file, dumpstore will overwrite it on the next write operation.
The rule is binary: own it completely, or don't touch it at all.
This is one half of the project's scope boundary (#121). The other half: domains dumpstore does not manage, it only integrates with — identity (lldap: auth bind + read-only display), observability (Prometheus/OTEL: export only, no embedded dashboards or alerting), power (NUT: status + thin shutdown hook), notifications (syslog/webhook out). The test for every feature: does it make dumpstore better at running ZFS + shares, or does it rebuild a tool that already exists?
| Service | Owned? | Config file | Restart mechanism |
|---|---|---|---|
| Samba | ✅ full |
/etc/samba/smb.conf / /usr/local/etc/smb4.conf
|
reload smbd/nmbd / samba_server (started if not running) |
| wsdd | ✅ full (Linux) | unit's EnvironmentFile (/etc/default/wsdd on Debian/Ubuntu), rendered from smb.conf; FreeBSD rc.d reads smb4.conf directly |
systemctl restart wsdd-server (only if running and options changed) |
| NFS | ✅ via ZFS | ZFS sharenfs property (ZFS manages /etc/exports) |
automatic on zfs set
|
| iSCSI | ✅ via CLI |
targetcli saveconfig / /etc/ctl.conf
|
targetcli / service ctld restart
|
| TLS | ✅ full |
dumpstore.conf cert fields |
dumpstore reload |
| Users / Groups | — | OS is source of truth | n/a |
| ZFS datasets | — | ZFS kernel properties | n/a |
For config-owning services the write path is extended:
playbooks/smb_apply.yml (example)
┌──────────────────────────────────┐
│ targets: localhost │
│ gather_facts: false │
│ 1. assert vars │
│ 2. create referenced dirs │
│ 3. render full config (template)│
│ 4. reload service │
└──────────────────────────────────┘
Sub-features (shares, home dirs, Time Machine targets) are gated behind an init gate — they are disabled in the UI until the service has been bootstrapped with POST /api/smb/init (or equivalent). This prevents partial config states where the config file exists but hasn't been initialised by dumpstore yet.
Reads call ZFS/system CLI tools directly. Writes are split across three lanes by shape (#115):
| Lane | Mechanism | Used for |
|---|---|---|
| Reads | exec.Command(zpool/zfs/smartctl) |
Everything GET — fast, no Python startup |
| Single-command writes |
internal/ops — in-process argv exec, no shell |
scrub, replace, offline/online, pool create/import/export/add/remove, quotas, snapshot create/destroy/clone |
| Config-file / OS-resource writes |
exec.Command(ansible-playbook) + ndjson callback |
smb.conf rendering, users/groups, TLS, ACLs, dataset property playbooks, scrub schedules, service control |
| Long-running data-plane |
internal/jobs — background process groups |
zfs send | recv, zfs rewrite
|
Both write lanes return the same tasks: [...] step shape (ansible.TaskStep) and stream live progress on the ansible.progress SSE topic — the frontend op-log cannot tell them apart. Input validation for ops-lane writes lives solely in the Go handlers. Ansible (and Python) remains a dependency only for the config-file / OS-resource features.
Browser
│ POST /api/snapshots {"dataset":"tank/data","snapname":"bkp"}
▼
zfs_handlers.go: createSnapshot()
│ validate input (names, labels — Go is the single validation point)
│ build argv: ["zfs", "snapshot", "tank/data@bkp"]
▼
internal/ops: Run(steps) ← in-process, no shell, no Python
│ exec zfs snapshot tank/data@bkp
│ step result → ansible.TaskStep{name, status, msg}
│ each step → ansible.progress SSE topic (live op-log)
▼
handlers.go: return 201 {"snapshot":"tank/data@bkp","tasks":[...]}
▼
Browser: showOpLog() renders task steps in modal
Config-file writes (e.g. SMB shares, user creation) take the same shape
but execute via ansible-playbook with the ndjson callback.
The frontend is vanilla JS with no build step. All data lives in a single state object. Render functions are pure — they read from state and write innerHTML.
A lightweight reactive store (storeSet/storeBatch/subscribe) automatically dispatches render functions when their subscribed state keys change. Each render function registers the state keys it depends on via subscribe(keys, fn). Writing state via storeSet(key, value) triggers only the affected renderers. storeBatch(fn) coalesces multiple key updates so each renderer fires at most once per batch.
On boot:
-
loadAll()fetches all fast endpoints in parallel, wrapped instoreBatch()— each render fires once -
loadSlowMetrics()fires in parallel for/api/iostat(~1 s) and/api/smart(drive scans), updating the I/O and disk health sections when ready -
startSSE()opens a persistentEventSourceconnection; on each messagestoreSet(key, data)auto-dispatches the subscribed renderers - If SSE drops, the client falls back to
setInterval(loadAll, 30_000)and retries SSE after 5 s
UI-local state (collapsedDatasets, selectedSnaps, hideSystemUsers, hideSystemGroups) is mutated directly on state with explicit render calls — it is not managed by the store.
All playbooks target localhost with gather_facts: false. Each playbook:
- Declares required extra vars in a header comment
- Has an
asserttask that validates all inputs before any mutation - Has stable task names (the runner looks them up by name for
RunAndGetStdout)
How to enable it is in Installation → Observability; this section covers the design.
Prometheus stays the pull path (GET /metrics, always on). OpenTelemetry is the push path, gated entirely by the standard OTEL_EXPORTER_OTLP_* env vars — unset means every OTEL call site is a no-op.
-
Traces —
otelhttpwraps the middleware chain (outermost, soRequestLoggersees the span); the span is renamed toMETHOD /route/patternafter mux dispatch and tagged withreq_id. Requests that never reach a route (r.Patternempty) are namedMETHOD (unauthenticated)for 401/403 orMETHOD (unmatched)otherwise, with adumpstore.short_circuitattribute — never the raw path, to keep span-name cardinality bounded. The Ansible runner and the ops layer open child spans from the request context; they usecontext.WithoutCancelso a client disconnect can never kill a mutating command. Jobs get root spans linked (not parented) to the dispatching request since they outlive it; scheduled replication runs and autosnap fires get root spans of their own. ZFS read calls are deliberately untraced. -
Logs (single-producer pipeline) — slog's default handler is an
otelslogbridge into onesdk/log.LoggerProvider. Its synchronous journald exporter (internal/logging.JournalExporter) reproduces the pre-OTEL output byte-for-byte (golden-tested), including syslog<N>priority prefixes, and appendstrace_id/span_idwhen present; the OTLP branch is a batch processor on the same provider, so both sinks emit the same record by construction. Never tee logs at the slog level.-log-stdout=falsedrops the journald branch for OTLP-only setups (startup warning when that would leave no sink at all). -
Metrics — Go runtime metrics and otelhttp's
http.server.*pushed via OTLP; the Prometheus registry is untouched (migrating it to the OTEL meter, #168, is deliberately deferred).
| Path | Responsibility |
|---|---|
main.go |
Flags, startup dependency checks, middleware chain, wiring of scheduler/jobs/poller |
internal/api |
HTTP handlers and route registration; input validation for all writes |
internal/zfs |
Direct zpool/zfs reads; POSIX/NFSv4 ACL parsing |
internal/ops |
Single-command writes (argv exec, no shell) |
internal/ansible |
Playbook runner (ndjson callback) and its metrics |
internal/jobs |
Background process groups for long-running work, persisted status |
internal/scheduler |
5-field cron parser and ticker |
internal/replication |
Scheduled send/recv tasks: holds, incrementals, pruning |
internal/autosnap |
Native com.sun:auto-snapshot* execution and daemon takeover |
internal/smb |
Owned smb.conf and wsdd options: parse and render from templates |
internal/iscsi |
Read iSCSI targets (targetcli saveconfig / ctl.conf) |
internal/system |
Host info, users/groups, services (systemd / rc.d), installed software |
internal/smart, internal/blockdev
|
S.M.A.R.T. data; physical devices and vdev matching |
internal/broker |
SSE pub/sub and the background poller |
internal/auth |
Config file, sessions, login, rate limiting, trusted-proxy identity |
internal/logging, internal/otel
|
slog pipeline (journald exporter), request logging, OTEL setup |
internal/schema, internal/platform
|
Dataset property metadata; OS-specific paths |
playbooks/ |
Ansible playbooks for config-file / OS-resource writes |
static/ |
Frontend: index.html, app.js, per-tab modules in static/js/, style.css
|
contrib/ |
systemd unit and rc.d script |
tests/integration/ |
End-to-end suite against a deployed VM |
dev/ |
CLI stubs for make dev
|
- Input to Ansible extra-vars is validated for shell-special characters (`@;|&$``) before the playbook call
-
static/is served byhttp.FileServer— do not put secrets there - The service runs as root (required for ZFS); do not expose it on a public interface without authentication in front of it
-
Unit tests —
go test ./...; parser- and validator-level, no external dependencies. -
Integration tests —
tests/integration(build tagintegration) drives a deployed instance in the Lima dev VM over HTTP and asserts on real host state: every read endpoint, auth and password change, dataset/snapshot lifecycle,zfs diff, quotas, send/recv and replication jobs, users/groups, Samba, ACLs, chown, iSCSI, TLS, service control, auto-snapshot properties, scrub schedules, and a full pool lifecycle (create, offline/online,zpool replace+ resilver, spares, export/import) on three dedicated scratch disks. Tests skip when a host tool is missing (stale VM, FreeBSD). Run withmake vm-linux-start && make vm-linux-deploy && make test-integration. -
Dev VMs —
make vm-linux-*(Ubuntu 24.04, follows the host architecture) andmake vm-freebsd-*(FreeBSD 15, arm64) run headless under Lima via QEMU; targets arestart,deploy,ssh,stop,destroy. Each VM gets a 10 GiB disk with atankpool created at first boot plus three 1 GiB scratch disks for the integration suite.deploypacks the source tree, copies it in, and runsmake installnatively — no cross-compilation. Port forwards are fixed (Linux:8080, FreeBSD:8081) so both can run at once; loginadmin/admin.make devinstead runs the UI on any machine against the CLI stubs indev/bin/. -
Screenshots —
make screenshotsseeds demo data on the deployed Linux VM (dev/screenshots/seed.sh up), drives headless Chrome through every tab and dialog (dev/screenshots/capture.mjs,puppeteer-coreagainst the installed Chrome;CHROME_PATHoverrides), writesscreenshots/, and always removes the demo data again (seed.sh down). -
CI —
ci.ymlruns build/vet/unit tests plus arelease-smokejob (the fullmake releasecross-build matrix with a--versioncheck) on every PR;integration-tests.ymlboots the Lima VM on a KVM-enabled runner and runs the integration suite nightly, on manual dispatch, and on PRs labeledrun-integration.