Skip to content

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.

  • Rust: one Error enum per crate via thiserror with E_* discriminant codes; no unwrap()/expect() in library code; #[tracing::instrument(skip_all)] on public async fns; cargo fmt + cargo clippy -- -D warnings clean.
  • Every #[utoipa::path] handler sets an explicit operation_id.
  • network.yaml is the single source of truth; synvirt-network is the sole applicator. New fields use #[serde(default)] so existing files keep loading.
  • The /etc/hosts write touches ONLY the managed block — system lines preserved byte-for-byte. Atomic write via crate::uplink_l2::write_atomic.
  • TS: strict, no any. After any endpoint change, re-dump OpenAPI and regenerate schemas.ts; keep openapi.snapshot.json in lockstep.
  • Scope: per-host (no fleet fan-out); no search domains; English UI copy; brand #0566C1.
  • Frozen-contract rule: openapi.snapshot.json is 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 near VmKernel
  • 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 tests in spec/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 to NetworkYaml:
/// 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
Terminal window
git add crates/synvirt-network/src/spec/mod.rs
git 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 (add pub 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.rs with 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
Terminal window
git add crates/synvirt-network/src/hosts.rs crates/synvirt-network/src/lib.rs
git commit -m "feat(synvirt-network): hosts.rs validation + HostsError"

Task 3: hosts.rsrender_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 tests module:

#[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
Terminal window
git add crates/synvirt-network/src/hosts.rs
git commit -m "feat(synvirt-network): hosts.rs managed-block render + idempotent splice"

Task 4: hosts.rsapply_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 to dns::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 }. Honors SYNVIRT_HOSTS_PATH (test override; default /etc/hosts).

  • Step 1: Write the failing test — add to the tests module:

#[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; add use std::path::{Path, PathBuf}; to the imports and use 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
Terminal window
git add crates/synvirt-network/src/hosts.rs crates/synvirt-network/src/main.rs
git 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 existing test_controller_with_default_vswitch helper):

#[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) in controller.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
Terminal window
git add crates/synvirt-network/src/controller.rs crates/synvirt-network/src/controller/tests.rs
git 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)] to network.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
Terminal window
git add crates/daemon/src/settings/api/network.rs crates/daemon/src/settings/api/mod.rs
git commit -m "feat(daemon): settings network host-entries view + PUT /settings/network/hosts"

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:

Terminal window
cargo build -p synvirt-daemon --release
./target/release/synvirt-daemon --dump-openapi > crates/web-ux-v2/openapi.snapshot.json

Expected: 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
Terminal window
git add crates/web-ux-v2/openapi.snapshot.json crates/web-ux-v2/src/api/generated/schemas.ts
git 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).

Terminal window
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.vue
git 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 (the ERROR_MAP table — confirm filename via grep -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, import putHosts, type HostEntry from @/api/settings/network; add to UseHostNetwork:
/** 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 backend E_* 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.

Terminal window
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.ts
git commit -m "feat(web-ux-v2): /etc/hosts entries editor in Settings -> IP/DNS and Hostname"

  • 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/hosts managed block + getent hosts vcenter.synnet.mx resolve; reboot and confirm the block survives.

Deploy to the fleet (.11/.12/.13) is gated on an explicit go — not part of this plan.

  • 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_entries names 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/formatList are used identically where referenced.
  • Search domains / fleet fan-out: intentionally absent (out of scope per spec).