Skip to content

Settings → Network (hostname + management interfaces) Implementation Plan

Synced read-only from /home/synnet/mirrors/synvirt-product/docs/superpowers/plans/2026-06-26-settings-network-hostname-ipmgmt.md. Edit at the source, not here.

Settings → Network (hostname + management interfaces) Implementation Plan

Section titled “Settings → Network (hostname + management interfaces) 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: Fill the existing network section of the Settings page with a panel that edits the host hostname and the management interfaces (ipMGMT) — one per vSwitch across N vSwitches — reusing the already-wired commit-confirm/rollback apply path, and announcing a management-IP change to the cluster (RAFT identity is the stable node id, not the IP).

Architecture: Hostname + ipMGMT edits reuse existing wiring (set_hostname; the /api/v1/vswitches/{name}/service-ports/mgmt endpoints). The only cluster-side work is announcing the new address: the mesh listener is changed to bind 0.0.0.0 (IP-agnostic) while still advertising the detected IP, and after a management-IP change the daemon submits an AddMember upsert (keyed on this node’s stable id) so peers refresh the address they dial. A new daemon Settings section (GET /settings/network + PUT /settings/network/hostname) composes the read + hostname write. The Vue panel mirrors TimePanel.

Tech Stack: Rust (axum, synvirt-network NetworkController, synvirt-cluster Raft, synvirt-clusterd UDS, utoipa), Vue 3.5 + TypeScript (Vite, fetchJson), vitest.

  • Backend: one Error enum per crate (thiserror) with E_* codes; #[tracing::instrument(skip_all)] on public async fns; no unwrap()/expect() in library code; subprocess via tokio::process::Command with timeout, stdout/stderr captured separately, never shell strings; every #[utoipa::path] handler sets an explicit unique operation_id.
  • network.yaml is the single source of truth; the in-process NetworkController is the only applicator. The hostname mutation lives in NetworkController — do not write network.yaml elsewhere.
  • The vmkernel kind for management is the literal string "mgmt" on both route and controller.
  • RAFT identity = node_id_from_fingerprint(...) (stable, IP-independent). A management-IP change is announced via ClusterCommand::AddMember { node_id, address }, which is an upsert (ON CONFLICT(node_id) DO UPDATE SET address). node_id on AddMember is a String (use runtime.node_id().to_string()).
  • Cluster control is out-of-process (clusterd, UDS). Any new cluster operation is wired in BOTH ClusterControl impls (EmbeddedClusterControl, ClusterControlUdsClient) + the clusterd proto + handler — mirror config_put exactly.
  • Frontend: TypeScript strict, no any; npm run lint -- --max-warnings 0; import fetchJson from @/api; copy uses ipMGMT, vSwitch, vNIC (idents stay lowercase); never remove translate="no"/notranslate; statics are Cache-Control: no-cache (one Ctrl+Shift+R after deploy).
  • Versioning: every crate uses version.workspace = true; never hardcode versions. No -FixN.
  • Commits: Conventional Commits with crate scope. Atomic commit per task. End each commit message with: Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
  • Deploy/verify target: .13 (synvirt-03, snapshotted VM). Never re-IP .11 live. Deploy via scripts/deploy-to-host.sh with USER=root.

Backend (Rust):

  • Modify crates/synvirt-network/src/spec/mod.rs — make is_rfc1123_hostname public.
  • Modify crates/synvirt-network/src/controller.rs — add set_hostname + hostname.
  • Modify crates/synvirt-cluster/src/federation_runtime.rs — mesh listener binds 0.0.0.0.
  • Modify crates/daemon/src/api/cluster_control.rsClusterControl::announce_address + EmbeddedClusterControl impl.
  • Modify crates/daemon/src/api/cluster_control_client.rsClusterControlUdsClient::announce_address.
  • Modify crates/synvirt-clusterd/src/control_proto.rsPATH_ANNOUNCE_ADDRESS + AnnounceAddressRequest.
  • Modify crates/synvirt-clusterd/src/http.rs — announce route + handler.
  • Modify crates/daemon/src/main.rsdetect_mgmt_ipv4pub(crate).
  • Modify crates/daemon/src/api/network.rs — announce after a clustered mgmt change.
  • Modify crates/daemon/src/api/cluster.rsnode_identity reads live hostname.
  • Create crates/daemon/src/settings/api/network.rsGET /settings/network + PUT /settings/network/hostname.
  • Modify crates/daemon/src/settings/api/mod.rs — register routes + pub mod network;.

Frontend (Vue/TS):

  • Create crates/web-ux-v2/src/api/settings/network.ts.
  • Create crates/web-ux-v2/src/composables/useHostNetwork.ts.
  • Create crates/web-ux-v2/src/views/settings/network/selfInterface.ts.
  • Create crates/web-ux-v2/src/views/settings/NetworkPanel.vue.
  • Modify crates/web-ux-v2/src/views/SettingsView.vue.
  • Tests: src/api/settings/__tests__/network.spec.ts, src/views/settings/network/__tests__/selfInterface.spec.ts.

Contract:

  • Modify crates/web-ux-v2/openapi.snapshot.json + regenerated src/api/generated/schemas.ts.

Task 1: synvirt-network — hostname read/set + public RFC1123 check

Section titled “Task 1: synvirt-network — hostname read/set + public RFC1123 check”

Files:

  • Modify: crates/synvirt-network/src/spec/mod.rs:570 (make is_rfc1123_hostname pub)
  • Modify: crates/synvirt-network/src/controller.rs
  • Test: crates/synvirt-network/src/controller/tests.rs

Interfaces:

  • Produces: synvirt_network::spec::is_rfc1123_hostname(&str) -> bool (pub); NetworkController::hostname(&self) -> String; NetworkController::set_hostname(&self, &str) -> Result<(), NetworkError> (validate → write network.yamlhostnamectl set-hostname; no OVS reconcile).

  • Step 1: Write the failing test

Add to crates/synvirt-network/src/controller/tests.rs (mirror an existing configure_vmkernel test for the temp-yaml controller setup; return (ctrl, yaml_path)):

#[tokio::test]
async fn set_hostname_rejects_invalid_and_persists_valid() {
std::env::set_var("SYNVIRT_SKIP_HOSTNAMECTL", "1");
let (ctrl, yaml_path) = test_controller_with_default_vswitch().await;
let before = ctrl.hostname().await;
let err = ctrl.set_hostname("bad host!").await.unwrap_err();
assert!(matches!(err, NetworkError::Validation(_)));
assert_eq!(ctrl.hostname().await, before);
ctrl.set_hostname("synvirt-03").await.unwrap();
assert_eq!(ctrl.hostname().await, "synvirt-03");
let on_disk = tokio::fs::read_to_string(&yaml_path).await.unwrap();
assert!(on_disk.contains("hostname: synvirt-03"));
}
  • Step 2: Run test to verify it fails

