Skip to content

SynVirt Backend Conventions

Synced read-only from /home/synnet/mirrors/synvirt-product/docs/BACKEND_CONVENTIONS.md. Edit at the source, not here.

This document governs every Rust crate in SynVirt. It exists so that a developer who has never seen the codebase can contribute a correct change within 1 hour of arrival, and so that AI agents (Claude Code and others) produce consistent output across generations. Deviations require a written justification in the commit message.

/home/synvirt/dev/
├── Cargo.toml workspace manifest
├── docs/ canonical engineering docs (this file)
│ ├── BACKEND_CONVENTIONS.md
│ ├── FRONTEND_PRINCIPLES.md
│ └── architecture.md
├── crates/
│ ├── daemon/ control-plane HTTP API
│ ├── live/ boot-time TUI (ISO live session)
│ ├── console/ post-install TUI (direct console)
│ ├── boot-splash/ post-install boot progress UI
│ ├── tui-theme/ shared TUI palette + layout helpers
│ ├── web-installer/ pre-install browser wizard
│ ├── web-ux/ post-install browser dashboard
│ ├── synvirt-libvirt/ VM / domain / disk / NIC wrapper
│ ├── synvirt-network/ OVS / vSwitch data-plane service (R1, shipped)
│ ├── synvirt-storage/ backend-agnostic StorageBackend trait + types
│ └── synvirt-zfs/ ZFS impl of StorageBackend (R2 service: synvirt-storaged)
└── iso-builder/ live-build pipeline to produce the ISO

Every backend crate follows:

