Settings DNS field + per-host /etc/hosts editor — Implementation Plan
Synced read-only from
/home/synnet/mirrors/synvirt-product/docs/superpowers/plans/2026-06-29-settings-dns-hosts.md. Edit at the source, not here.
Settings DNS field + per-host /etc/hosts editor — Implementation Plan
Section titled “Settings DNS field + per-host /etc/hosts editor — Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make per-interface DNS editable in Settings → IP/DNS and Hostname, and add a per-host /etc/hosts entries editor owned by synvirt-network.
Architecture: synvirt-network owns a delimited managed block in /etc/hosts (new hosts.rs, mirroring dns.rs): network.yaml carries host_entries, the block is regenerated atomically on boot and on-demand. The daemon exposes read+PUT under the existing Settings → Network surface; web-ux-v2 adds a DNS input and a “Host file entries” card to the same panel. The DNS-field fix is frontend-only (the backend already round-trips dns).
Tech Stack: Rust (axum, thiserror, tokio, serde_yaml), Vue 3.5 + TypeScript + Vite + Pinia, utoipa/openapi-typescript.
Global Constraints
Section titled “Global Constraints”- Rust: one
Errorenum per crate viathiserrorwithE_*discriminant codes; nounwrap()/expect()in library code;#[tracing::instrument(skip_all)]on public async fns;cargo fmt+cargo clippy -- -D warningsclean. - Every
#[utoipa::path]handler sets an explicitoperation_id. network.yamlis the single source of truth;synvirt-networkis the sole applicator. New fields use#[serde(default)]so existing files keep loading.- The
/etc/hostswrite touches ONLY the managed block — system lines preserved byte-for-byte. Atomic write viacrate::uplink_l2::write_atomic. - TS: strict, no
any. After any endpoint change, re-dump OpenAPI and regenerateschemas.ts; keepopenapi.snapshot.jsonin lockstep. - Scope: per-host (no fleet fan-out); no search domains; English UI copy; brand
#0566C1. - Frozen-contract rule:
openapi.snapshot.jsonis the frontend contract — re-dump after touching endpoints.
Task 1: HostEntry model + NetworkYaml.host_entries
Section titled “Task 1: HostEntry model + NetworkYaml.host_entries”Files:
- Modify:
crates/synvirt-network/src/spec/mod.rs:26-35(NetworkYaml) + new struct nearVmKernel - Test: same file
#[cfg(test)](serde roundtrip)
Interfaces:
-
Produces:
pub struct HostEntry { pub ip: String, pub hostnames: Vec<String>, pub comment: Option<String> };NetworkYaml.host_entries: Vec<HostEntry>. -
Step 1: Write the failing test — append to the existing
#[cfg(test)] mod testsinspec/mod.rs:
#[test]fn network_yaml_defaults_host_entries_when_absent() { // A YAML written before this field existed must still parse. let y = "version: 1\nhostname: h\nvmnics: []\nvswitches: []\n"; let parsed: NetworkYaml = serde_yaml::from_str(y).unwrap(); assert!(parsed.host_entries.is_empty());}
#[test]fn host_entry_roundtrips() { let e = HostEntry { ip: "172.16.11.50".into(), hostnames: vec!["vcenter.synnet.mx".into(), "vcenter".into()], comment: Some("dc".into()), }; let s = serde_yaml::to_string(&e).unwrap(); let back: HostEntry = serde_yaml::from_str(&s).unwrap(); assert_eq!(e, back);}- Step 2: Run test to verify it fails
Run: cargo test -p synvirt-network spec::tests::network_yaml_defaults_host_entries_when_absent
Expected: FAIL — HostEntry not found / host_entries unknown field.
- Step 3: Write minimal implementation — add the struct after
VmKernel(after line ~414) and the field toNetworkYaml:
/// One managed entry in `/etc/hosts`: an address mapped to one or more names.#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]pub struct HostEntry { /// IPv4 or IPv6 literal. pub ip: String, /// One or more hostnames/aliases for this address (each RFC 1123). pub hostnames: Vec<String>, /// Optional operator note rendered as a trailing `# comment`. #[serde(default, skip_serializing_if = "Option::is_none")] pub comment: Option<String>,}In NetworkYaml (after the hci field, line ~34):
#[serde(default)] pub host_entries: Vec<HostEntry>,Also update NetworkYaml::empty() (line ~41) to set host_entries: Vec::new(),.
- Step 4: Run tests to verify they pass
Run: cargo test -p synvirt-network spec::tests::network_yaml_defaults_host_entries_when_absent spec::tests::host_entry_roundtrips
Expected: PASS. Then cargo build -p synvirt-network (catches any other NetworkYaml { .. } literal that now needs the field — fix each by adding host_entries: Vec::new() or ..Default as appropriate; the dns.rs test helpers build NetworkYaml via struct literal, so add the field there too).
- Step 5: Commit
git add crates/synvirt-network/src/spec/mod.rsgit commit -m "feat(synvirt-network): add HostEntry model + NetworkYaml.host_entries"Task 2: hosts.rs — errors + validate_entries
Section titled “Task 2: hosts.rs — errors + validate_entries”Files:
- Create:
crates/synvirt-network/src/hosts.rs - Modify:
crates/synvirt-network/src/lib.rs(addpub mod hosts;)
Interfaces:
-
Consumes:
crate::spec::{HostEntry, is_rfc1123_hostname}. -
Produces:
pub enum HostsError(BadIp,BadName,NoName,ReservedName,Io);pub fn validate_entries(entries: &[HostEntry], own_hostname: &str) -> Result<(), HostsError>. -
Step 1: Write the failing test — create
crates/synvirt-network/src/hosts.rswith only the test module to start:
#[cfg(test)]mod tests { use super::*; use crate::spec::HostEntry;
fn e(ip: &str, names: &[&str]) -> HostEntry { HostEntry { ip: ip.into(), hostnames: names.iter().map(|s| s.to_string()).collect(), comment: None } }
#[test] fn validate_accepts_v4_v6_and_names() { let ents = vec![e("172.16.11.50", &["vcenter.synnet.mx", "vcenter"]), e("fd00::1", &["v6host"])]; validate_entries(&ents, "synvirt-02").unwrap(); }
#[test] fn validate_rejects_bad_ip() { let err = validate_entries(&[e("not-an-ip", &["x"])], "h").unwrap_err(); assert!(matches!(err, HostsError::BadIp(_))); }
#[test] fn validate_rejects_bad_name() { let err = validate_entries(&[e("10.0.0.1", &["bad host!"])], "h").unwrap_err(); assert!(matches!(err, HostsError::BadName(_))); }
#[test] fn validate_rejects_empty_names() { let err = validate_entries(&[e("10.0.0.1", &[])], "h").unwrap_err(); assert!(matches!(err, HostsError::NoName(_))); }
#[test] fn validate_rejects_loopback_and_own_hostname() { let lo = validate_entries(&[e("10.0.0.1", &["localhost"])], "h").unwrap_err(); assert!(matches!(lo, HostsError::ReservedName(_))); let own = validate_entries(&[e("10.0.0.1", &["synvirt-02"])], "synvirt-02").unwrap_err(); assert!(matches!(own, HostsError::ReservedName(_))); }}- Step 2: Run test to verify it fails
Run: cargo test -p synvirt-network hosts::tests::validate_accepts_v4_v6_and_names
Expected: FAIL — validate_entries / HostsError not found.
- Step 3: Write minimal implementation — prepend to
hosts.rs(above the test module):
//! `/etc/hosts` apply — writes ONLY a delimited managed block from//! `network.yaml.host_entries`. Mirrors `dns.rs`: pure render + splice//! helpers, an atomic IO wrapper, and strict validation for the write//! path. System lines (loopback etc.) are preserved byte-for-byte.
use std::net::IpAddr;use std::str::FromStr;
use crate::spec::{HostEntry, NetworkYaml};
/// Names owned by the system loopback lines — never managed here.const RESERVED: &[&str] = &[ "localhost", "localhost.localdomain", "localhost4", "localhost6", "localhost4.localdomain4", "localhost6.localdomain6", "ip6-localhost", "ip6-loopback", "ip6-allnodes", "ip6-allrouters",];
#[derive(Debug, thiserror::Error)]pub enum HostsError { #[error("E_HOST_ENTRY_BAD_IP: {0:?} is not a valid IP address")] BadIp(String), #[error("E_HOST_ENTRY_BAD_NAME: {0:?} is not a valid hostname")] BadName(String), #[error("E_HOST_ENTRY_NO_NAME: entry for {0:?} has no hostnames")] NoName(String), #[error("E_HOST_ENTRY_RESERVED_NAME: {0:?} is reserved and cannot be managed here")] ReservedName(String), #[error("E_HOSTS_IO: {0}")] Io(#[from] std::io::Error),}
/// Strict validation for the write path (UI/API). Boot rendering is/// lenient (skips invalid entries) — see `render_managed_block`.pub fn validate_entries(entries: &[HostEntry], own_hostname: &str) -> Result<(), HostsError> { let own = own_hostname.to_ascii_lowercase(); for e in entries { IpAddr::from_str(e.ip.trim()).map_err(|_| HostsError::BadIp(e.ip.clone()))?; if e.hostnames.is_empty() { return Err(HostsError::NoName(e.ip.clone())); } for h in &e.hostnames { if !crate::spec::is_rfc1123_hostname(h) { return Err(HostsError::BadName(h.clone())); } let hl = h.to_ascii_lowercase(); if RESERVED.contains(&hl.as_str()) || hl == own { return Err(HostsError::ReservedName(h.clone())); } } } Ok(())}Add to crates/synvirt-network/src/lib.rs next to the other pub mod lines (alphabetical near dns): pub mod hosts;
- Step 4: Run tests to verify they pass
Run: cargo test -p synvirt-network hosts::tests::validate
Expected: PASS (all 5 validate tests).
- Step 5: Commit
git add crates/synvirt-network/src/hosts.rs crates/synvirt-network/src/lib.rsgit commit -m "feat(synvirt-network): hosts.rs validation + HostsError"Task 3: hosts.rs — render_managed_block + splice
Section titled “Task 3: hosts.rs — render_managed_block + splice”Files:
- Modify:
crates/synvirt-network/src/hosts.rs
Interfaces:
-
Produces:
pub(crate) fn render_managed_block(entries: &[HostEntry]) -> String(empty string when no valid entries);pub(crate) fn splice(existing: &str, block: &str) -> String(idempotent). -
Step 1: Write the failing test — add to the
testsmodule:
#[test]fn render_skips_invalid_and_formats_valid() { let ents = vec![ e("172.16.11.50", &["vcenter.synnet.mx", "vcenter"]), e("bad", &["x"]), // skipped: bad ip HostEntry { ip: "10.0.0.9".into(), hostnames: vec!["nas".into()], comment: Some("storage".into()) }, ]; let b = render_managed_block(&ents); assert!(b.starts_with("# >>> SYNVIRT MANAGED")); assert!(b.contains("172.16.11.50\tvcenter.synnet.mx vcenter\n")); assert!(b.contains("10.0.0.9\tnas\t# storage\n")); assert!(!b.contains("bad")); assert!(b.trim_end().ends_with("# <<< SYNVIRT MANAGED"));}
#[test]fn render_empty_when_no_valid_entries() { assert_eq!(render_managed_block(&[]), "");}
#[test]fn splice_appends_then_is_idempotent() { let base = "127.0.0.1 localhost\n127.0.1.1 myhost\n"; let block = render_managed_block(&[e("10.0.0.9", &["nas"])]); let once = splice(base, &block); assert!(once.starts_with("127.0.0.1 localhost\n127.0.1.1 myhost\n")); assert!(once.contains("# >>> SYNVIRT MANAGED")); let twice = splice(&once, &block); assert_eq!(once, twice, "splice must be idempotent");}
#[test]fn splice_replaces_existing_block() { let base = "127.0.0.1 localhost\n"; let b1 = render_managed_block(&[e("10.0.0.1", &["a"])]); let b2 = render_managed_block(&[e("10.0.0.2", &["b"])]); let step1 = splice(base, &b1); let step2 = splice(&step1, &b2); assert!(step2.contains("10.0.0.2\tb")); assert!(!step2.contains("10.0.0.1\ta")); assert!(step2.starts_with("127.0.0.1 localhost\n"));}
#[test]fn splice_empty_block_removes_region_preserving_system_lines() { let base = "127.0.0.1 localhost\n"; let withblock = splice(base, &render_managed_block(&[e("10.0.0.1", &["a"])])); let removed = splice(&withblock, ""); assert_eq!(removed, "127.0.0.1 localhost\n");}- Step 2: Run test to verify it fails
Run: cargo test -p synvirt-network hosts::tests::render_skips_invalid_and_formats_valid
Expected: FAIL — render_managed_block not found.
- Step 3: Write minimal implementation — add to
hosts.rs(above tests):
const BEGIN: &str = "# >>> SYNVIRT MANAGED — do not edit by hand";const END: &str = "# <<< SYNVIRT MANAGED";
/// Render the managed block for the given entries. Lenient: structurally/// invalid entries are skipped (boot must never fail on a hand-edit)./// Returns "" when no entry is renderable.pub(crate) fn render_managed_block(entries: &[HostEntry]) -> String { let mut lines: Vec<String> = Vec::new(); for e in entries { let ip = e.ip.trim(); if IpAddr::from_str(ip).is_err() { continue; } let names: Vec<&str> = e .hostnames .iter() .map(|h| h.trim()) .filter(|h| !h.is_empty() && crate::spec::is_rfc1123_hostname(h)) .collect(); if names.is_empty() { continue; } let mut line = format!("{ip}\t{}", names.join(" ")); if let Some(c) = e.comment.as_ref().map(|c| c.trim()).filter(|c| !c.is_empty()) { line.push_str(&format!("\t# {c}")); } lines.push(line); } if lines.is_empty() { return String::new(); } let mut out = String::new(); out.push_str(BEGIN); out.push('\n'); for l in &lines { out.push_str(l); out.push('\n'); } out.push_str(END); out.push('\n'); out}
/// Strip any existing managed region (BEGIN..=END plus one trailing/// newline), returning the base with a single trailing newline.fn strip_region(existing: &str) -> String { let Some(b) = existing.find(BEGIN) else { return existing.to_string(); }; let Some(rel) = existing[b..].find(END) else { return existing.to_string(); }; let mut end = b + rel + END.len(); if existing[end..].starts_with('\n') { end += 1; } let before = existing[..b].trim_end_matches('\n'); let after = &existing[end..]; if before.is_empty() { after.to_string() } else if after.is_empty() { format!("{before}\n") } else { format!("{before}\n{after}") }}
/// Replace (or insert/remove) the managed block. Idempotent: strip any/// prior region, then append the new block after exactly one newline.pub(crate) fn splice(existing: &str, block: &str) -> String { let stripped = strip_region(existing); if block.is_empty() { return stripped; } let base = stripped.trim_end_matches('\n'); if base.is_empty() { block.to_string() } else { format!("{base}\n{block}") }}- Step 4: Run tests to verify they pass
Run: cargo test -p synvirt-network hosts::tests
Expected: PASS (validation + render + splice tests).
- Step 5: Commit
git add crates/synvirt-network/src/hosts.rsgit commit -m "feat(synvirt-network): hosts.rs managed-block render + idempotent splice"Task 4: hosts.rs — apply_hosts (IO) + boot wiring
Section titled “Task 4: hosts.rs — apply_hosts (IO) + boot wiring”Files:
- Modify:
crates/synvirt-network/src/hosts.rs - Modify:
crates/synvirt-network/src/main.rs:~334(boot reconcile, next todns::apply_dns)
Interfaces:
-
Consumes:
crate::uplink_l2::write_atomic. -
Produces:
pub fn apply_hosts(yaml: &NetworkYaml) -> Result<HostsApplyReport, HostsError>;pub struct HostsApplyReport { pub entries_written: usize }. HonorsSYNVIRT_HOSTS_PATH(test override; default/etc/hosts). -
Step 1: Write the failing test — add to the
testsmodule:
#[test]fn apply_writes_block_into_a_temp_hosts_file() { use crate::spec::NetworkYaml; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("hosts"); std::fs::write(&path, "127.0.0.1 localhost\n").unwrap(); std::env::set_var("SYNVIRT_HOSTS_PATH", &path);
let mut y = NetworkYaml::empty("synvirt-02"); y.host_entries = vec![e("172.16.11.50", &["vcenter.synnet.mx"])]; let report = apply_hosts(&y).unwrap(); assert_eq!(report.entries_written, 1);
let written = std::fs::read_to_string(&path).unwrap(); assert!(written.starts_with("127.0.0.1 localhost\n")); assert!(written.contains("172.16.11.50\tvcenter.synnet.mx"));
// Empty -> block removed, system line preserved. y.host_entries.clear(); apply_hosts(&y).unwrap(); assert_eq!(std::fs::read_to_string(&path).unwrap(), "127.0.0.1 localhost\n"); std::env::remove_var("SYNVIRT_HOSTS_PATH");}- Step 2: Run test to verify it fails
Run: cargo test -p synvirt-network hosts::tests::apply_writes_block_into_a_temp_hosts_file
Expected: FAIL — apply_hosts / HostsApplyReport not found.
- Step 3: Write minimal implementation — add to
hosts.rs(above tests; adduse std::path::{Path, PathBuf};to the imports anduse tracing::{info, instrument};):
/// Canonical hosts file. Overridable via `SYNVIRT_HOSTS_PATH` for tests.pub const ETC_HOSTS: &str = "/etc/hosts";
#[derive(Debug, Default)]pub struct HostsApplyReport { pub entries_written: usize,}
fn hosts_path() -> PathBuf { std::env::var_os("SYNVIRT_HOSTS_PATH") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(ETC_HOSTS))}
/// Apply `host_entries` by rewriting only the managed block in `/etc/hosts`./// Idempotent; preserves all non-managed lines. Empty list removes the block.#[instrument(skip_all, target = "synvirt.network.hosts")]pub fn apply_hosts(yaml: &NetworkYaml) -> Result<HostsApplyReport, HostsError> { let path = hosts_path(); let existing = std::fs::read_to_string(&path).unwrap_or_default(); let block = render_managed_block(&yaml.host_entries); let next = splice(&existing, &block); crate::uplink_l2::write_atomic(Path::new(&path), next.as_bytes(), 0o644)?; let entries_written = yaml.host_entries.len(); info!( target: "synvirt.network.hosts", entries = entries_written, "/etc/hosts managed block regenerated from network.yaml" ); Ok(HostsApplyReport { entries_written })}Then wire boot: in crates/synvirt-network/src/main.rs, immediately after the existing dns::apply_dns spawn_blocking block (~line 334), add an analogous block:
let yaml_for_hosts = yaml.clone(); if let Err(e) = tokio::task::spawn_blocking(move || synvirt_network::hosts::apply_hosts(&yaml_for_hosts)) .await .unwrap_or_else(|e| Err(synvirt_network::hosts::HostsError::Io(std::io::Error::other(e.to_string())))) { tracing::error!(target: "synvirt.network.hosts", error = %e, "failed to apply /etc/hosts on boot"); }(Match the exact variable name used for the DNS clone at that site — read main.rs:~330-340 and mirror it; do not assume yaml if the local is named differently.)
- Step 4: Run tests + build to verify
Run: cargo test -p synvirt-network hosts::tests::apply_writes_block_into_a_temp_hosts_file && cargo build -p synvirt-network
Expected: PASS + clean build.
- Step 5: Commit
git add crates/synvirt-network/src/hosts.rs crates/synvirt-network/src/main.rsgit commit -m "feat(synvirt-network): apply_hosts IO + boot-time /etc/hosts materialization"Task 5: Controller host_entries() + set_host_entries()
Section titled “Task 5: Controller host_entries() + set_host_entries()”Files:
- Modify:
crates/synvirt-network/src/controller.rs(after the Hostname operations block, ~line 1001) - Test:
crates/synvirt-network/src/controller/tests.rs
Interfaces:
-
Consumes:
crate::hosts::{validate_entries, apply_hosts},crate::spec::HostEntry,self.yaml: Arc<RwLock<NetworkYaml>>,self.yaml_path,yaml_store::save_atomic. -
Produces:
pub async fn host_entries(&self) -> Vec<HostEntry>;pub async fn set_host_entries(&self, entries: Vec<HostEntry>) -> Result<(), NetworkError>. -
Step 1: Write the failing test — add to
controller/tests.rs(uses the existingtest_controller_with_default_vswitchhelper):
#[tokio::test]async fn set_host_entries_rejects_invalid_and_persists_valid() { use crate::spec::HostEntry; let dir = tempdir().unwrap(); std::env::set_var("SYNVIRT_HOSTS_PATH", dir.path().join("hosts")); std::fs::write(dir.path().join("hosts"), "127.0.0.1 localhost\n").unwrap();
let (ctrl, yaml_path) = test_controller_with_default_vswitch().await;
// invalid: bad ip -> Validation, nothing persisted let bad = vec![HostEntry { ip: "nope".into(), hostnames: vec!["x".into()], comment: None }]; let err = ctrl.set_host_entries(bad).await.unwrap_err(); assert!(matches!(err, NetworkError::Validation(_))); assert!(ctrl.host_entries().await.is_empty());
// valid: persisted to YAML + materialized into the temp hosts file let good = vec![HostEntry { ip: "172.16.11.50".into(), hostnames: vec!["vcenter.synnet.mx".into()], comment: None }]; ctrl.set_host_entries(good).await.unwrap(); assert_eq!(ctrl.host_entries().await.len(), 1); let on_disk = tokio::fs::read_to_string(&yaml_path).await.unwrap(); assert!(on_disk.contains("vcenter.synnet.mx")); let hosts = std::fs::read_to_string(dir.path().join("hosts")).unwrap(); assert!(hosts.contains("172.16.11.50\tvcenter.synnet.mx")); std::env::remove_var("SYNVIRT_HOSTS_PATH");}- Step 2: Run test to verify it fails
Run: cargo test -p synvirt-network set_host_entries_rejects_invalid_and_persists_valid
Expected: FAIL — set_host_entries / host_entries not found.
- Step 3: Write minimal implementation — add after
set_hostname(line ~1001) incontroller.rs:
// -------------------------------------------------------------------- // /etc/hosts managed entries // --------------------------------------------------------------------
/// The host's managed `/etc/hosts` entries (the `network.yaml` /// `host_entries` field). #[instrument(skip(self))] pub async fn host_entries(&self) -> Vec<crate::spec::HostEntry> { self.yaml.read().await.host_entries.clone() }
/// Validate, persist to `network.yaml`, then materialize the managed /// block in `/etc/hosts`. No OVS reconcile; a local file write only. #[instrument(skip(self), fields(count = entries.len()))] pub async fn set_host_entries( &self, entries: Vec<crate::spec::HostEntry>, ) -> Result<(), NetworkError> { let own = self.yaml.read().await.hostname.clone(); crate::hosts::validate_entries(&entries, &own) .map_err(|e| NetworkError::Validation(e.to_string()))?;
let applied = { let mut guard = self.yaml.write().await; let pre = guard.clone(); guard.host_entries = entries; if let Err(e) = yaml_store::save_atomic(&self.yaml_path, &guard).await { *guard = pre; return Err(NetworkError::Internal(format!( "failed to persist network.yaml: {e}" ))); } guard.clone() };
crate::hosts::apply_hosts(&applied) .map_err(|e| NetworkError::Internal(format!("failed to apply /etc/hosts: {e}")))?; Ok(()) }- Step 4: Run test to verify it passes
Run: cargo test -p synvirt-network set_host_entries_rejects_invalid_and_persists_valid
Expected: PASS.
- Step 5: Commit
git add crates/synvirt-network/src/controller.rs crates/synvirt-network/src/controller/tests.rsgit commit -m "feat(synvirt-network): controller host_entries/set_host_entries"Task 6: Daemon API — view field + PUT /settings/network/hosts
Section titled “Task 6: Daemon API — view field + PUT /settings/network/hosts”Files:
- Modify:
crates/daemon/src/settings/api/network.rs - Modify:
crates/daemon/src/settings/api/mod.rs:28-29(add route) - Test:
crates/daemon/src/settings/api/network.rs#[cfg(test)](view passthrough)
Interfaces:
-
Consumes:
NetworkController::{host_entries, set_host_entries},synvirt_network::spec::HostEntry. -
Produces:
HostEntryView { ip, hostnames, comment };NetworkSettingsView.host_entries: Vec<HostEntryView>;PutHostsBody { entries: Vec<HostEntryInput> };pub async fn put_hosts(...). -
Step 1: Write the failing test — add a
#[cfg(test)]tonetwork.rs:
#[cfg(test)]mod tests { use super::*;
#[test] fn view_carries_host_entries() { let entries = vec![HostEntryView { ip: "172.16.11.50".into(), hostnames: vec!["vcenter.synnet.mx".into()], comment: None, }]; let v = build_network_view("h".into(), false, &[], entries.clone()); assert_eq!(v.host_entries, entries); }}- Step 2: Run test to verify it fails
Run: cargo test -p synvirt-daemon settings::api::network::tests::view_carries_host_entries
Expected: FAIL — HostEntryView / 4-arg build_network_view not found.
- Step 3: Write minimal implementation
In network.rs, add the view type (derive PartialEq so the test compares) and extend the settings view:
/// A single managed `/etc/hosts` entry.#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]pub struct HostEntryView { pub ip: String, pub hostnames: Vec<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub comment: Option<String>,}Add pub host_entries: Vec<HostEntryView>, to NetworkSettingsView (after addable_vswitches). Change build_network_view to accept and set it:
pub(crate) fn build_network_view( hostname: String, is_cluster_member: bool, vswitches: &[VSwitchInfo], host_entries: Vec<HostEntryView>,) -> NetworkSettingsView { // ... existing mgmt_interfaces / addable_vswitches loop unchanged ... let fqdn = hostname.clone(); NetworkSettingsView { hostname, fqdn, is_cluster_member, mgmt_interfaces, addable_vswitches, host_entries }}Update read_view to fetch entries and map them:
let host_entries = ctrl .host_entries() .await .into_iter() .map(|e| HostEntryView { ip: e.ip, hostnames: e.hostnames, comment: e.comment }) .collect(); Ok(build_network_view(hostname, is_cluster_member, &vswitches, host_entries))Add the request body + handler:
/// Request body for `PUT /api/v1/settings/network/hosts`.#[derive(Debug, Clone, Deserialize, ToSchema)]pub struct PutHostsBody { pub entries: Vec<HostEntryView>,}
/// `PUT /api/v1/settings/network/hosts`#[utoipa::path( put, path = "/api/v1/settings/network/hosts", operation_id = "put_settings_network_hosts", tag = "settings", request_body = PutHostsBody, responses( (status = 200, description = "Host entries applied", body = SettingsEnvelope<NetworkSettingsView>), (status = 400, body = crate::api::error::ErrorEnvelope), (status = 401, body = crate::api::error::ErrorEnvelope), (status = 503, body = crate::api::error::ErrorEnvelope), ), security(("basic_auth" = [])),)]#[instrument(skip_all, fields(actor = %actor.0, count = body.entries.len()), target = "synvirt.settings.network")]pub async fn put_hosts( State(state): State<AppState>, Extension(actor): Extension<AuthUser>, Json(body): Json<PutHostsBody>,) -> Result<impl IntoResponse, ApiError> { let ctrl = state.network_controller.as_ref().ok_or_else(|| { ApiError::new(StatusCode::SERVICE_UNAVAILABLE, "E_NETWORK_UNAVAILABLE", "network controller unavailable") })?; let entries = body .entries .into_iter() .map(|e| synvirt_network::spec::HostEntry { ip: e.ip, hostnames: e.hostnames, comment: e.comment }) .collect(); ctrl.set_host_entries(entries).await.map_err(|e| { let msg = e.to_string(); if matches!(e, synvirt_network::error::NetworkError::Validation(_)) { ApiError::bad("E_HOST_ENTRY_INVALID", msg) } else { ApiError::internal("E_NETWORK_HOSTS_APPLY", msg) } })?; tracing::info!(target: "synvirt.settings.network", actor = %actor.0, "host entries updated"); let view = read_view(&state).await?; Ok((StatusCode::OK, Json(SettingsEnvelope::ok(view))))}(Confirm synvirt_network::error::NetworkError is the public path; if re-exported, use the canonical one. Add use serde::Deserialize; if not already imported — it is.)
In mod.rs, add the route after the hostname route (line 29):
.route("/v1/settings/network/hosts", put(network::put_hosts))- Step 4: Run test + build
Run: cargo test -p synvirt-daemon settings::api::network::tests::view_carries_host_entries && cargo build -p synvirt-daemon
Expected: PASS + clean build (fix any other build_network_view( call site — read_view is the only one).
- Step 5: Commit
git add crates/daemon/src/settings/api/network.rs crates/daemon/src/settings/api/mod.rsgit commit -m "feat(daemon): settings network host-entries view + PUT /settings/network/hosts"Task 7: OpenAPI re-dump + TS regen
Section titled “Task 7: OpenAPI re-dump + TS regen”Files:
- Modify:
crates/web-ux-v2/openapi.snapshot.json - Modify:
crates/web-ux-v2/src/api/generated/schemas.ts
Interfaces: none (contract sync only).
- Step 1: Build the daemon and dump the spec
Run:
cargo build -p synvirt-daemon --release./target/release/synvirt-daemon --dump-openapi > crates/web-ux-v2/openapi.snapshot.jsonExpected: openapi.snapshot.json now contains put_settings_network_hosts and HostEntryView (verify: grep -c put_settings_network_hosts crates/web-ux-v2/openapi.snapshot.json → ≥1).
- Step 2: Regenerate the TS types
Run: cd crates/web-ux-v2 && npm run generate:api:from-file
Expected: src/api/generated/schemas.ts updates; git diff --stat shows both files changed.
- Step 3: Commit
git add crates/web-ux-v2/openapi.snapshot.json crates/web-ux-v2/src/api/generated/schemas.tsgit commit -m "chore(web-ux-v2): regenerate OpenAPI snapshot for network host entries"Task 8: Frontend — list parser + DNS input
Section titled “Task 8: Frontend — list parser + DNS input”Files:
- Create:
crates/web-ux-v2/src/views/settings/network/parseList.ts - Create:
crates/web-ux-v2/src/views/settings/network/__tests__/parseList.spec.ts - Modify:
crates/web-ux-v2/src/views/settings/NetworkPanel.vue(mgmt-interface grid, ~line 295)
Interfaces:
-
Produces:
parseList(input: string): string[],formatList(items: string[]): string. -
Step 1: Write the failing test — create
__tests__/parseList.spec.ts:
import { describe, it, expect } from 'vitest';import { parseList, formatList } from '../parseList';
describe('parseList', () => { it('splits on commas and whitespace, trims, drops empties', () => { expect(parseList('8.8.8.8, 1.1.1.1 9.9.9.9')).toEqual(['8.8.8.8', '1.1.1.1', '9.9.9.9']); expect(parseList(' ')).toEqual([]); }); it('formatList joins with comma-space', () => { expect(formatList(['a', 'b'])).toBe('a, b'); });});- Step 2: Run test to verify it fails
Run: cd crates/web-ux-v2 && npx vitest run src/views/settings/network/__tests__/parseList.spec.ts
Expected: FAIL — module not found.
- Step 3: Write minimal implementation — create
parseList.ts:
/** Parse a comma/space-separated list into trimmed, non-empty tokens. */export function parseList(input: string): string[] { return input.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);}
/** Render a token list back into the canonical comma-space form. */export function formatList(items: string[]): string { return items.join(', ');}- Step 4: Run test to verify it passes
Run: cd crates/web-ux-v2 && npx vitest run src/views/settings/network/__tests__/parseList.spec.ts
Expected: PASS.
- Step 5: Add the DNS input to NetworkPanel.vue — import the helpers in
<script setup>(with the other imports):
import { parseList, formatList } from '@/views/settings/network/parseList';Add a full-width DNS field inside the management-interface grid, after the MTU <label> (~line 295, still inside the grid grid-cols-2 div):
<label class="text-xs text-text-3 col-span-2"> DNS servers (comma- or space-separated) <input :value="formatList(draftOf(m.vswitch).dns)" @input="(ev) => { draftOf(m.vswitch).dns = parseList((ev.target as HTMLInputElement).value); }" class="mt-1 w-full rounded-input border border-border bg-surface px-2 py-1 text-sm" placeholder="8.8.8.8, 1.1.1.1" > </label>(rowDirty(m) already compares d.dns.join(',') !== m.dns.join(',') at line 102, and applyRow already sends dns — no other change needed for DNS to apply.)
- Step 6: Typecheck + commit
Run: cd crates/web-ux-v2 && npm run build
Expected: build succeeds (no TS errors).
git add crates/web-ux-v2/src/views/settings/network/parseList.ts \ crates/web-ux-v2/src/views/settings/network/__tests__/parseList.spec.ts \ crates/web-ux-v2/src/views/settings/NetworkPanel.vuegit commit -m "feat(web-ux-v2): editable DNS field in Settings -> IP/DNS and Hostname"Task 9: Frontend — hosts API client, composable, card, error map
Section titled “Task 9: Frontend — hosts API client, composable, card, error map”Files:
- Modify:
crates/web-ux-v2/src/api/settings/network.ts - Modify:
crates/web-ux-v2/src/composables/useHostNetwork.ts - Modify:
crates/web-ux-v2/src/views/settings/NetworkPanel.vue - Modify:
crates/web-ux-v2/src/api/errorMap.ts(theERROR_MAPtable — confirm filename viagrep -rl "ERROR_MAP" src/api) - Test:
crates/web-ux-v2/src/api/settings/__tests__/network.spec.ts
Interfaces:
-
Consumes:
fetchJson,SettingsEnvelope,putHosts. -
Produces:
HostEntry(ts),NetworkSettingsView.host_entries,putHosts(body),useHostNetwork().applyHosts(entries). -
Step 1: Write the failing test — create
src/api/settings/__tests__/network.spec.ts:
import { describe, it, expect, vi, beforeEach } from 'vitest';import * as base from '@/api';import { putHosts } from '@/api/settings/network';
describe('putHosts', () => { beforeEach(() => vi.restoreAllMocks()); it('PUTs entries to /api/v1/settings/network/hosts', async () => { const spy = vi.spyOn(base, 'fetchJson').mockResolvedValue({ data: {}, warnings: [] } as never); await putHosts({ entries: [{ ip: '172.16.11.50', hostnames: ['vcenter.synnet.mx'] }] }); expect(spy).toHaveBeenCalledWith('/api/v1/settings/network/hosts', { method: 'PUT', body: { entries: [{ ip: '172.16.11.50', hostnames: ['vcenter.synnet.mx'] }] }, }); });});- Step 2: Run test to verify it fails
Run: cd crates/web-ux-v2 && npx vitest run src/api/settings/__tests__/network.spec.ts
Expected: FAIL — putHosts not exported.
- Step 3: Extend the API client — in
src/api/settings/network.ts:
export interface HostEntry { ip: string; hostnames: string[]; comment?: string;}Add host_entries: HostEntry[]; to NetworkSettingsView. Add the body type + call:
export interface PutHostsBody { entries: HostEntry[];}
export async function putHosts( body: PutHostsBody,): Promise<SettingsEnvelope<NetworkSettingsView>> { return fetchJson<SettingsEnvelope<NetworkSettingsView>>(`${BASE}/hosts`, { method: 'PUT', body });}- Step 4: Run test to verify it passes
Run: cd crates/web-ux-v2 && npx vitest run src/api/settings/__tests__/network.spec.ts
Expected: PASS.
- Step 5: Extend the composable — in
useHostNetwork.ts, importputHosts, type HostEntryfrom@/api/settings/network; add toUseHostNetwork:
/** PUT /api/v1/settings/network/hosts then refresh state from the response. */ applyHosts: (entries: HostEntry[]) => Promise<void>;Add the implementation (mirror applyHostname) and include applyHosts in the returned object:
async function applyHosts(entries: HostEntry[]): Promise<void> { loading.value = true; try { const env = await putHosts({ entries }); state.value = env.data; warnings.value = env.warnings; error.value = null; } catch (cause) { const wrapped = cause instanceof ApiError ? cause : ApiError.network(cause); error.value = wrapped; throw wrapped; } finally { loading.value = false; } }- Step 6: Add the “Host file entries” card to NetworkPanel.vue — in
<script setup>, add an editable draft + handlers:
import type { HostEntry } from '@/api/settings/network';
const hostRows = ref<{ ip: string; names: string; comment: string }[]>([]);watch( () => net.state.value?.host_entries, (entries) => { hostRows.value = (entries ?? []).map((e) => ({ ip: e.ip, names: formatList(e.hostnames), comment: e.comment ?? '', })); }, { immediate: true },);function addHostRow() { hostRows.value.push({ ip: '', names: '', comment: '' }); }function removeHostRow(i: number) { hostRows.value.splice(i, 1); }async function applyHosts() { const entries: HostEntry[] = hostRows.value .filter((r) => r.ip.trim() && r.names.trim()) .map((r) => ({ ip: r.ip.trim(), hostnames: parseList(r.names), ...(r.comment.trim() ? { comment: r.comment.trim() } : {}), })); submitError.value = null; try { await net.applyHosts(entries); } catch (e) { submitError.value = (e as Error).message; }}Add a card at the end of the template (after the management-interfaces Card, before the closing </div> at line 310):
<Card> <div class="flex flex-col gap-3"> <h3 class="text-sm font-medium text-text">Host file entries</h3> <p class="text-xs text-text-3"> Static name → address mappings written to this host's <code>/etc/hosts</code>. Useful when a name has no DNS record (e.g. an external system on a routed segment). </p> <div v-for="(r, i) in hostRows" :key="i" class="grid grid-cols-12 gap-2"> <input v-model="r.ip" placeholder="172.16.11.50" class="col-span-3 rounded-input border border-border bg-surface px-2 py-1 text-sm"> <input v-model="r.names" placeholder="vcenter.synnet.mx vcenter" class="col-span-5 rounded-input border border-border bg-surface px-2 py-1 text-sm"> <input v-model="r.comment" placeholder="comment (optional)" class="col-span-3 rounded-input border border-border bg-surface px-2 py-1 text-sm"> <button class="col-span-1 text-text-3 hover:text-danger" @click="removeHostRow(i)">✕</button> </div> <div class="flex items-center gap-2"> <Button variant="ghost" size="sm" @click="addHostRow">Add entry</Button> <Button variant="primary" size="sm" :disabled="net.loading.value" @click="applyHosts">Apply</Button> </div> <Banner v-if="submitError" severity="error" title="Could not apply host entries">{{ submitError }}</Banner> </div> </Card>- Step 7: Map the new error codes — in
errorMap.ts, add entries so the backendE_*codes render curated copy:
E_HOST_ENTRY_INVALID: { user_message: 'A host file entry is invalid.', suggested_action: 'Check that each IP is valid and each hostname is a valid name.', severity: 'error' }, E_HOST_ENTRY_BAD_IP: { user_message: 'A host entry has an invalid IP address.', suggested_action: 'Enter a valid IPv4 or IPv6 address.', severity: 'error' }, E_HOST_ENTRY_BAD_NAME: { user_message: 'A host entry has an invalid hostname.', suggested_action: 'Use letters, digits, and hyphens only.', severity: 'error' }, E_HOST_ENTRY_RESERVED_NAME: { user_message: 'That hostname is reserved.', suggested_action: 'Do not remap localhost or this host’s own name.', severity: 'error' },(Match the exact shape of neighboring ERROR_MAP entries — confirm key names like user_message/suggested_action/severity against an existing entry in the same file before editing.)
- Step 8: Typecheck, unit tests, commit
Run: cd crates/web-ux-v2 && npm run build && npx vitest run src/api/settings/__tests__/network.spec.ts src/views/settings/network/__tests__/parseList.spec.ts
Expected: build OK, both specs PASS.
git add crates/web-ux-v2/src/api/settings/network.ts \ crates/web-ux-v2/src/composables/useHostNetwork.ts \ crates/web-ux-v2/src/views/settings/NetworkPanel.vue \ crates/web-ux-v2/src/api/errorMap.ts \ crates/web-ux-v2/src/api/settings/__tests__/network.spec.tsgit commit -m "feat(web-ux-v2): /etc/hosts entries editor in Settings -> IP/DNS and Hostname"Final verification (after all tasks)
Section titled “Final verification (after all tasks)”-
cargo test -p synvirt-network && cargo test -p synvirt-daemon settings::api::network -
cargo clippy -p synvirt-network -p synvirt-daemon -- -D warnings && cargo fmt --check -
cd crates/web-ux-v2 && npm run build && npm run lint -- --max-warnings 0 - Manual smoke (lab host, NOT fleet): set a DNS server + add
vcenter.synnet.mx → <IP>in Settings → IP/DNS and Hostname; confirm/etc/hostsmanaged block +getent hosts vcenter.synnet.mxresolve; reboot and confirm the block survives.
Deploy to the fleet (.11/.12/.13) is gated on an explicit go — not part of this plan.
Self-review notes
Section titled “Self-review notes”- Spec coverage: model (T1), materializer + boot + on-demand (T2-T5), API GET/PUT (T6), contract (T7), DNS field (T8), hosts UI + validation surfacing (T9) — every §3/§4 spec item maps to a task.
- Type consistency:
HostEntry/host_entriesnames match across Rust (spec), daemon (HostEntryView), and TS (HostEntry);validate_entries(entries, own_hostname),apply_hosts(yaml),set_host_entries(entries),applyHosts(entries),putHosts({entries}),parseList/formatListare used identically where referenced. - Search domains / fleet fan-out: intentionally absent (out of scope per spec).