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.
Global Constraints
Section titled “Global Constraints”- Backend: one
Errorenum per crate (thiserror) withE_*codes;#[tracing::instrument(skip_all)]on public async fns; nounwrap()/expect()in library code; subprocess viatokio::process::Commandwith timeout, stdout/stderr captured separately, never shell strings; every#[utoipa::path]handler sets an explicit uniqueoperation_id. network.yamlis the single source of truth; the in-processNetworkControlleris the only applicator. The hostname mutation lives inNetworkController— do not writenetwork.yamlelsewhere.- The vmkernel
kindfor 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 viaClusterCommand::AddMember { node_id, address }, which is an upsert (ON CONFLICT(node_id) DO UPDATE SET address).node_idonAddMemberis a String (useruntime.node_id().to_string()). - Cluster control is out-of-process (clusterd, UDS). Any new cluster operation is wired in BOTH
ClusterControlimpls (EmbeddedClusterControl,ClusterControlUdsClient) + the clusterd proto + handler — mirrorconfig_putexactly. - Frontend: TypeScript strict, no
any;npm run lint -- --max-warnings 0; importfetchJsonfrom@/api; copy uses ipMGMT, vSwitch, vNIC (idents stay lowercase); never removetranslate="no"/notranslate; statics areCache-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.shwithUSER=root.
File structure
Section titled “File structure”Backend (Rust):
- Modify
crates/synvirt-network/src/spec/mod.rs— makeis_rfc1123_hostnamepublic. - Modify
crates/synvirt-network/src/controller.rs— addset_hostname+hostname. - Modify
crates/synvirt-cluster/src/federation_runtime.rs— mesh listener binds0.0.0.0. - Modify
crates/daemon/src/api/cluster_control.rs—ClusterControl::announce_address+EmbeddedClusterControlimpl. - Modify
crates/daemon/src/api/cluster_control_client.rs—ClusterControlUdsClient::announce_address. - Modify
crates/synvirt-clusterd/src/control_proto.rs—PATH_ANNOUNCE_ADDRESS+AnnounceAddressRequest. - Modify
crates/synvirt-clusterd/src/http.rs— announce route + handler. - Modify
crates/daemon/src/main.rs—detect_mgmt_ipv4→pub(crate). - Modify
crates/daemon/src/api/network.rs— announce after a clusteredmgmtchange. - Modify
crates/daemon/src/api/cluster.rs—node_identityreads live hostname. - Create
crates/daemon/src/settings/api/network.rs—GET /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+ regeneratedsrc/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(makeis_rfc1123_hostnamepub) - 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 → writenetwork.yaml→hostnamectl 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
NetworkErrorhas noInternal(String)variant, use the crate’s catch-all variant (grepenum NetworkErrorincrates/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
git add crates/synvirt-network/src/spec/mod.rs crates/synvirt-network/src/controller.rs crates/synvirt-network/src/controller/tests.rsgit 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
TcpListenerbinds0.0.0.0:mesh_port(IP-agnostic) while the advertisedmesh_addrstill usesbind_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
git add crates/synvirt-cluster/src/federation_runtime.rsgit 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.rstests (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>— whenmode() == "cluster", upserts this node’scluster_membersaddress; no-op otherwise. Wired through clusterd overPATH_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), theapplysignature, and the members-read API used by the existingAddMembertest 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.rs — ClusterControlUdsClient 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
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.rsgit 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_ipv4→pub(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_portis the field name (ClusterConfighaspub mesh_port: u16). Confirmcontrol.mode()returns&strand"cluster"is the clustered value (verified incluster_control.rs).
- Step 2: Build
Run: cargo build -p synvirt-daemon
Expected: builds clean.
- Step 3: Commit
git add crates/daemon/src/main.rs crates/daemon/src/api/network.rsgit 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/identityreturns 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
hostnamecrate is already used insettings/envelope.rs. If absent fromcrates/daemon/Cargo.toml, add it matching the version inCargo.lock.
- Step 2: Build
Run: cargo build -p synvirt-daemon
Expected: builds clean.
- Step 3: Commit
git add crates/daemon/src/api/cluster.rsgit 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/SettingsWarningfromcrate::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 }; routesGET /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
BondConfigDtois notDefault, construct it explicitly (greppub 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
ApiErrorconstructors:bad,internalare used insettings/api/time.rs. For 503, grepApiError::incrates/daemon/src/api/error.rs; ifservice_unavailabledoesn’t exist, use the constructor that yields 503 there (or reuse therequire_controllererror path fromapi/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
git add crates/daemon/src/settings/api/network.rs crates/daemon/src/settings/api/mod.rsgit 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
cargo build -p synvirt-daemon./target/debug/synvirt-daemon --dump-openapi > crates/web-ux-v2/openapi.snapshot.jsonExpected: grep get_settings_network crates/web-ux-v2/openapi.snapshot.json returns a hit.
- Step 2: Regenerate TS types
cd crates/web-ux-v2 && npm run generate:api:from-fileExpected: regenerated, no errors.
- 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): 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:
fetchJsonfrom@/api;SettingsEnvelopefrom@/api/settings/time. -
Produces:
NetworkSettingsView,MgmtInterfaceView,PutHostnameBody,getNetworkSettings(),putHostname(body). -
Step 1: Write the failing test
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
/** * 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/timedoes not exportSettingsEnvelope, copy theSettingsEnvelope/SettingsWarning/SettingsMetainterfaces verbatim fromtime.tsinto 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
git add crates/web-ux-v2/src/api/settings/network.ts crates/web-ux-v2/src/api/settings/__tests__/network.spec.tsgit 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 MutationEnvelopefrom@/api/network;ApiErrorfrom@/api. -
Produces:
isSelfInterface(address, currentHost) -> boolean;newIpUrl(address) -> string;useHostNetwork()→{ state, warnings, loading, error, refresh, applyHostname, applyMgmtIp, removeMgmtIp }. -
Step 1: Write the failing test
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
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`;}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.networkexists (used throughoutuseHostTime.ts) andconfigureServicePort/clearServicePortsignatures (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
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 ServicePortConfigfrom@/api/network,type MgmtInterfaceViewfrom@/api/settings/network, UICard/Button/Banner(same importsTimePanel.vueuses). -
Step 1: Implement the panel
<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 realNotifyHandlerstype in@/api/network. Open it and use the exact property names. Reuse the realBannerseverity values andButtonvariant names fromTimePanel.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
git add crates/web-ux-v2/src/views/settings/NetworkPanel.vue crates/web-ux-v2/src/views/SettingsView.vuegit 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
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-networkExpected: clean. Commit fmt-only changes with style: cargo fmt.
- Step 2: Deploy hot to .13 (snapshotted VM)
USER=root scripts/deploy-to-host.sh 10.10.26.13 daemon clusterd network web-ux-v2Match 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
curl -sk -u root:1234 https://10.10.26.13/api/v1/settings/network | jqExpected: envelope with data.hostname, data.is_cluster_member: true, data.mgmt_interfaces[], data.addable_vswitches.
- Step 4: Verify hostname change + live identity (no restart)
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"}' | jqcurl -sk -u root:1234 https://10.10.26.13/api/v1/node/identity | jq .hostnameExpected: 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.
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:
# 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 voterssh [email protected] 'curl -sk -u root:1234 https://10.10.26.11/api/v1/cluster/status' | jq # .13 address updatedExpected: 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)
git add -A && git commit -m "chore: settings/network fmt + lint fixups"Self-review notes (author)
Section titled “Self-review notes (author)”- 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
AddMemberupsert. All interfaces editable. - node_id is a String on AddMember —
runtime.node_id().to_string()everywhere. - Cluster control is out-of-process —
announce_addresswired in trait + both impls + clusterd proto/handler, mirroringconfig_put. clusterd must be redeployed (Task 11 step 2). - Hostname hot-reload = live
hostname::get()re-read innode_identity(noLocalNodeIdentitychange, no restart).