crates/<name>/
├── Cargo.toml
├── README.md one-page overview: what/why/how used by others
└── src/
├── lib.rs re-exports public types; never contains logic
├── error.rs ErrorKind enum with thiserror; one enum per crate
├── <concept>.rs one file per domain concept
└── tests/ contract tests (integration-style)
  • All error types derive thiserror::Error.
  • One Error enum per crate, exposed as pub use error::Error.
  • Public APIs return Result<T, crate::Error>, never anyhow::Result.
  • anyhow::Result is only allowed in main functions and tests.
  • Every variant has a machine-readable discriminant (e.g. #[error("...", code = "E_VM_NOT_FOUND")]).

Do:

#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("VM {name} not found")]
VmNotFound { name: String },
#[error("libvirt connection failed: {source}")]
LibvirtConn { #[source] source: std::io::Error },
}

Don’t:

pub fn get_vm(name: &str) -> Result<Vm, String> { ... }
  • Public APIs are async fn unless the operation is trivially fast (<1ms).
  • Anything that shells out, hits disk, or waits on a socket runs via tokio::task::spawn_blocking to avoid stalling the runtime.
  • Never block the tokio runtime with std::process::Command::status() — use tokio::process::Command or spawn_blocking.
  • Use the tracing crate. Never println!, never eprintln! in library code.
  • Annotate every public async fn with #[tracing::instrument(skip_all)] and emit debug! on entry, info! for business events, warn! for recoverable issues, error! only for non-recoverable failures.
  • Trace IDs propagate across daemon → synvirt-libvirt → external commands.

Example:

#[tracing::instrument(skip_all, fields(vm_name = %req.name))]
pub async fn create_vm(req: CreateVmRequest) -> Result<Vm, Error> {
debug!("entering create_vm");
let xml = xml::render(&req)?;
info!("defining domain {}", req.name);
...
}
  • Contract tests in crates/<name>/src/tests/ exercise the public API only.
  • No tests of internal functions unless they encode non-trivial math.
  • Integration tests that require a running service (libvirtd, zfs, ovs) live under crates/<name>/tests/integration_*.rs and are gated with #[ignore] by default; CI runs them with cargo test -- --ignored.
  • Use mockall or hand-rolled trait mocks to isolate units from the filesystem and external processes in unit tests.

Function verbs carry meaning:

Prefix / pattern Semantic
get_* Fails if absent. Returns Result<T, Error::NotFound>.
find_* Returns Option<T>. Never errors on absence.
list_* Returns Vec<T>. Empty vec if none.
ensure_* Idempotent. Creates if missing, no-op if exists.
reconcile_* Idempotent convergence toward a declared state.
apply_* Destructive / state-changing. Not idempotent. Logged as info.
plan_* Pure, no side effects. Returns a plan object.

Conventional Commits with crate scope:

feat(synvirt-libvirt): add create_vm with cloud-init support
fix(daemon): handle 503 from upstream maintenance middleware
refactor(web-ux): extract VmCard component
docs(backend): clarify tracing conventions

Breaking changes: ! after the scope plus BREAKING CHANGE: footer.

  • cargo fmt must pass. The repo ships a rustfmt.toml using default Rust style.
  • cargo clippy -- -D warnings must pass. Deny warnings in CI.
  • cargo audit must pass. No dependencies with known advisories.
  • No unwrap() or expect() in library code outside of test modules. Use ok_or_else / ? / explicit match.
  • Every pub item gets rustdoc. No exceptions.
  • The first line of a rustdoc is a single sentence summary, complete, ending with a period. Subsequent paragraphs can elaborate.
  • Public enums document each variant.
  • Examples in rustdoc compile and run under cargo test --doc.
  • Each crate has its own version in Cargo.toml.
  • Bump patch on fix, minor on new API, major on breaking change.
  • Root workspace version (the ISO VERSION file) is independent.

All user-facing strings (logs emitted above debug level, error messages, REST response bodies) live in crates/<name>/src/strings.rs as pub const or in a strings/en_us.toml loaded at startup. This is the foundation for i18n. en_us is the only catalog today. Do not hardcode strings inline in business logic.

  • Prefer immutable data and message passing (tokio::sync::mpsc) over shared mutable state.
  • When shared state is unavoidable, use tokio::sync::RwLock for read-heavy, tokio::sync::Mutex for write-heavy. Avoid std::sync::* inside async code.
  • Global singletons (e.g. config) use once_cell::sync::Lazy initialized at daemon startup, never at first access.

Two approaches, in order of preference:

  1. Library binding (e.g. virt crate for libvirt, zfs crate for ZFS). Use when mature and available. Type-safe, faster.
  2. Subprocess (tokio::process::Command). Use when no good binding exists or when debugging ease is more important than performance.

If subprocess, always:

  • Pass arguments as &[&str], never build shell strings.
  • Capture stdout AND stderr separately.
  • On non-zero exit, return Error with the full stderr in the message.
  • Apply a timeout via tokio::time::timeout — no external command runs unbounded.

ZFS execution ownership (doctrine amended 2026-07-01)

Section titled “ZFS execution ownership (doctrine amended 2026-07-01)”

synvirt-storaged is the control plane for storage object lifecycle — pool create/delete/rename/list and the PoolId ↔ PoolLabel registry. It is NOT the sole executor of zfs: streaming data-plane work (zfs send / zfs receive) and per-job snapshot handling execute in the daemon that owns the job. The daemon snapshot stack and the migrator sink set the precedent; synvirt-backupd follows it for backup/restore (backup-P3 signature). Bounding rules:

  • A data-path daemon runs zfs only against snapshots/datasets it owns. backupd enforces this in code: snapshot create/destroy only under the synvirt-backup-* prefix, zfs receive only into its restore staging namespace, violations trip a typed E_BACKUP_SNAPSHOT_FORBIDDEN.
  • Pool lifecycle mutations (zpool create/destroy/rename, import/adopt) remain storaged-only.
  • Tracked deferral (TODO.md): funnel the scattered direct zfs call sites across daemon/migrator/core through one shared, guard-capable helper.
  • No secrets in code, comments, or logs.
  • No unsafe blocks without a // SAFETY: comment explaining invariants.
  • All HTTP endpoints that mutate state require authentication middleware.
  • Rate limit destructive endpoints (delete VM, factory reset) per IP.