Skip to content

feat: arcticwolfctl admin CLI — exports, audit, metrics, graceful shutdown (closes #25)#29

Merged
kchiu merged 9 commits into
polrstorage:mainfrom
amarok-60T:feature/admin-cli
Jun 30, 2026
Merged

feat: arcticwolfctl admin CLI — exports, audit, metrics, graceful shutdown (closes #25)#29
kchiu merged 9 commits into
polrstorage:mainfrom
amarok-60T:feature/admin-cli

Conversation

@amarok-60T

@amarok-60T amarok-60T commented May 19, 2026

Copy link
Copy Markdown
Collaborator

Closes #25.

Adds a Unix-socket-backed admin server to the arcticwolf NFSv3 daemon, plus an arcticwolfctl client binary. End-to-end commands: status, version, log-level get/set, exports list/add/remove/update, config show, metrics. Includes structured audit logging, in-memory operational metrics, and graceful shutdown on SIGTERM/SIGINT.

No HTTP endpoint. No external metrics crate. No new runtime dependencies beyond arc-swap, vergen-gitcl, and tokio_util::sync::CancellationToken (already-included tokio-util 0.7).

Stack (9 commits)

# Phase Subject
1 Phase 1 admin: introduce Unix socket scaffolding with length-prefixed JSON
2 Phase 2 admin: implement status and version commands
3 Phase 2.a refactor: drop dual lib/bin module compilation (cross-cutting)
4 Phase 3 admin: implement log-level get/set with tracing reload handle
5 Phase 4 fsal: refactor MultiExportFilesystem to ArcSwap snapshot
6 Phase 5 admin: wire exports list/add/remove/update and config show
7 Phase 6 admin: structured audit log with SO_PEERCRED capture
8 Phase 7 admin: metrics command with per-export and per-NFS-op counters
9 Phase 8 admin: graceful shutdown on SIGTERM/SIGINT

Each phase was independently reviewed under Opus 4.7 1M context; review findings (SHOULD FIX and NICE TO HAVE) were folded into the same commit via --fixup + --autosquash before progressing to the next phase. CI (Build / Test / Lint / NFS Integration Test) is green on every squashed SHA.

Key design decisions

  • No HTTP surface. Metrics and audit are admin-command-only. Operators reach them via arcticwolfctl metrics and the JSONL audit file. This keeps the attack surface minimal and avoids the dependency overhead of an HTTP framework.
  • In-memory runtime mutation only. exports add/remove/update mutate MultiExportFilesystem via an ArcSwap snapshot but do NOT write back to config.toml. v1 only allows mutating the read_only flag; structural changes (uid, name, backend, path) require config edits + restart.
  • uid retirement. A removed export's uid is retired until daemon restart and cannot be reused, preventing stale handle/uid collisions across mutations. Names are reusable after removal.
  • --dry-run on every mutating command. The dry-run path validates without taking the write lock or mutating state.
  • Audit log in v1. Each accepted admin request emits one JSONL line with ts, peer (uid/gid/pid via SO_PEERCRED), command tag, verbatim request payload, result, error (if any), duration_ms. Sensitive-field redaction is deferred until the S3 backend lands (the local backend has no sensitive config fields).
  • Metrics without prometheus. AtomicU64 counters with Ordering::Relaxed, exposed as a JSON snapshot via arcticwolfctl metrics. Covers rpc_requests_total / rpc_decode_errors_total / rpc_errors_total, all 22 NFSv3 procedure counters, per-export requests/bytes_read/bytes_written/errors, and per-admin-command counters via a pre-populated allowlist (to prevent client-supplied tags from growing the map without bound).
  • Graceful shutdown. SIGTERM and SIGINT trigger a CancellationToken that stops the four accept loops (portmap, mount, NFS, admin). In-flight per-connection handlers are tracked in JoinSet and awaited. The whole drain is bounded by [shutdown] drain_timeout_seconds (default 30); on timeout, a warn is emitted and the daemon exits 0. A second signal during drain escalates to process::exit(128 + signum). Audit channel is flushed and admin socket file is unlinked before exit.
  • One commit per phase. Each phase's review fixes were folded back into the phase commit via --fixup + git rebase -i --autosquash. The history is reviewable phase-by-phase, not fix-by-fix.

Configuration

New sections in config.toml:

[admin]
enabled = true
socket_path = "/var/run/arcticwolf/admin.sock"

[audit]
enabled = false                        # default off
path = "/var/log/arcticwolf/admin-audit.jsonl"

[shutdown]
drain_timeout_seconds = 30             # ceiling, not target; exit 0 on overrun

Config::validate() rejects audit.enabled = true without a non-empty path. See arcticwolf.example.toml for the full annotated example.

Test coverage

  • 230+ library unit tests, including:
    • ArcSwap snapshot semantics (counters survive update_export, reset on remove_export)
    • per-NFS-op counter dispatch (22 procedures)
    • audit JSONL round-trip + RFC 3339 formatter (boundary-checked through year 9999 and Gregorian leap rules)
    • graceful shutdown signal handler mapping (130 / 143)
    • audit Mutex<Option<Sender>> shutdown idempotency
  • Integration tests for status, version, log-level, exports, config-show, metrics, audit, graceful-shutdown — all driving the real Unix-socket wire format.
  • CLI exit-code matrix for every subcommand (success / daemon-Err / unreachable-daemon).
  • NFS Integration Test (make nfstest) green on every squashed SHA — verifies multi-export mount, EROFS enforcement, MNT NOENT, NFS POSIX against /data.

Known limitations / follow-up

  • Idle per-connection handlers block drain until the timeout ceiling. The per-connection read loops in RpcServer::serve and admin::serve_with_shutdown do not check the CancellationToken between requests; an idle client (TCP keepalive on the NFS port, or an arcticwolfctl that connected and never sent anything) keeps its handler task alive until the drain timeout fires. Tracked separately — see follow-up issue.
  • Sensitive-field redaction in audit payload deferred to the S3 backend follow-up.
  • Per-export historical metrics — removing an export drops its counters. Persistent historical metrics out of scope for v1.
  • macOS-only compile_error! in src/lib.rs is unchanged; this PR is Linux-only in practice (matches existing repo policy).

DCO

All commits carry Signed-off-by: trailers.

@gemini-code-assist

Copy link
Copy Markdown

Important

Installation incomplete: to start using Gemini Code Assist, please ask the organization owner(s) to visit the Gemini Code Assist Admin Console and sign the Terms of Services.

@amarok-60T
amarok-60T force-pushed the feature/admin-cli branch from 6af4dff to bdebec8 Compare May 19, 2026 12:40
Phase 1 of polrstorage#25. Adds the admin server skeleton that the rest of
the admin CLI work hangs off:

- New src/admin/ module with protocol.rs (length-prefixed JSON
  codec), request/response enums, server.rs accepting Unix
  socket connections, and an AdminContext placeholder.
- New [admin] config section with enabled=false default so the
  scaffolding is inert unless explicitly turned on.
- main.rs binds the admin Unix socket when [admin] enabled=true
  and spawns the listener alongside the portmap/mount/nfs RPC
  servers; missing socket parent directory or insufficient write
  permission causes fail-fast startup error.
- Adds tokio-util (codec) and nix (will be used for SO_PEERCRED
  in the audit phase) to Cargo.toml.
- Dispatcher currently returns "Command not implemented in
  phase 1" for every request; phase 2 wires up Status.

Tests cover the codec round-trip and the unknown-command error
shape; existing multi-export tests are untouched.

Signed-off-by: amarok-bot <[email protected]>
@amarok-60T
amarok-60T force-pushed the feature/admin-cli branch 4 times, most recently from 687b42e to e577017 Compare May 21, 2026 05:22
amarok-60T and others added 2 commits May 21, 2026 18:16
Phase 2 of polrstorage#25. The dispatcher now handles two read-only commands
end-to-end:

- status — daemon uptime, actual listening ports (after bind so
  port=0 dynamic allocation is resolved), bind address, current
  log level, and the number of configured exports.
- version — daemon CARGO_PKG_VERSION plus vergen-derived
  build_commit, rustc_version, and build_profile.

AdminContext gains the fields these commands read: start_time,
ServerMetadata (actual ports), startup_log_level (Phase 3 will
replace this with a reload handle), filesystem (for export_count),
and config (for bind_address).

The new `arcticwolfctl` binary is the operator-facing client.
It speaks the Phase 1 wire protocol and supports `status`,
`version`, and a global `--socket` flag.

Tests cover the in-process client/daemon round trip for both
commands and assert the response JSON shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Signed-off-by: amarok-bot <[email protected]>
…age#25)

Phase 2.a of polrstorage#25 — a cross-cutting cleanup required before Phase 5
needs admin export mutations to be observable by the NFS path.

Until now the daemon binary re-declared every module
(mod config; mod fsal; ...) instead of importing them from
the library crate, causing every type to be compiled twice
and creating two distinct Type identities the borrow checker
couldn't unify. Phase 2 of polrstorage#25 worked around this by building
a second MultiExportFilesystem instance just so the
AdminContext (which lives in the lib crate) could hold an
Arc<dyn NfsBackend> of the right type.

main.rs now imports modules from the library
(use arcticwolf::{config, fsal, ...}) and the daemon owns a
single MultiExportFilesystem instance that is shared between
the three RPC servers and the admin server. Phase 5 will need
this single source of truth so exports add/remove/update is
observable by both the NFS path and the admin path.

No NFS or admin behavior change; pure structural cleanup.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Signed-off-by: amarok-bot <[email protected]>
Phase 3 of polrstorage#25. Migrates the daemon's tracing setup from
`tracing_subscriber::fmt().init()` to `Registry` + `EnvFilter`
+ `reload::Layer`. The reload handle is threaded through
`AdminContext`, replacing the static `startup_log_level: String`
field added in Phase 2 — `status` now reports the live filter
directive, and the new commands let operators tune verbosity at
runtime without a daemon restart.

AdminRequest gains LogLevelGet and LogLevelSet { level } variants;
the dispatcher routes them to handlers in
src/admin/commands/log_level.rs. Set rejects unparseable filter
strings (e.g. `tomato`) with an error and leaves the previous
filter intact, matching the AC.

arcticwolfctl learns nested `log-level get` and `log-level set
<level>` subcommands; both honour `--socket` and `--json`.

Tests cover the set→get round-trip, the rejected-input path
where filter must not change, and the arcticwolfctl exit-code
contract for both success and failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Signed-off-by: amarok-bot <[email protected]>
@amarok-60T
amarok-60T force-pushed the feature/admin-cli branch from 75fe6e2 to e66d44c Compare May 31, 2026 16:44
Phase 4 of polrstorage#25. Wraps the export map in arc_swap::ArcSwap so admin
commands (Phase 5) can mutate it concurrently with live NFS traffic,
without holding a guard across any .await.

The snapshot type is ExportsSnapshot { by_uid, by_name, retired_uids }.
retired_uids tracks export uids that were live and then removed during
this daemon's lifetime, preventing reuse via add_export until the next
restart (where the config file is re-read fresh).

All Filesystem and ExportRegistry trait methods load the snapshot,
clone the inner Arc<LocalFilesystem> out, drop the snapshot guard, and
only then .await. This pattern is the reason the wrapper exists at all
- the dual-compilation cleanup in Phase 2.a already ensured the
snapshot is shared between the NFS dispatchers and admin commands.

Single-handle ops funnel through `inner_for_handle`; the two
cross-export-pair validators (rename, link) share
`inner_for_same_export_pair` so the "load -> clone Arc -> drop guard"
shape exists in exactly two places and rustfmt has no excuse to wrap
either one weirdly.

New inherent methods on MultiExportFilesystem:
- add_export(&ExportConfig) - rebuilds snapshot, swaps atomically.
  Rejects uid in use, retired uids, name in use, and invalid config
  (uid 0, name not starting with `/`, non-absolute local path).
- remove_export(&ExportSelector) - moves uid to retired_uids.
  Returns the removed uid.
- update_export(&ExportSelector, Option<bool> read_only) - v1 only
  mutates read_only. Larger mutations (name, uid, path) are deferred
  per the polrstorage#25 plan.

ExportSelector lives in fsal::multi_export so the storage layer owns
its own selector type; Phase 5 wires the admin command on top.

This phase does not add any admin command yet. NFS client behaviour
is unchanged.

Tests cover add/remove/update happy paths, all rejection modes
(duplicate uid/name, retired uid reuse, no-op update, not-found,
invalid config), and a read-only flip observable via
is_read_only(handle) round-trip.

Signed-off-by: amarok-bot <[email protected]>
@amarok-60T
amarok-60T force-pushed the feature/admin-cli branch from 96ac41c to b7bfc06 Compare June 1, 2026 12:58
Implements Phase 5 of polrstorage#25. New AdminRequest variants exports-list,
exports-add, exports-remove, exports-update, config-show; handlers
delegate to MultiExportFilesystem inherent methods from Phase 4.
arcticwolfctl gains the matching subcommand tree. --dry-run on
mutating commands validates without taking the write lock. config
show returns the startup config (sensitive-field redaction is a
follow-up when non-local fsals land).

Signed-off-by: amarok-bot <[email protected]>
@amarok-60T
amarok-60T force-pushed the feature/admin-cli branch from 6b63861 to e238897 Compare June 2, 2026 01:33
Implements Phase 6 of polrstorage#25. Every admin request emits one JSONL audit
event including peer credentials (uid/gid/pid via SO_PEERCRED), the
wire command tag, the request payload, the result (ok/err with error
message), and duration in ms. Configurable via [audit] enabled/path
in the daemon config; disabled by default. NoopAuditWriter is used
when disabled. Sensitive-field redaction in the request payload is
deferred to the S3 backend follow-up.

Signed-off-by: amarok-bot <[email protected]>
Implements Phase 7 of polrstorage#25. New AdminRequest::Metrics variant returns
a JSON snapshot of server-wide, per-NFS-op, per-export, and per-admin-
command counters. Counters are AtomicU64-backed, monotonically
increasing within a daemon lifetime, lock-free reads, no external
metrics crate. Per-export counters live on ExportEntry and survive
update_export across snapshot rebuilds; they are dropped on
remove_export. No HTTP endpoint (deferred per spec). arcticwolfctl
gains a metrics subcommand printing the JSON pretty-formatted.

Signed-off-by: amarok-bot <[email protected]>
@Vicente-Cheng
Vicente-Cheng self-requested a review June 14, 2026 07:39
Implements Phase 8 of polrstorage#25 (final phase). Daemon catches SIGTERM and
SIGINT, signals all four server loops (portmap, mount, NFS, admin)
via a CancellationToken, drains in-flight requests bounded by
[shutdown] drain_timeout_seconds (default 30), flushes the audit
writer, and removes the admin socket file before exit. A second
signal during drain escalates to immediate process exit. Shutdown
completes with exit status 0; drain timeout logs a warn but is not
a failure.

Signed-off-by: amarok-bot <[email protected]>
@amarok-60T amarok-60T changed the title feat: admin CLI Unix socket scaffolding (phase 1 of #25) feat: arcticwolfctl admin CLI — exports, audit, metrics, graceful shutdown (closes #25) Jun 14, 2026
@amarok-60T
amarok-60T marked this pull request as ready for review June 14, 2026 09:31
@amarok-60T

Copy link
Copy Markdown
Collaborator Author

Architecture overview as flow charts, in case it helps a cold reviewer orient.

1. Request flow — arcticwolfctl to daemon

flowchart LR
    subgraph client["Client"]
        ctl["arcticwolfctl<br/>(clap CLI)"]
    end

    subgraph wire["Wire"]
        uds["Unix socket<br/>length-prefixed JSON"]
    end

    subgraph daemon["arcticwolf daemon"]
        accept["admin accept loop<br/>SO_PEERCRED on accept"]
        dispatch["Dispatcher<br/>route by command tag"]

        subgraph handlers["Handlers"]
            h1["status<br/>version"]
            h2["log-level<br/>get / set"]
            h3["exports<br/>list / add / remove / update"]
            h4["config-show"]
            h5["metrics"]
        end

        subgraph state["Shared state (Arc)"]
            mef["MultiExportFilesystem<br/>ArcSwap&lt;ExportsSnapshot&gt;<br/>+ retired_uids"]
            cfg["Config<br/>(startup snapshot)"]
            metrics_obj["Metrics<br/>AtomicU64 counters<br/>+ pre-populated by_command"]
            reload["LogReloadHandle"]
            audit["AuditWriter<br/>mpsc to BufWriter"]
        end

        afile[("admin-audit.jsonl")]
        rpc["Portmap / Mount / NFS<br/>(RpcServer::serve)"]
    end

    ctl <--> uds
    uds <--> accept
    accept --> dispatch
    dispatch --> h1 & h2 & h3 & h4 & h5
    h1 -.read.-> cfg
    h2 -->|reload| reload
    h3 -->|add / remove / update + list| mef
    h4 -.read.-> cfg
    h5 -.read.-> metrics_obj

    accept -->|record event| audit
    audit -->|writer task| afile

    accept -->|bump counter| metrics_obj
    rpc -->|bump per-op + bytes| metrics_obj
    rpc -->|fs ops| mef
Loading

Notes:

  • MultiExportFilesystem mutating methods (add_export / remove_export / update_export) are serialized by a Mutex<()> write lock; readers (every NFS trait method) are lock-free via ArcSwap.
  • Per-export Arc<ExportMetrics> survives update_export snapshot rebuilds (refcount bump on the cloned ExportEntry), and resets on remove_export + re-add.
  • config-show returns the startup config snapshot, not the live mutated state — the difference matters once runtime mutations land.
  • AuditWriter::record is fire-and-forget; the writer task drains the mpsc into the file with per-line flush().

2. Graceful shutdown — Phase 8

flowchart TB
    sig["SIGTERM / SIGINT<br/>(handler installed right after tracing init)"]

    sig -->|1st signal| token["CancellationToken.cancel()"]
    token -->|notify| servers["Four accept loops<br/>stop accepting new connections"]
    servers --> drain["Drain per-server JoinSet<br/>(in-flight requests finish)"]
    drain --> wrap{"tokio::time::timeout<br/>(drain_timeout_seconds)"}
    wrap -->|complete| flush["audit.shutdown()<br/>drain mpsc to file"]
    wrap -->|ceiling| warn["warn: drain timeout<br/>(in_flight=N)"]
    warn --> flush
    flush --> unlink["unlink admin socket"]
    unlink --> exit0["exit 0"]

    sig -.->|2nd signal during drain| escalate["process::exit(128 + signum)<br/>SIGINT to 130, SIGTERM to 143"]
Loading

Notes:

  • The Mutex<Option<Sender>> pattern inside FileAuditWriter lets shutdown() close the audit channel regardless of how many Arc<dyn AuditWriter> clones are still alive.
  • Drain timeout is a ceiling, not a target — exit status remains 0 if the ceiling fires.
  • The admin socket file is unlinked via a RAII SocketCleanup guard at the end of serve_with_shutdown, plus a best-effort fallback in main.rs for the timeout path.
  • See follow-up issue admin: idle per-connection handlers block graceful drain until ceiling #30 for the known limitation that idle per-connection handlers block drain until the ceiling.

@Vicente-Cheng
Vicente-Cheng requested a review from kchiu June 14, 2026 14:02
@kchiu
kchiu merged commit 8b813e8 into polrstorage:main Jun 30, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Add admin CLI

2 participants