Run: cargo test -p synvirt-network set_hostname_rejects_invalid_and_persists_valid Expected: FAIL — symbols not defined / not public.

  • Step 3: Implement

crates/synvirt-network/src/spec/mod.rs line ~570 — change fn is_rfc1123_hostname to:

/// RFC 1123 hostname check: 1–253 chars, dot-separated labels of 1–63
/// chars, each alphanumeric-bounded with internal hyphens.
pub fn is_rfc1123_hostname(s: &str) -> bool {

crates/synvirt-network/src/controller.rs — add near configure_vmkernel:

/// The host's configured hostname (the `network.yaml` `hostname` field).
pub async fn hostname(&self) -> String {
self.yaml.read().await.hostname.clone()
}
/// Set the host hostname: validate RFC 1123, persist to `network.yaml`,
/// and apply via `hostnamectl set-hostname`. No OVS reconcile.
#[instrument(skip(self), fields(hostname = %hostname))]
pub async fn set_hostname(&self, hostname: &str) -> Result<(), NetworkError> {
if !crate::spec::is_rfc1123_hostname(hostname) {
return Err(NetworkError::Validation(format!(
"invalid hostname {hostname:?} (must be a valid RFC 1123 hostname)"
)));
}
{
let mut guard = self.yaml.write().await;
let pre = guard.clone();
guard.hostname = hostname.to_string();
if let Err(errs) = guard.validate() {
*guard = pre;
let msg = errs.iter().map(|e| format!("- {e}")).collect::<Vec<_>>().join("\n");
return Err(NetworkError::Validation(msg));
}
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}")));
}
}
apply_os_hostname(hostname).await
}

Add the free fn:

