From 6b5c2ae7a9d1af3b98164a8b56ace836ff9e513a Mon Sep 17 00:00:00 2001 From: Kieran Date: Thu, 30 Jul 2026 15:25:40 +0100 Subject: [PATCH 1/4] fix(proxmox): set guest DNS via cloud-init resolv_conf in vendor snippet Statically-addressed VMs (ipconfig0, no DHCP) relied on each image's own network renderer to apply the DNS servers Proxmox provides. Minimal images silently dropped them: Alpine's busybox ifupdown writes dns-nameservers to the interfaces file, which only reaches /etc/resolv.conf when openresolv is installed (the stock Alpine cloud image lacks it), so guests booted with an empty /etc/resolv.conf and no working DNS. Extend the shared cloud-init vendor snippet to mirror the Proxmox host's /etc/resolv.conf into 'manage_resolv_conf: true' + 'resolv_conf.nameservers', forcing cloud-init to write /etc/resolv.conf directly regardless of the guest network renderer. This matches Proxmox's own host-resolv.conf fallback and fixes Alpine, NixOS and any other minimal image. Falls back to the previous snippet (no DNS block) when the host has no resolvers. Adds parse_resolv_conf_nameservers + build_vendor_snippet helpers with tests. --- lnvps_api/src/host/proxmox.rs | 91 ++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/lnvps_api/src/host/proxmox.rs b/lnvps_api/src/host/proxmox.rs index d088dcfc..507c7e93 100644 --- a/lnvps_api/src/host/proxmox.rs +++ b/lnvps_api/src/host/proxmox.rs @@ -363,7 +363,6 @@ impl ProxmoxClient { }; let snippet_filename = "lnvps-vendor.yaml"; - let snippet_content = "#cloud-config\nssh_deletekeys: false\n"; // Snippet storage path depends on the storage type; for the default // `local` storage this is `/var/lib/vz/snippets/`. For other directory- @@ -377,6 +376,21 @@ impl ProxmoxClient { .await .map_err(OpError::Transient)?; + // Mirror the Proxmox host's resolvers into the snippet so cloud-init + // force-writes /etc/resolv.conf on every guest. Without this, minimal + // images (Alpine, NixOS) silently drop the DNS servers Proxmox provides. + let (_, resolv_conf) = ssh + .execute("cat /etc/resolv.conf 2>/dev/null || true") + .await + .map_err(OpError::Transient)?; + let nameservers = parse_resolv_conf_nameservers(&resolv_conf); + if nameservers.is_empty() { + warn!( + "No nameservers found in host /etc/resolv.conf; vendor snippet will not manage guest DNS" + ); + } + let snippet_content = build_vendor_snippet(&nameservers); + let vol_ref = format!("{storage_name}:snippets/{snippet_filename}"); // Resolve the on-disk path for the snippet volume reference @@ -2229,6 +2243,52 @@ fn parse_storage_from_disk(disk: &str) -> Option { } } +/// Extract the `nameserver` entries from an `/etc/resolv.conf`, preserving order +/// and dropping duplicates/comments. +/// +/// Used to mirror the Proxmox host's resolvers into the cloud-init vendor +/// snippet (see [`build_vendor_snippet`]). +fn parse_resolv_conf_nameservers(resolv_conf: &str) -> Vec { + let mut out: Vec = Vec::new(); + for line in resolv_conf.lines() { + let line = line.trim(); + if line.starts_with('#') || line.starts_with(';') { + continue; + } + let mut parts = line.split_whitespace(); + if parts.next() == Some("nameserver") + && let Some(ip) = parts.next() + && ip.parse::().is_ok() + && !out.iter().any(|e| e == ip) + { + out.push(ip.to_string()); + } + } + out +} + +/// Build the cloud-init vendor-data snippet applied to every VM on the host. +/// +/// Always disables SSH host-key regeneration (`ssh_deletekeys: false`). When +/// `nameservers` is non-empty it also forces cloud-init to write +/// `/etc/resolv.conf` directly (`manage_resolv_conf: true`), which is required +/// for minimal images (e.g. Alpine, NixOS) whose native network renderer does +/// not apply the DNS servers Proxmox hands them (Alpine's busybox ifupdown +/// needs `openresolv`, which the stock cloud image lacks). Mirroring the host's +/// resolvers here matches Proxmox's own host-`resolv.conf` fallback behaviour. +fn build_vendor_snippet(nameservers: &[String]) -> String { + let mut s = String::from("#cloud-config\nssh_deletekeys: false\n"); + if !nameservers.is_empty() { + s.push_str("manage_resolv_conf: true\nresolv_conf:\n nameservers:\n"); + for ns in nameservers { + s.push_str(" - "); + s.push_str(ns); + s.push('\n'); + } + } + s +} + impl From for i32 { fn from(val: ProxmoxVmId) -> Self { val.0 as i32 + 100 @@ -2840,6 +2900,35 @@ mod tests { use wiremock::matchers::{method, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; + #[test] + fn test_parse_resolv_conf_nameservers() { + let rc = "# generated\nsearch example.com\nnameserver 1.1.1.1\nnameserver 9.9.9.9\n;comment\nnameserver 1.1.1.1\nnameserver not-an-ip\nnameserver 2606:4700:4700::1111\n"; + assert_eq!( + parse_resolv_conf_nameservers(rc), + vec![ + "1.1.1.1".to_string(), + "9.9.9.9".to_string(), + "2606:4700:4700::1111".to_string(), + ] + ); + assert!(parse_resolv_conf_nameservers("# nothing here\nsearch foo\n").is_empty()); + } + + #[test] + fn test_build_vendor_snippet() { + // No nameservers -> only ssh_deletekeys, no resolv_conf block. + let empty = build_vendor_snippet(&[]); + assert_eq!(empty, "#cloud-config\nssh_deletekeys: false\n"); + assert!(!empty.contains("manage_resolv_conf")); + + // With nameservers -> manage_resolv_conf block appended. + let s = build_vendor_snippet(&["1.1.1.1".to_string(), "9.9.9.9".to_string()]); + assert_eq!( + s, + "#cloud-config\nssh_deletekeys: false\nmanage_resolv_conf: true\nresolv_conf:\n nameservers:\n - 1.1.1.1\n - 9.9.9.9\n" + ); + } + #[test] fn test_image_source_checksum_path() { assert_eq!( From 4794ff0298bde13405103faeb87dcc9f01a8dc7c Mon Sep 17 00:00:00 2001 From: Kieran Date: Thu, 30 Jul 2026 15:31:50 +0100 Subject: [PATCH 2/4] refactor: use a fixed public DNS list (no host SSH read) Instead of reading the Proxmox host's /etc/resolv.conf over SSH, force a fixed set of public resolvers into the cloud-init vendor snippet: Cloudflare, Google and Quad9, including their IPv6 variants. Drops the parse_resolv_conf_nameservers helper and the extra SSH round-trip. --- lnvps_api/src/host/proxmox.rs | 82 +++++++++++------------------------ 1 file changed, 25 insertions(+), 57 deletions(-) diff --git a/lnvps_api/src/host/proxmox.rs b/lnvps_api/src/host/proxmox.rs index 507c7e93..5858d6fb 100644 --- a/lnvps_api/src/host/proxmox.rs +++ b/lnvps_api/src/host/proxmox.rs @@ -376,20 +376,10 @@ impl ProxmoxClient { .await .map_err(OpError::Transient)?; - // Mirror the Proxmox host's resolvers into the snippet so cloud-init - // force-writes /etc/resolv.conf on every guest. Without this, minimal - // images (Alpine, NixOS) silently drop the DNS servers Proxmox provides. - let (_, resolv_conf) = ssh - .execute("cat /etc/resolv.conf 2>/dev/null || true") - .await - .map_err(OpError::Transient)?; - let nameservers = parse_resolv_conf_nameservers(&resolv_conf); - if nameservers.is_empty() { - warn!( - "No nameservers found in host /etc/resolv.conf; vendor snippet will not manage guest DNS" - ); - } - let snippet_content = build_vendor_snippet(&nameservers); + // Force a fixed set of public resolvers into every guest's + // /etc/resolv.conf via cloud-init. Without this, minimal images + // (Alpine, NixOS) silently drop the DNS servers Proxmox provides. + let snippet_content = build_vendor_snippet(GUEST_DNS_SERVERS); let vol_ref = format!("{storage_name}:snippets/{snippet_filename}"); @@ -2243,29 +2233,17 @@ fn parse_storage_from_disk(disk: &str) -> Option { } } -/// Extract the `nameserver` entries from an `/etc/resolv.conf`, preserving order -/// and dropping duplicates/comments. -/// -/// Used to mirror the Proxmox host's resolvers into the cloud-init vendor -/// snippet (see [`build_vendor_snippet`]). -fn parse_resolv_conf_nameservers(resolv_conf: &str) -> Vec { - let mut out: Vec = Vec::new(); - for line in resolv_conf.lines() { - let line = line.trim(); - if line.starts_with('#') || line.starts_with(';') { - continue; - } - let mut parts = line.split_whitespace(); - if parts.next() == Some("nameserver") - && let Some(ip) = parts.next() - && ip.parse::().is_ok() - && !out.iter().any(|e| e == ip) - { - out.push(ip.to_string()); - } - } - out -} +/// DNS resolvers forced into every guest's `/etc/resolv.conf` via the cloud-init +/// vendor snippet (see [`build_vendor_snippet`]). +const GUEST_DNS_SERVERS: &[&str] = &[ + "1.1.1.1", + "8.8.8.8", + "9.9.9.9", + // IPv6 variants of the same providers (Cloudflare, Google, Quad9). + "2606:4700:4700::1111", + "2001:4860:4860::8888", + "2620:fe::fe", +]; /// Build the cloud-init vendor-data snippet applied to every VM on the host. /// @@ -2274,9 +2252,8 @@ fn parse_resolv_conf_nameservers(resolv_conf: &str) -> Vec { /// `/etc/resolv.conf` directly (`manage_resolv_conf: true`), which is required /// for minimal images (e.g. Alpine, NixOS) whose native network renderer does /// not apply the DNS servers Proxmox hands them (Alpine's busybox ifupdown -/// needs `openresolv`, which the stock cloud image lacks). Mirroring the host's -/// resolvers here matches Proxmox's own host-`resolv.conf` fallback behaviour. -fn build_vendor_snippet(nameservers: &[String]) -> String { +/// needs `openresolv`, which the stock cloud image lacks). +fn build_vendor_snippet(nameservers: &[&str]) -> String { let mut s = String::from("#cloud-config\nssh_deletekeys: false\n"); if !nameservers.is_empty() { s.push_str("manage_resolv_conf: true\nresolv_conf:\n nameservers:\n"); @@ -2900,20 +2877,6 @@ mod tests { use wiremock::matchers::{method, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; - #[test] - fn test_parse_resolv_conf_nameservers() { - let rc = "# generated\nsearch example.com\nnameserver 1.1.1.1\nnameserver 9.9.9.9\n;comment\nnameserver 1.1.1.1\nnameserver not-an-ip\nnameserver 2606:4700:4700::1111\n"; - assert_eq!( - parse_resolv_conf_nameservers(rc), - vec![ - "1.1.1.1".to_string(), - "9.9.9.9".to_string(), - "2606:4700:4700::1111".to_string(), - ] - ); - assert!(parse_resolv_conf_nameservers("# nothing here\nsearch foo\n").is_empty()); - } - #[test] fn test_build_vendor_snippet() { // No nameservers -> only ssh_deletekeys, no resolv_conf block. @@ -2921,12 +2884,17 @@ mod tests { assert_eq!(empty, "#cloud-config\nssh_deletekeys: false\n"); assert!(!empty.contains("manage_resolv_conf")); - // With nameservers -> manage_resolv_conf block appended. - let s = build_vendor_snippet(&["1.1.1.1".to_string(), "9.9.9.9".to_string()]); + // With nameservers (incl. IPv6) -> manage_resolv_conf block appended. + let s = build_vendor_snippet(&["1.1.1.1", "2606:4700:4700::1111"]); assert_eq!( s, - "#cloud-config\nssh_deletekeys: false\nmanage_resolv_conf: true\nresolv_conf:\n nameservers:\n - 1.1.1.1\n - 9.9.9.9\n" + "#cloud-config\nssh_deletekeys: false\nmanage_resolv_conf: true\nresolv_conf:\n nameservers:\n - 1.1.1.1\n - 2606:4700:4700::1111\n" ); + + // The production constant covers both IPv4 and IPv6 resolvers. + let prod = build_vendor_snippet(GUEST_DNS_SERVERS); + assert!(prod.contains(" - 8.8.8.8\n")); + assert!(prod.contains(" - 2620:fe::fe\n")); } #[test] From d36fb24dc5acabb350cd6a37698c651ccbe7f95f Mon Sep 17 00:00:00 2001 From: Kieran Date: Thu, 30 Jul 2026 15:41:12 +0100 Subject: [PATCH 3/4] refactor(proxmox): build cloud-init vendor data from a typed serde struct Replace hand-concatenated YAML with CloudInitVendorData / CloudInitResolvConf structs serialised via serde_json. JSON is a strict subset of YAML, so the output (prefixed with the #cloud-config header) is valid cloud-config while eliminating fragile manual indentation/escaping. No new dependency. --- lnvps_api/src/host/proxmox.rs | 62 ++++++++++++++++++++++++++--------- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/lnvps_api/src/host/proxmox.rs b/lnvps_api/src/host/proxmox.rs index 5858d6fb..f9c70669 100644 --- a/lnvps_api/src/host/proxmox.rs +++ b/lnvps_api/src/host/proxmox.rs @@ -2245,6 +2245,26 @@ const GUEST_DNS_SERVERS: &[&str] = &[ "2620:fe::fe", ]; +/// Structured `#cloud-config` vendor-data document written to the host snippet +/// and referenced by every VM's `cicustom` (see [`build_vendor_snippet`]). +#[derive(Debug, Serialize)] +struct CloudInitVendorData { + /// Keep SSH host keys across cloud-init reconfiguration so users don't hit + /// host-key-changed warnings on IP/key updates. + ssh_deletekeys: bool, + /// Force cloud-init to write `/etc/resolv.conf` directly. Only emitted when + /// there are nameservers to set. + #[serde(skip_serializing_if = "Option::is_none")] + manage_resolv_conf: Option, + #[serde(skip_serializing_if = "Option::is_none")] + resolv_conf: Option, +} + +#[derive(Debug, Serialize)] +struct CloudInitResolvConf { + nameservers: Vec, +} + /// Build the cloud-init vendor-data snippet applied to every VM on the host. /// /// Always disables SSH host-key regeneration (`ssh_deletekeys: false`). When @@ -2253,17 +2273,24 @@ const GUEST_DNS_SERVERS: &[&str] = &[ /// for minimal images (e.g. Alpine, NixOS) whose native network renderer does /// not apply the DNS servers Proxmox hands them (Alpine's busybox ifupdown /// needs `openresolv`, which the stock cloud image lacks). +/// +/// Serialised from a typed struct via `serde_json`: JSON is a strict subset of +/// YAML, so the output — prefixed with the `#cloud-config` header line — is +/// valid cloud-config while avoiding fragile hand-built YAML. fn build_vendor_snippet(nameservers: &[&str]) -> String { - let mut s = String::from("#cloud-config\nssh_deletekeys: false\n"); - if !nameservers.is_empty() { - s.push_str("manage_resolv_conf: true\nresolv_conf:\n nameservers:\n"); - for ns in nameservers { - s.push_str(" - "); - s.push_str(ns); - s.push('\n'); - } - } - s + let has_dns = !nameservers.is_empty(); + let data = CloudInitVendorData { + ssh_deletekeys: false, + manage_resolv_conf: has_dns.then_some(true), + resolv_conf: has_dns.then(|| CloudInitResolvConf { + nameservers: nameservers.iter().map(|s| s.to_string()).collect(), + }), + }; + format!( + "#cloud-config\n{}\n", + // This struct is always serialisable, so this cannot fail. + serde_json::to_string(&data).expect("serialize cloud-init vendor data") + ) } impl From for i32 { @@ -2881,20 +2908,25 @@ mod tests { fn test_build_vendor_snippet() { // No nameservers -> only ssh_deletekeys, no resolv_conf block. let empty = build_vendor_snippet(&[]); - assert_eq!(empty, "#cloud-config\nssh_deletekeys: false\n"); + assert_eq!(empty, "#cloud-config\n{\"ssh_deletekeys\":false}\n"); assert!(!empty.contains("manage_resolv_conf")); - // With nameservers (incl. IPv6) -> manage_resolv_conf block appended. + // With nameservers (incl. IPv6) -> manage_resolv_conf + resolv_conf set. let s = build_vendor_snippet(&["1.1.1.1", "2606:4700:4700::1111"]); assert_eq!( s, - "#cloud-config\nssh_deletekeys: false\nmanage_resolv_conf: true\nresolv_conf:\n nameservers:\n - 1.1.1.1\n - 2606:4700:4700::1111\n" + "#cloud-config\n{\"ssh_deletekeys\":false,\"manage_resolv_conf\":true,\"resolv_conf\":{\"nameservers\":[\"1.1.1.1\",\"2606:4700:4700::1111\"]}}\n" ); + // Must carry the cloud-config header and be valid YAML (JSON subset). + assert!(s.starts_with("#cloud-config\n")); + let body = s.strip_prefix("#cloud-config\n").unwrap(); + let parsed: serde_json::Value = serde_json::from_str(body).unwrap(); + assert_eq!(parsed["resolv_conf"]["nameservers"][0], "1.1.1.1"); // The production constant covers both IPv4 and IPv6 resolvers. let prod = build_vendor_snippet(GUEST_DNS_SERVERS); - assert!(prod.contains(" - 8.8.8.8\n")); - assert!(prod.contains(" - 2620:fe::fe\n")); + assert!(prod.contains("\"8.8.8.8\"")); + assert!(prod.contains("\"2620:fe::fe\"")); } #[test] From e9fc557146f83ca27fff6e3156a3fa86beeef3f0 Mon Sep 17 00:00:00 2001 From: Kieran Date: Thu, 30 Jul 2026 15:45:28 +0100 Subject: [PATCH 4/4] refactor(proxmox): emit cloud-init vendor snippet as real YAML via serde_yml Swap serde_json for serde_yml so the on-disk lnvps-vendor.yaml is proper cloud-config YAML instead of JSON-in-a-.yaml-file. Adds serde_yml to the workspace deps. Test now round-trips the snippet through serde_yml to assert structure rather than matching an exact string. --- Cargo.lock | 26 ++++++++++++++++++++ Cargo.toml | 1 + lnvps_api/Cargo.toml | 1 + lnvps_api/src/host/proxmox.rs | 46 ++++++++++++++++++++--------------- 4 files changed, 54 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f6969a6f..ca3b5e92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3253,6 +3253,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + [[package]] name = "lightning-invoice" version = "0.34.0" @@ -3372,6 +3382,7 @@ dependencies = [ "russh-sftp", "serde", "serde_json", + "serde_yml", "ssh-key 0.6.7", "tokio", "tokio-tungstenite 0.28.0", @@ -5830,6 +5841,21 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] + [[package]] name = "serdect" version = "0.4.3" diff --git a/Cargo.toml b/Cargo.toml index 580892c0..78ceb53f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ log = "0.4" env_logger = "0.11" serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yml = "0.0.12" axum = { version = "0.8", features = ["ws", "macros"] } tower-http = { version = "0.6", features = ["cors", "set-header"] } config = { version = "0.15", features = ["yaml"] } diff --git a/lnvps_api/Cargo.toml b/lnvps_api/Cargo.toml index c90da7bf..f2029561 100644 --- a/lnvps_api/Cargo.toml +++ b/lnvps_api/Cargo.toml @@ -68,6 +68,7 @@ tokio.workspace = true config.workspace = true serde.workspace = true serde_json.workspace = true +serde_yml.workspace = true axum.workspace = true tower-http.workspace = true hex.workspace = true diff --git a/lnvps_api/src/host/proxmox.rs b/lnvps_api/src/host/proxmox.rs index f9c70669..574e4347 100644 --- a/lnvps_api/src/host/proxmox.rs +++ b/lnvps_api/src/host/proxmox.rs @@ -2274,9 +2274,9 @@ struct CloudInitResolvConf { /// not apply the DNS servers Proxmox hands them (Alpine's busybox ifupdown /// needs `openresolv`, which the stock cloud image lacks). /// -/// Serialised from a typed struct via `serde_json`: JSON is a strict subset of -/// YAML, so the output — prefixed with the `#cloud-config` header line — is -/// valid cloud-config while avoiding fragile hand-built YAML. +/// Serialised to YAML from a typed struct via `serde_yml`, prefixed with the +/// `#cloud-config` header line, so the on-disk snippet is real cloud-config +/// YAML without any fragile hand-built indentation/escaping. fn build_vendor_snippet(nameservers: &[&str]) -> String { let has_dns = !nameservers.is_empty(); let data = CloudInitVendorData { @@ -2287,9 +2287,9 @@ fn build_vendor_snippet(nameservers: &[&str]) -> String { }), }; format!( - "#cloud-config\n{}\n", + "#cloud-config\n{}", // This struct is always serialisable, so this cannot fail. - serde_json::to_string(&data).expect("serialize cloud-init vendor data") + serde_yml::to_string(&data).expect("serialize cloud-init vendor data") ) } @@ -2906,27 +2906,33 @@ mod tests { #[test] fn test_build_vendor_snippet() { - // No nameservers -> only ssh_deletekeys, no resolv_conf block. + // Every snippet must carry the cloud-config header and parse as YAML. + let header = "#cloud-config\n"; + + // No nameservers -> ssh_deletekeys only, no resolv_conf keys. let empty = build_vendor_snippet(&[]); - assert_eq!(empty, "#cloud-config\n{\"ssh_deletekeys\":false}\n"); + assert!(empty.starts_with(header)); assert!(!empty.contains("manage_resolv_conf")); + assert!(!empty.contains("resolv_conf")); + let v: serde_yml::Value = serde_yml::from_str(&empty).unwrap(); + assert_eq!(v["ssh_deletekeys"], serde_yml::Value::Bool(false)); // With nameservers (incl. IPv6) -> manage_resolv_conf + resolv_conf set. let s = build_vendor_snippet(&["1.1.1.1", "2606:4700:4700::1111"]); - assert_eq!( - s, - "#cloud-config\n{\"ssh_deletekeys\":false,\"manage_resolv_conf\":true,\"resolv_conf\":{\"nameservers\":[\"1.1.1.1\",\"2606:4700:4700::1111\"]}}\n" - ); - // Must carry the cloud-config header and be valid YAML (JSON subset). - assert!(s.starts_with("#cloud-config\n")); - let body = s.strip_prefix("#cloud-config\n").unwrap(); - let parsed: serde_json::Value = serde_json::from_str(body).unwrap(); - assert_eq!(parsed["resolv_conf"]["nameservers"][0], "1.1.1.1"); - - // The production constant covers both IPv4 and IPv6 resolvers. + assert!(s.starts_with(header)); + let v: serde_yml::Value = serde_yml::from_str(&s).unwrap(); + assert_eq!(v["ssh_deletekeys"], serde_yml::Value::Bool(false)); + assert_eq!(v["manage_resolv_conf"], serde_yml::Value::Bool(true)); + assert_eq!(v["resolv_conf"]["nameservers"][0], "1.1.1.1"); + assert_eq!(v["resolv_conf"]["nameservers"][1], "2606:4700:4700::1111"); + + // The production constant covers both IPv4 and IPv6 resolvers, and the + // body must be valid cloud-config YAML. let prod = build_vendor_snippet(GUEST_DNS_SERVERS); - assert!(prod.contains("\"8.8.8.8\"")); - assert!(prod.contains("\"2620:fe::fe\"")); + let v: serde_yml::Value = serde_yml::from_str(&prod).unwrap(); + let ns = v["resolv_conf"]["nameservers"].as_sequence().unwrap(); + assert!(ns.iter().any(|n| n.as_str() == Some("8.8.8.8"))); + assert!(ns.iter().any(|n| n.as_str() == Some("2620:fe::fe"))); } #[test]