/// Apply the hostname at the OS level. Skipped when
/// `SYNVIRT_SKIP_HOSTNAMECTL=1` (unit tests).
async fn apply_os_hostname(hostname: &str) -> Result<(), NetworkError> {
if std::env::var("SYNVIRT_SKIP_HOSTNAMECTL").as_deref() == Ok("1") {
return Ok(());
}
let out = tokio::time::timeout(
std::time::Duration::from_secs(10),
tokio::process::Command::new("hostnamectl").arg("set-hostname").arg(hostname).output(),
)
.await
.map_err(|_| NetworkError::Internal("hostnamectl timed out".into()))?
.map_err(|e| NetworkError::Internal(format!("failed to spawn hostnamectl: {e}")))?;
if !out.status.success() {
return Err(NetworkError::Internal(format!(
"hostnamectl set-hostname failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(())
}

If NetworkError has no Internal(String) variant, use the crate’s catch-all variant (grep enum NetworkError in crates/synvirt-network/src/error.rs).

  • Step 4: Run test to verify it passes

Run: cargo test -p synvirt-network set_hostname_rejects_invalid_and_persists_valid Expected: PASS

  • Step 5: Commit
Terminal window
git add crates/synvirt-network/src/spec/mod.rs crates/synvirt-network/src/controller.rs crates/synvirt-network/src/controller/tests.rs
git commit -m "feat(synvirt-network): hostname read/set + public RFC1123 check"

Task 2: synvirt-cluster — mesh listener binds wildcard

Section titled “Task 2: synvirt-cluster — mesh listener binds wildcard”

Files:

  • Modify: crates/synvirt-cluster/src/federation_runtime.rs:156

Interfaces:

  • Behavior change only: the mesh TcpListener binds 0.0.0.0:mesh_port (IP-agnostic) while the advertised mesh_addr still uses bind_address (the detected reachable IP). No signature changes.

  • Step 1: Implement

In crates/synvirt-cluster/src/federation_runtime.rs, change the bind line (currently TcpListener::bind((self.bind_address.as_str(), self.config.mesh_port))) to bind the wildcard, leaving the mesh_addr advertise line untouched:

// Bind the wildcard so the listener survives a runtime management-IP
// change (the socket is not pinned to one address); we still ADVERTISE
// the reachable `bind_address` in `mesh_addr` below, and an IP change
// re-announces via ClusterCommand::AddMember.
let listener = TcpListener::bind(("0.0.0.0", self.config.mesh_port))
.await
.map_err(|e| ClusterError::MeshTlsConfig {
message: format!("bind federation mesh listener: {e}"),
})?;

Leave unchanged:

let mesh_addr = format!("{}:{}", self.bind_address, port);
  • Step 2: Run the cluster test suite (regression — no listener-binding regressions)

Run: cargo test -p synvirt-cluster Expected: PASS (existing federation_runtime tests start runtimes with bind_address = "127.0.0.1"; advertising 127.0.0.1:port while binding 0.0.0.0:port keeps them reachable).

  • Step 3: Commit
Terminal window
git add crates/synvirt-cluster/src/federation_runtime.rs
git commit -m "fix(synvirt-cluster): mesh listener binds wildcard, advertises reachable IP"

Task 3: cluster — announce_address (AddMember upsert) end-to-end

Section titled “Task 3: cluster — announce_address (AddMember upsert) end-to-end”

Files:

  • Modify: crates/synvirt-cluster/src/statemachine/mod.rs tests (prove upsert)
  • Modify: crates/daemon/src/api/cluster_control.rs (trait + EmbeddedClusterControl)
  • Modify: crates/daemon/src/api/cluster_control_client.rs (UDS client)
  • Modify: crates/synvirt-clusterd/src/control_proto.rs (path + request)
  • Modify: crates/synvirt-clusterd/src/http.rs (route + handler)

Interfaces:

  • Produces: ClusterControl::announce_address(&self, address: &str) -> Result<(), String> — when mode() == "cluster", upserts this node’s cluster_members address; no-op otherwise. Wired through clusterd over PATH_ANNOUNCE_ADDRESS.

  • Step 1: Write the failing test (upsert guarantee the feature relies on)

In crates/synvirt-cluster/src/statemachine/mod.rs tests (mirror the existing AddMember test around line 576), add:

#[test]
fn add_member_upserts_address_on_conflict() {
let s = StateMachine::open_in_memory().unwrap(); // use the same constructor the nearby tests use
s.apply(&ClusterCommand::InitializeCluster { cluster_id: "c1".into() }).unwrap();
s.apply(&ClusterCommand::AddMember { node_id: "7".into(), address: "10.0.0.1:7443".into() }).unwrap();
// Re-add same node_id with a new address → address updated, single row.
s.apply(&ClusterCommand::AddMember { node_id: "7".into(), address: "10.0.0.99:7443".into() }).unwrap();
let members = s.cluster_members().unwrap(); // use the existing read used by the nearby test
let row = members.iter().find(|m| m.node_id == "7").unwrap();
assert_eq!(row.address, "10.0.0.99:7443");
assert_eq!(members.iter().filter(|m| m.node_id == "7").count(), 1);
}

Match the exact constructor (open_in_memory/new), the apply signature, and the members-read API used by the existing AddMember test in that file. If the read returns rows differently, adapt the assertions to that shape — keep the two asserts (address updated, one row).

  • Step 2: Run test to verify it fails or passes

Run: cargo test -p synvirt-cluster add_member_upserts_address_on_conflict Expected: PASS if the upsert SQL already behaves so (it does — ON CONFLICT(node_id) DO UPDATE); this test locks that guarantee for the feature. If it fails, the SQL/read assumption is wrong — stop and report.

  • Step 3: Implement the proto + clusterd handler

crates/synvirt-clusterd/src/control_proto.rs — add beside PATH_CONFIG_PUT / ConfigPutRequest:

pub const PATH_ANNOUNCE_ADDRESS: &str = "/v1/cluster/member/announce-address";
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AnnounceAddressRequest {
pub address: String,
}

crates/synvirt-clusterd/src/http.rs — add PATH_ANNOUNCE_ADDRESS + AnnounceAddressRequest to the use import block, add the route next to PATH_CONFIG_PUT’s, and add the handler mirroring config_put:

.route(PATH_ANNOUNCE_ADDRESS, post(announce_address))
async fn announce_address(
State(host): State<Arc<RuntimeHost>>,
Json(req): Json<AnnounceAddressRequest>,
) -> Response {
match host
.runtime
.submit(ClusterCommand::AddMember {
node_id: host.runtime.node_id().to_string(),
address: req.address,
})
.await
{
Ok(_) => Json(OkResponse::default()).into_response(),
Err(e) => cluster_err(e),
}
}
  • Step 4: Implement the trait + both impls

crates/daemon/src/api/cluster_control.rs — add to the ClusterControl trait (next to config_put):

/// Announce this node's (possibly changed) mesh dial address to the
/// cluster — an `AddMember` upsert keyed on this node's stable id. No-op
/// unless `mode() == "cluster"`. Identity (node id) is unchanged; only the
/// address peers dial is updated.
async fn announce_address(&self, address: &str) -> Result<(), String>;

EmbeddedClusterControl impl (next to config_put):

async fn announce_address(&self, address: &str) -> Result<(), String> {
if self.runtime.mode().unwrap_or("standalone") != "cluster" {
return Ok(());
}
self.runtime
.submit(synvirt_cluster::statemachine::ClusterCommand::AddMember {
node_id: self.runtime.node_id().to_string(),
address: address.to_string(),
})
.await
.map(|_| ())
.map_err(|e| format!("{}: {e}", e.code()))
}

crates/daemon/src/api/cluster_control_client.rsClusterControlUdsClient impl (mirror config_put; add PATH_ANNOUNCE_ADDRESS, AnnounceAddressRequest to its use of control_proto):

async fn announce_address(&self, address: &str) -> Result<(), String> {
self.fresh(self.client.post_json::<_, OkResponse>(
PATH_ANNOUNCE_ADDRESS,
&AnnounceAddressRequest { address: address.to_string() },
))
.await
.map(|_| ())
}
  • Step 5: Build the affected crates

Run: cargo build -p synvirt-clusterd -p synvirt-daemon Expected: builds clean (the trait now has all impls).

  • Step 6: Commit
Terminal window
git add crates/synvirt-cluster/src/statemachine/mod.rs crates/daemon/src/api/cluster_control.rs crates/daemon/src/api/cluster_control_client.rs crates/synvirt-clusterd/src/control_proto.rs crates/synvirt-clusterd/src/http.rs
git commit -m "feat(cluster): announce_address upserts member address (re-IP propagation)"

Task 4: daemon — announce after a clustered management-IP change

Section titled “Task 4: daemon — announce after a clustered management-IP change”

Files:

  • Modify: crates/daemon/src/main.rs:228 (detect_mgmt_ipv4pub(crate))
  • Modify: crates/daemon/src/api/network.rs (configure_vmkernel)

Interfaces:

  • Consumes: crate::detect_mgmt_ipv4() -> Option<Ipv4Addr>; state.cluster_control.get().mode(), .announce_address(addr); state.cluster_config.mesh_port: u16.

  • Step 1: Implement

crates/daemon/src/main.rs — change fn detect_mgmt_ipv4() to pub(crate) fn detect_mgmt_ipv4() (leave udp_source_ipv4 / first_global_ipv4 private).

crates/daemon/src/api/network.rs — in configure_vmkernel, after the successful let outcome = ctrl.configure_vmkernel(...).await...?; and before building the response envelope, add:

// RAFT identity is the stable node id, not the IP — so a management-IP
// change only needs the cluster to learn the new dial address. Announce it
// (AddMember upsert). Non-fatal: the IP change already succeeded.
if kind.eq_ignore_ascii_case("mgmt") {
if let Some(control) = state.cluster_control.get() {
if control.mode() == "cluster" {
if let Some(ip) = crate::detect_mgmt_ipv4() {
let addr = format!("{}:{}", ip, state.cluster_config.mesh_port);
if let Err(e) = control.announce_address(&addr).await {
tracing::warn!(
target: "synvirt.network",
error = %e,
address = %addr,
"failed to announce new management address to cluster"
);
}
}
}
}
}

Confirm state.cluster_config.mesh_port is the field name (ClusterConfig has pub mesh_port: u16). Confirm control.mode() returns &str and "cluster" is the clustered value (verified in cluster_control.rs).

  • Step 2: Build

Run: cargo build -p synvirt-daemon Expected: builds clean.

  • Step 3: Commit
Terminal window
git add crates/daemon/src/main.rs crates/daemon/src/api/network.rs
git commit -m "feat(synvirt-daemon): announce new mgmt address to cluster on re-IP"

Task 5: daemon — node_identity reports the live hostname

Section titled “Task 5: daemon — node_identity reports the live hostname”

Files:

  • Modify: crates/daemon/src/api/cluster.rs:537

Interfaces:

  • GET /api/v1/node/identity returns the live OS hostname (so a hostname change shows without a restart).

  • Step 1: Implement

In crates/daemon/src/api/cluster.rs, in the node_identity handler, replace hostname: id.hostname.clone(), in the returned NodeIdentityView with:

// Live hostname: a runtime `hostnamectl set-hostname` updates the kernel
// hostname immediately, so read it fresh rather than the boot snapshot —
// the dashboard reflects the change with no daemon restart.
hostname: hostname::get()
.ok()
.map(|h| h.to_string_lossy().into_owned())
.filter(|h| !h.is_empty())
.unwrap_or_else(|| id.hostname.clone()),

The hostname crate is already used in settings/envelope.rs. If absent from crates/daemon/Cargo.toml, add it matching the version in Cargo.lock.

  • Step 2: Build

Run: cargo build -p synvirt-daemon Expected: builds clean.

  • Step 3: Commit
Terminal window
git add crates/daemon/src/api/cluster.rs
git commit -m "feat(synvirt-daemon): node identity reports live hostname (no restart)"

Task 6: daemon — Network settings section (GET + PUT hostname)

Section titled “Task 6: daemon — Network settings section (GET + PUT hostname)”

Files:

  • Create: crates/daemon/src/settings/api/network.rs
  • Modify: crates/daemon/src/settings/api/mod.rs

Interfaces:

  • Consumes: AppState (network_controller, cluster_control), SettingsEnvelope/SettingsWarning from crate::settings::envelope, AuthUser, ApiError, is_rfc1123_hostname (Task 1), VSwitchInfo.

  • Produces (wire): NetworkSettingsView { hostname, fqdn, is_cluster_member, mgmt_interfaces: Vec<MgmtInterfaceView>, addable_vswitches: Vec<String> }; MgmtInterfaceView { vswitch, is_default, vlan, mode, address, gateway, dns, mtu }; PutHostnameBody { hostname }; routes GET /api/v1/settings/network, PUT /api/v1/settings/network/hostname.

  • Step 1: Write the failing test

Create crates/daemon/src/settings/api/network.rs with the pure builder + test:

#[cfg(test)]
mod tests {
use super::*;
use synvirt_network::dto::{BondConfigDto, VSwitchInfo, VmKernelConfig};
fn vsw(name: &str, default: bool, has_mgmt: bool, addr: &str) -> VSwitchInfo {
VSwitchInfo {
name: name.into(), default, mtu: 1500, uplinks: vec![], bond: BondConfigDto::default(),
has_ipmgmt: has_mgmt,
ipmgmt: VmKernelConfig { vlan: 10, mode: "static".into(), address: addr.into(), gateway: "10.10.26.1".into(), dns: vec!["10.10.10.1".into()], mtu: 1500 },
has_ipstorage: false, ipstorage: VmKernelConfig { vlan: 0, mode: String::new(), address: String::new(), gateway: String::new(), dns: vec![], mtu: 0 },
has_ipmigration: false, ipmigration: VmKernelConfig { vlan: 0, mode: String::new(), address: String::new(), gateway: String::new(), dns: vec![], mtu: 0 },
networks: vec![],
}
}
#[test]
fn builds_list_and_addables() {
let vsws = vec![vsw("vSwitch0", true, true, "10.10.26.13/24"), vsw("vSwitch1", false, false, "")];
let view = build_network_view("synvirt-03".into(), true, &vsws);
assert_eq!(view.mgmt_interfaces.len(), 1);
let m = &view.mgmt_interfaces[0];
assert_eq!(m.vswitch, "vSwitch0");
assert!(m.is_default);
assert_eq!(m.vlan, 10);
assert_eq!(view.addable_vswitches, vec!["vSwitch1".to_string()]);
assert!(view.is_cluster_member);
}
}

If BondConfigDto is not Default, construct it explicitly (grep pub struct BondConfigDto).

  • Step 2: Run test to verify it fails

Run: cargo test -p synvirt-daemon settings::api::network Expected: FAIL — module/build_network_view not defined.

  • Step 3: Implement

Top of crates/daemon/src/settings/api/network.rs:

//! Handlers for `/api/v1/settings/network` — Settings → Network (hostname +
//! management interfaces). Read composes hostname + every vSwitch's ipMGMT.
//! Hostname mutation delegates to `NetworkController::set_hostname`.
//! Management-IP edits are NOT here — the panel reuses the existing
//! `/api/v1/vswitches/{name}/service-ports/mgmt` routes (commit-confirm +
//! rollback), which also announce the new address to the cluster.
use axum::{
extract::{Extension, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::{Deserialize, Serialize};
use synvirt_network::dto::VSwitchInfo;
use tracing::instrument;
use utoipa::ToSchema;
use crate::api::health::AppState;
use crate::api::ApiError;
use crate::auth::AuthUser;
use crate::settings::envelope::{SettingsEnvelope, SettingsWarning};
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct MgmtInterfaceView {
pub vswitch: String,
pub is_default: bool,
pub vlan: u16,
pub mode: String,
pub address: String,
pub gateway: String,
pub dns: Vec<String>,
pub mtu: u32,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct NetworkSettingsView {
pub hostname: String,
pub fqdn: String,
pub is_cluster_member: bool,
pub mgmt_interfaces: Vec<MgmtInterfaceView>,
pub addable_vswitches: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct PutHostnameBody {
pub hostname: String,
}
/// Pure view builder — unit tested.
pub(crate) fn build_network_view(
hostname: String,
is_cluster_member: bool,
vswitches: &[VSwitchInfo],
) -> NetworkSettingsView {
let mut mgmt_interfaces = Vec::new();
let mut addable_vswitches = Vec::new();
for vs in vswitches {
if vs.has_ipmgmt {
mgmt_interfaces.push(MgmtInterfaceView {
vswitch: vs.name.clone(),
is_default: vs.default,
vlan: vs.ipmgmt.vlan,
mode: vs.ipmgmt.mode.clone(),
address: vs.ipmgmt.address.clone(),
gateway: vs.ipmgmt.gateway.clone(),
dns: vs.ipmgmt.dns.clone(),
mtu: vs.ipmgmt.mtu,
});
} else {
addable_vswitches.push(vs.name.clone());
}
}
let fqdn = hostname.clone();
NetworkSettingsView { hostname, fqdn, is_cluster_member, mgmt_interfaces, addable_vswitches }
}
async fn read_view(state: &AppState) -> Result<NetworkSettingsView, ApiError> {
let ctrl = state.network_controller.as_ref().ok_or_else(|| {
ApiError::service_unavailable("E_NETWORK_UNAVAILABLE", "network controller unavailable")
})?;
let hostname = ctrl.hostname().await;
let vswitches = ctrl.list_vswitches().await;
let is_cluster_member = state
.cluster_control
.get()
.map(|c| c.mode() != "standalone")
.unwrap_or(false);
Ok(build_network_view(hostname, is_cluster_member, &vswitches))
}
/// `GET /api/v1/settings/network`
#[utoipa::path(
get,
path = "/api/v1/settings/network",
operation_id = "get_settings_network",
tag = "settings",
responses(
(status = 200, description = "Hostname + management interfaces", body = SettingsEnvelope<NetworkSettingsView>),
(status = 401, body = crate::api::error::ErrorEnvelope),
(status = 503, body = crate::api::error::ErrorEnvelope),
),
security(("basic_auth" = [])),
)]
#[instrument(skip_all, target = "synvirt.settings.network")]
pub async fn get(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
let view = read_view(&state).await?;
Ok((StatusCode::OK, Json(SettingsEnvelope::ok(view))))
}
/// `PUT /api/v1/settings/network/hostname`
#[utoipa::path(
put,
path = "/api/v1/settings/network/hostname",
operation_id = "put_settings_network_hostname",
tag = "settings",
request_body = PutHostnameBody,
responses(
(status = 200, description = "Hostname 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), target = "synvirt.settings.network")]
pub async fn put_hostname(
State(state): State<AppState>,
Extension(actor): Extension<AuthUser>,
Json(body): Json<PutHostnameBody>,
) -> Result<impl IntoResponse, ApiError> {
let ctrl = state.network_controller.as_ref().ok_or_else(|| {
ApiError::service_unavailable("E_NETWORK_UNAVAILABLE", "network controller unavailable")
})?;
if !synvirt_network::spec::is_rfc1123_hostname(&body.hostname) {
return Err(ApiError::bad(
"E_NETWORK_INVALID_HOSTNAME",
format!("invalid hostname {:?} (must be a valid RFC 1123 hostname)", body.hostname),
));
}
ctrl.set_hostname(&body.hostname)
.await
.map_err(|e| ApiError::internal("E_NETWORK_HOSTNAME_APPLY", e.to_string()))?;
tracing::info!(target: "synvirt.settings.network", actor = %actor.0, hostname = %body.hostname, "hostname changed");
let view = read_view(&state).await?;
let warnings = if view.is_cluster_member {
vec![SettingsWarning {
code: "W_NETWORK_HOSTNAME_CLUSTER_MEMBER".into(),
message: "This host is a cluster member. Cluster identity is keyed off node id / \
fingerprint, not hostname, so membership is unaffected; peers show the new \
name after their next sync.".into(),
details: None,
}]
} else {
Vec::new()
};
Ok((StatusCode::OK, Json(SettingsEnvelope::with_warnings(view, warnings))))
}

Match real ApiError constructors: bad, internal are used in settings/api/time.rs. For 503, grep ApiError:: in crates/daemon/src/api/error.rs; if service_unavailable doesn’t exist, use the constructor that yields 503 there (or reuse the require_controller error path from api/network.rs).

In crates/daemon/src/settings/api/mod.rs: add pub mod network; with the other pub mod lines, and inside router():

.route("/v1/settings/network", get(network::get))
.route("/v1/settings/network/hostname", put(network::put_hostname))
  • Step 4: Run test + build

Run: cargo test -p synvirt-daemon settings::api::network && cargo build -p synvirt-daemon Expected: test PASS, build clean.

  • 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(synvirt-daemon): Settings Network section (GET + PUT hostname)"

Task 7: Re-dump OpenAPI snapshot + regenerate TS types

Section titled “Task 7: Re-dump OpenAPI snapshot + regenerate TS types”

Files:

  • Modify: crates/web-ux-v2/openapi.snapshot.json, crates/web-ux-v2/src/api/generated/schemas.ts

  • Step 1: Dump the spec

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

Expected: grep get_settings_network crates/web-ux-v2/openapi.snapshot.json returns a hit.

  • Step 2: Regenerate TS types
Terminal window
cd crates/web-ux-v2 && npm run generate:api:from-file

Expected: regenerated, no errors.

  • 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): re-dump OpenAPI for settings/network endpoints"

Task 8: Frontend — src/api/settings/network.ts

Section titled “Task 8: Frontend — src/api/settings/network.ts”

Files:

  • Create: crates/web-ux-v2/src/api/settings/network.ts
  • Test: crates/web-ux-v2/src/api/settings/__tests__/network.spec.ts

Interfaces:

  • Consumes: fetchJson from @/api; SettingsEnvelope from @/api/settings/time.

  • Produces: NetworkSettingsView, MgmtInterfaceView, PutHostnameBody, getNetworkSettings(), putHostname(body).

  • Step 1: Write the failing test

crates/web-ux-v2/src/api/settings/__tests__/network.spec.ts
import { describe, it, expect, vi, afterEach } from 'vitest';
const fetchJson = vi.fn();
vi.mock('@/api', () => ({ fetchJson: (...a: unknown[]) => fetchJson(...a) }));
import { getNetworkSettings, putHostname } from '../network';
afterEach(() => fetchJson.mockReset());
describe('settings/network api', () => {
it('GET hits /api/v1/settings/network', async () => {
fetchJson.mockResolvedValue({ data: {}, warnings: [], meta: {} });
await getNetworkSettings();
expect(fetchJson).toHaveBeenCalledWith('/api/v1/settings/network');
});
it('PUT hostname posts the body', async () => {
fetchJson.mockResolvedValue({ data: {}, warnings: [], meta: {} });
await putHostname({ hostname: 'synvirt-03' });
expect(fetchJson).toHaveBeenCalledWith('/api/v1/settings/network/hostname', {
method: 'PUT', body: { hostname: 'synvirt-03' },
});
});
});
  • 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 — ../network not found.

  • Step 3: Implement
crates/web-ux-v2/src/api/settings/network.ts
/**
* Typed client for `/api/v1/settings/network` — Settings → Network.
* Mirrors `crates/daemon/src/settings/api/network.rs`; bump in lockstep.
* Management-IP edits reuse `@/api/network` service-port helpers.
*/
import { fetchJson } from '@/api';
import type { SettingsEnvelope } from '@/api/settings/time';
export interface MgmtInterfaceView {
vswitch: string;
is_default: boolean;
vlan: number;
mode: string;
address: string;
gateway: string;
dns: string[];
mtu: number;
}
export interface NetworkSettingsView {
hostname: string;
fqdn: string;
is_cluster_member: boolean;
mgmt_interfaces: MgmtInterfaceView[];
addable_vswitches: string[];
}
export interface PutHostnameBody {
hostname: string;
}
const BASE = '/api/v1/settings/network';
export async function getNetworkSettings(): Promise<SettingsEnvelope<NetworkSettingsView>> {
return fetchJson<SettingsEnvelope<NetworkSettingsView>>(BASE);
}
export async function putHostname(
body: PutHostnameBody,
): Promise<SettingsEnvelope<NetworkSettingsView>> {
return fetchJson<SettingsEnvelope<NetworkSettingsView>>(`${BASE}/hostname`, { method: 'PUT', body });
}

If @/api/settings/time does not export SettingsEnvelope, copy the SettingsEnvelope/SettingsWarning/SettingsMeta interfaces verbatim from time.ts into this file.

  • 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: Commit
Terminal window
git add crates/web-ux-v2/src/api/settings/network.ts crates/web-ux-v2/src/api/settings/__tests__/network.spec.ts
git commit -m "feat(web-ux-v2): settings/network API client"

Task 9: Frontend — useHostNetwork composable + self-interface helper

Section titled “Task 9: Frontend — useHostNetwork composable + self-interface helper”

Files:

  • Create: crates/web-ux-v2/src/composables/useHostNetwork.ts
  • Create: crates/web-ux-v2/src/views/settings/network/selfInterface.ts
  • Test: crates/web-ux-v2/src/views/settings/network/__tests__/selfInterface.spec.ts

Interfaces:

  • Consumes: getNetworkSettings, putHostname (Task 8); configureServicePort, clearServicePort, type ServicePortConfig, type MutationEnvelope from @/api/network; ApiError from @/api.

  • Produces: isSelfInterface(address, currentHost) -> boolean; newIpUrl(address) -> string; useHostNetwork(){ state, warnings, loading, error, refresh, applyHostname, applyMgmtIp, removeMgmtIp }.

  • Step 1: Write the failing test

crates/web-ux-v2/src/views/settings/network/__tests__/selfInterface.spec.ts
import { describe, it, expect } from 'vitest';
import { isSelfInterface, newIpUrl } from '../selfInterface';
describe('selfInterface', () => {
it('detects the connected interface by CIDR IP', () => {
expect(isSelfInterface('10.10.26.13/24', '10.10.26.13')).toBe(true);
expect(isSelfInterface('10.10.26.13/24', '10.10.26.99')).toBe(false);
expect(isSelfInterface('', '10.10.26.13')).toBe(false);
});
it('builds the new-IP settings URL', () => {
expect(newIpUrl('10.10.26.14/24')).toBe('https://10.10.26.14/settings');
});
});
  • Step 2: Run test to verify it fails

Run: cd crates/web-ux-v2 && npx vitest run src/views/settings/network/__tests__/selfInterface.spec.ts Expected: FAIL — ../selfInterface not found.

  • Step 3: Implement helper + composable
crates/web-ux-v2/src/views/settings/network/selfInterface.ts
function cidrIp(address: string): string {
return address.split('/')[0] ?? '';
}
export function isSelfInterface(address: string, currentHost: string): boolean {
const ip = cidrIp(address);
return ip.length > 0 && ip === currentHost;
}
export function newIpUrl(address: string): string {
return `https://${cidrIp(address)}/settings`;
}
crates/web-ux-v2/src/composables/useHostNetwork.ts
import { ref, onMounted, onScopeDispose, type Ref } from 'vue';
import { ApiError } from '@/api';
import { getNetworkSettings, putHostname, type NetworkSettingsView } from '@/api/settings/network';
import type { SettingsWarning } from '@/api/settings/time';
import {
configureServicePort, clearServicePort,
type ServicePortConfig, type MutationEnvelope,
} from '@/api/network';
export interface UseHostNetwork {
state: Ref<NetworkSettingsView | null>;
warnings: Ref<SettingsWarning[]>;
loading: Ref<boolean>;
error: Ref<ApiError | null>;
refresh: () => Promise<void>;
applyHostname: (hostname: string) => Promise<SettingsWarning[]>;
applyMgmtIp: (vswitch: string, config: ServicePortConfig) => Promise<MutationEnvelope<ServicePortConfig>>;
removeMgmtIp: (vswitch: string) => Promise<MutationEnvelope>;
}
export function useHostNetwork(): UseHostNetwork {
const state = ref<NetworkSettingsView | null>(null);
const warnings = ref<SettingsWarning[]>([]);
const loading = ref(false);
const error = ref<ApiError | null>(null);
async function refresh(): Promise<void> {
loading.value = true;
try {
const env = await getNetworkSettings();
state.value = env.data;
warnings.value = env.warnings;
error.value = null;
} catch (cause) {
error.value = cause instanceof ApiError ? cause : ApiError.network(cause);
} finally {
loading.value = false;
}
}
async function applyHostname(hostname: string): Promise<SettingsWarning[]> {
loading.value = true;
try {
const env = await putHostname({ hostname });
state.value = env.data;
warnings.value = env.warnings;
error.value = null;
return env.warnings;
} catch (cause) {
const wrapped = cause instanceof ApiError ? cause : ApiError.network(cause);
error.value = wrapped;
throw wrapped;
} finally {
loading.value = false;
}
}
async function applyMgmtIp(vswitch: string, config: ServicePortConfig): Promise<MutationEnvelope<ServicePortConfig>> {
loading.value = true;
try {
const env = await configureServicePort(vswitch, 'mgmt', config);
await refresh();
return env;
} catch (cause) {
const wrapped = cause instanceof ApiError ? cause : ApiError.network(cause);
error.value = wrapped;
throw wrapped;
} finally {
loading.value = false;
}
}
async function removeMgmtIp(vswitch: string): Promise<MutationEnvelope> {
loading.value = true;
try {
const env = await clearServicePort(vswitch, 'mgmt');
await refresh();
return env;
} catch (cause) {
const wrapped = cause instanceof ApiError ? cause : ApiError.network(cause);
error.value = wrapped;
throw wrapped;
} finally {
loading.value = false;
}
}
onMounted(() => { void refresh(); });
onScopeDispose(() => { /* no timers */ });
return { state, warnings, loading, error, refresh, applyHostname, applyMgmtIp, removeMgmtIp };
}

Confirm ApiError.network exists (used throughout useHostTime.ts) and configureServicePort/clearServicePort signatures (confirmed in @/api/network).

  • Step 4: Run test to verify it passes

Run: cd crates/web-ux-v2 && npx vitest run src/views/settings/network/__tests__/selfInterface.spec.ts Expected: PASS

  • Step 5: Commit
Terminal window
git add crates/web-ux-v2/src/composables/useHostNetwork.ts crates/web-ux-v2/src/views/settings/network/
git commit -m "feat(web-ux-v2): useHostNetwork composable + self-interface helper"

Task 10: Frontend — NetworkPanel.vue + wire into SettingsView

Section titled “Task 10: Frontend — NetworkPanel.vue + wire into SettingsView”

Files:

  • Create: crates/web-ux-v2/src/views/settings/NetworkPanel.vue
  • Modify: crates/web-ux-v2/src/views/SettingsView.vue

Interfaces:

  • Consumes: useHostNetwork (Task 9), isSelfInterface/newIpUrl (Task 9), notifyApplyOutcome + type ServicePortConfig from @/api/network, type MgmtInterfaceView from @/api/settings/network, UI Card/Button/Banner (same imports TimePanel.vue uses).

  • Step 1: Implement the panel

crates/web-ux-v2/src/views/settings/NetworkPanel.vue
<script setup lang="ts">
/**
* Settings → Network: edit the host hostname and the management
* interfaces (ipMGMT — one per vSwitch, all editable). Management-IP apply
* reuses the service-port endpoints (commit-confirm + rollback), which also
* announce the new address to the cluster automatically.
*/
import { computed, ref, watch } from 'vue';
import { RefreshCw } from 'lucide-vue-next';
import Card from '@/components/ui/Card.vue';
import Button from '@/components/ui/Button.vue';
import Banner from '@/components/ui/Banner.vue';
import { useHostNetwork } from '@/composables/useHostNetwork';
import { isSelfInterface, newIpUrl } from '@/views/settings/network/selfInterface';
import { notifyApplyOutcome, type ServicePortConfig } from '@/api/network';
import type { MgmtInterfaceView } from '@/api/settings/network';
const net = useHostNetwork();
const hostnameDraft = ref('');
const submitError = ref<string | null>(null);
watch(() => net.state.value?.hostname, (h) => { if (h !== undefined) hostnameDraft.value = h; }, { immediate: true });
const hostnameValid = computed(() => {
const h = hostnameDraft.value.trim();
if (h.length === 0 || h.length > 253) return false;
return h.split('.').every((l) => /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$/.test(l) && l.length <= 63);
});
const hostnameDirty = computed(() => hostnameDraft.value !== (net.state.value?.hostname ?? ''));
async function applyHostname(): Promise<void> {
submitError.value = null;
try { await net.applyHostname(hostnameDraft.value.trim()); }
catch (cause) { submitError.value = cause instanceof Error ? cause.message : 'Hostname change failed'; }
}
type Draft = ServicePortConfig & { vswitch: string };
const drafts = ref<Record<string, Draft>>({});
watch(() => net.state.value?.mgmt_interfaces, (list) => {
const next: Record<string, Draft> = {};
for (const m of list ?? []) {
next[m.vswitch] = { vswitch: m.vswitch, vlan: m.vlan, mode: m.mode, address: m.address, gateway: m.gateway, dns: [...m.dns], mtu: m.mtu };
}
drafts.value = next;
}, { immediate: true, deep: true });
function rowDirty(m: MgmtInterfaceView): boolean {
const d = drafts.value[m.vswitch];
if (!d) return false;
return d.vlan !== m.vlan || d.mode !== m.mode || d.address !== m.address
|| d.gateway !== m.gateway || d.mtu !== m.mtu || d.dns.join(',') !== m.dns.join(',');
}
async function applyRow(m: MgmtInterfaceView): Promise<void> {
submitError.value = null;
const d = drafts.value[m.vswitch];
if (!d) return;
const self = isSelfInterface(m.address, window.location.hostname);
if (self && !window.confirm(`This is the interface you're connected through. Your session will drop; reconnect at ${newIpUrl(d.address)}.`)) return;
const config: ServicePortConfig = { vlan: d.vlan, mode: d.mode, address: d.address, gateway: d.gateway, dns: d.dns, mtu: d.mtu };
try {
const env = await net.applyMgmtIp(m.vswitch, config);
notifyApplyOutcome(env, {
confirmed: () => { submitError.value = null; },
rolledBack: (reason) => { submitError.value = `Rolled back: ${reason}`; },
applyFailed: (err) => { submitError.value = `Apply failed: ${err}`; },
});
if (self) window.location.href = newIpUrl(d.address);
} catch (cause) {
submitError.value = self
? `Session likely dropped. Reconnect at ${newIpUrl(d.address)}.`
: (cause instanceof Error ? cause.message : 'Apply failed');
}
}
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="min-w-0">
<h2 class="font-display text-display-4 text-text">Network</h2>
<p class="mt-1 text-sm text-text-3">Hostname and management interfaces (ipMGMT). Changes apply only when you click Apply.</p>
</div>
<Button variant="secondary" size="sm" :loading="net.loading.value" @click="net.refresh">
<RefreshCw class="h-4 w-4" /> Refresh
</Button>
</div>
<Banner v-if="net.error.value" severity="danger" title="Could not load network settings">{{ net.error.value.message }}</Banner>
<Banner v-if="submitError" severity="danger" title="Action failed">{{ submitError }}</Banner>
<Card>
<div class="flex flex-col gap-3">
<h3 class="text-sm font-medium text-text">Hostname</h3>
<input v-model="hostnameDraft" class="rounded-input border border-border bg-surface px-3 py-2 text-sm" spellcheck="false" autocomplete="off" />
<p v-if="!hostnameValid" class="text-xs text-danger">Must be a valid hostname (letters, digits, hyphens; dot-separated labels).</p>
<Banner v-if="net.state.value?.is_cluster_member" severity="info" title="Cluster member">
Cluster identity is keyed off node id / fingerprint, not hostname — membership is unaffected; peers show the new name after their next sync.
</Banner>
<div>
<Button variant="primary" size="sm" :disabled="!hostnameDirty || !hostnameValid || net.loading.value" @click="applyHostname">Apply hostname</Button>
</div>
</div>
</Card>
<Card>
<div class="flex flex-col gap-4">
<h3 class="text-sm font-medium text-text">Management interfaces (ipMGMT)</h3>
<Banner v-if="net.state.value?.is_cluster_member" severity="info" title="Cluster member">
Management-IP changes on this host are announced to the cluster automatically (identity is unchanged).
</Banner>
<p v-if="!(net.state.value?.mgmt_interfaces.length)" class="text-sm text-text-3">No management interfaces configured.</p>
<div v-for="m in net.state.value?.mgmt_interfaces ?? []" :key="m.vswitch" class="rounded-card border border-border p-3">
<div class="mb-2 flex items-center gap-2">
<span class="font-medium text-text">{{ m.vswitch }}</span>
<span v-if="m.is_default" class="text-xs text-text-3">default</span>
</div>
<div class="grid grid-cols-2 gap-2">
<label class="text-xs text-text-3">Address (CIDR)
<input v-model="drafts[m.vswitch].address" class="mt-1 w-full rounded-input border border-border bg-surface px-2 py-1 text-sm" />
</label>
<label class="text-xs text-text-3">Gateway
<input v-model="drafts[m.vswitch].gateway" class="mt-1 w-full rounded-input border border-border bg-surface px-2 py-1 text-sm" />
</label>
<label class="text-xs text-text-3">VLAN
<input v-model.number="drafts[m.vswitch].vlan" type="number" class="mt-1 w-full rounded-input border border-border bg-surface px-2 py-1 text-sm" />
</label>
<label class="text-xs text-text-3">MTU
<input v-model.number="drafts[m.vswitch].mtu" type="number" class="mt-1 w-full rounded-input border border-border bg-surface px-2 py-1 text-sm" />
</label>
</div>
<div class="mt-2">
<Button variant="primary" size="sm" :disabled="!rowDirty(m) || net.loading.value" @click="applyRow(m)">Apply</Button>
</div>
</div>
</div>
</Card>
</div>
</template>

notifyApplyOutcome’s handler keys (confirmed/rolledBack/applyFailed) must match the real NotifyHandlers type in @/api/network. Open it and use the exact property names. Reuse the real Banner severity values and Button variant names from TimePanel.vue.

  • Step 2: Wire into SettingsView.vue

In crates/web-ux-v2/src/views/SettingsView.vue, add the import next to the other panel imports:

import NetworkPanel from '@/views/settings/NetworkPanel.vue';

Add the conditional before the PlaceholderPanel fallback in the template chain:

<NetworkPanel v-else-if="active === 'network'" />
  • Step 3: Typecheck + lint + build

Run: cd crates/web-ux-v2 && npm run lint -- --max-warnings 0 && npx vue-tsc --noEmit && npm run build Expected: clean.

  • Step 4: Run the frontend test suite (judge diff-vs-baseline)

Run: cd crates/web-ux-v2 && npx vitest run Expected: new specs pass; no NEW failures vs the pre-change baseline (baseline not 100% green).

  • Step 5: Commit
Terminal window
git add crates/web-ux-v2/src/views/settings/NetworkPanel.vue crates/web-ux-v2/src/views/SettingsView.vue
git commit -m "feat(web-ux-v2): Network settings panel (hostname + ipMGMT list)"

Task 11: Deploy to .13 + live verification

Section titled “Task 11: Deploy to .13 + live verification”

Files: none (verification only).

  • Step 1: Workspace gates
Terminal window
cargo fmt --all && cargo clippy -p synvirt-network -p synvirt-cluster -p synvirt-clusterd -p synvirt-daemon -- -D warnings && cargo build -p synvirt-daemon -p synvirt-clusterd -p synvirt-network

Expected: clean. Commit fmt-only changes with style: cargo fmt.

  • Step 2: Deploy hot to .13 (snapshotted VM)
Terminal window
USER=root scripts/deploy-to-host.sh 10.10.26.13 daemon clusterd network web-ux-v2

Match the real syntax of scripts/deploy-to-host.sh (read its usage header). Ship daemon + clusterd + synvirt-network binaries + web-ux-v2 dist. clusterd carries the new announce handler — it must be redeployed.

  • Step 3: Verify read path
Terminal window
curl -sk -u root:1234 https://10.10.26.13/api/v1/settings/network | jq

Expected: envelope with data.hostname, data.is_cluster_member: true, data.mgmt_interfaces[], data.addable_vswitches.

  • Step 4: Verify hostname change + live identity (no restart)
Terminal window
curl -sk -u root:1234 -X PUT https://10.10.26.13/api/v1/settings/network/hostname \
-H 'Content-Type: application/json' -d '{"hostname":"synvirt-03"}' | jq
curl -sk -u root:1234 https://10.10.26.13/api/v1/node/identity | jq .hostname
ssh [email protected] hostnamectl --static

Expected: PUT 200 with the cluster-member warning; identity reports the new hostname with NO restart; hostnamectl --static matches.

  • Step 5: Verify re-IP + rollback on a non-disruptive interface

Add an ipMGMT to a bare vSwitch from addable_vswitches (isolated) and re-IP it; healthy config first (expect apply.status not rolled_back), then a deliberately-broken config (expect rolled_back + reason). This exercises the apply/rollback path without changing .13’s primary address.

Terminal window
curl -sk -u root:1234 -X PUT https://10.10.26.13/api/v1/vswitches/<bare-vswitch>/service-ports/mgmt \
-H 'Content-Type: application/json' \
-d '{"vlan":0,"mode":"static","address":"192.168.77.2/24","gateway":"","dns":[],"mtu":1500}' | jq '.apply.status'
  • Step 6: Verify cluster announce on a real primary re-IP

Re-IP .13’s primary management interface (snapshotted — recoverable). Then confirm from a peer that the cluster learned the new address and Raft re-converged:

Terminal window
# After re-IP'ing .13's mgmt to e.g. 10.10.26.23/24 via the service-port PUT:
curl -sk -u root:1234 https://10.10.26.11/api/v1/cluster/health | jq # .13 still a healthy voter
ssh [email protected] 'curl -sk -u root:1234 https://10.10.26.11/api/v1/cluster/status' | jq # .13 address updated

Expected: peers (.11/.12) show .13 at the new address; cluster health stays healthy; no restart was needed (wildcard bind). If the announce warn-logged a failure, check journalctl -u synvirt-daemon on .13.

  • Step 7: Verify the panel in the browser

Open https://10.10.26.13/settings → Network (hard refresh, Ctrl+Shift+R). Confirm: hostname card + cluster note; every ipMGMT editable; a bare vSwitch can be added/edited.

  • Step 8: Restore .13

Revert .13 to its pre-verification snapshot if any step left it degraded; otherwise clean up the bare-vSwitch ipMGMT via the DELETE service-port call and restore .13’s original mgmt IP.

  • Step 9: Final fmt/lint fixups commit (if any)
Terminal window
git add -A && git commit -m "chore: settings/network fmt + lint fixups"

  • Spec coverage: hostname read/edit (1,5,6,8,10), ipMGMT list 1-per-vSwitch × N + add (6,9,10), reuse commit-confirm apply (9), wildcard mesh bind (2), announce on re-IP end-to-end (3,4), OpenAPI (7), deploy/verify incl announce + peer re-converge (11). Out-of-scope: multiple ipMGMT on one vSwitch.
  • No gating — corrected per owner: RAFT identity is node id (fingerprint-derived, IP-independent); the only cluster action is the AddMember upsert. All interfaces editable.
  • node_id is a String on AddMemberruntime.node_id().to_string() everywhere.
  • Cluster control is out-of-processannounce_address wired in trait + both impls + clusterd proto/handler, mirroring config_put. clusterd must be redeployed (Task 11 step 2).
  • Hostname hot-reload = live hostname::get() re-read in node_identity (no LocalNodeIdentity change, no restart).