Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/pxe/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ pin-project-lite = { workspace = true }
prometheus = { workspace = true }
rand = { workspace = true }
serde = { features = ["derive"], workspace = true }
serde_yaml = { workspace = true }
tera = { workspace = true }
tokio = { workspace = true }
toml = { workspace = true }
Expand Down
148 changes: 129 additions & 19 deletions crates/pxe/src/routes/cloud_init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,6 @@ fn user_data_handler(
);
}
context.insert("interface_id".to_string(), machine_interface_id.to_string());
// Use URL overrides for external clients (static-assignments segment),
// falling back to global config.
context.insert(
"api_url".to_string(),
api_url_override.unwrap_or(config.client_facing_api_url),
Expand Down Expand Up @@ -259,10 +257,6 @@ pub async fn user_data(machine: Machine, state: State<AppState>) -> impl IntoRes
),
}
}
// discovery_instructions can not be None for a non-assigned machine.
// This means that the machine is assigned to tenant.
// custom_cloud_init None means user has not configured any user-data. Send a empty
// response.
(None, None) => {
let mut template_data: HashMap<String, String> = HashMap::new();
template_data.insert("user_data".to_string(), "{}".to_string());
Expand Down Expand Up @@ -301,6 +295,55 @@ pub async fn meta_data(machine: Machine, state: State<AppState>) -> impl IntoRes
axum_template::Render(template_key, state.engine.clone(), template_data)
}

/// Extracts the top-level `network:` key (if present) from a tenant's
/// custom cloud-init document and returns it as its own standalone YAML
/// document, suitable for seeding NoCloud's separate `network-config`
/// file. A `network:` key inside `user-data` itself is not a recognized
/// user-data format and is silently ignored by cloud-init.
fn extract_network_config(custom_cloud_init: &str) -> Option<String> {
let value: serde_yaml::Value = serde_yaml::from_str(custom_cloud_init).ok()?;
serde_yaml::to_string(value.get("network")?).ok()
}

/// Default network-config served when a tenant hasn't provided a custom
/// `network:` key in their cloud-init userdata. cloud-init's own default
/// behavior (no network-config at all) only DHCPs the first network
/// interface it finds; this instead DHCPs every matching interface, under
/// both the predictable ("en*") and legacy ("eth*") naming conventions,
/// so multi-NIC hosts come up with working networking on every port.
const DEFAULT_NETWORK_CONFIG: &str = "version: 2\nethernets:\n predictable-names:\n match:\n name: \"en*\"\n dhcp4: true\n dhcp6: true\n legacy-names:\n match:\n name: \"eth*\"\n dhcp4: true\n dhcp6: true\n";

/// Serves NoCloud's `network-config` document for a tenant's assigned
/// machine, extracted from any `network:` key present in their custom
/// cloud-init userdata. Renders empty when no such key is present, which
/// cloud-init treats as "no custom network config" and falls back to
/// its default DHCP behavior.
fn resolve_network_config(custom_cloud_init: Option<&str>) -> String {
custom_cloud_init
.and_then(extract_network_config)
.unwrap_or_else(|| DEFAULT_NETWORK_CONFIG.to_string())
}

/// Serves NoCloud's `network-config` document for a tenant's assigned
/// machine, extracted from any `network:` key present in their custom
/// cloud-init userdata. When no such key is present, serves
/// DEFAULT_NETWORK_CONFIG instead of an empty document, so hosts get
/// DHCP on every interface by default rather than cloud-init's own
/// first-interface-only behavior.
pub async fn network_config(machine: Machine, state: State<AppState>) -> impl IntoResponse {
let network_config_yaml =
resolve_network_config(machine.instructions.custom_cloud_init.as_deref());

let mut template_data: HashMap<String, String> = HashMap::new();
template_data.insert("network_config".to_string(), network_config_yaml);

axum_template::Render(
"network-config".to_string(),
state.engine.clone(),
template_data,
)
}

pub async fn vendor_data(state: State<AppState>) -> impl IntoResponse {
emit(PxeBootOutcome {
endpoint: BootEndpoint::CloudInit,
Expand All @@ -313,6 +356,9 @@ pub async fn vendor_data(state: State<AppState>) -> impl IntoResponse {
)
}

/// Builds the PXE service's route table for the cloud-init-related
/// endpoints served under `path_prefix`: `user-data`, `meta-data`,
/// `vendor-data`, and `network-config`.
pub fn get_router(path_prefix: &str) -> Router<AppState> {
Router::new()
.route(
Expand All @@ -327,6 +373,10 @@ pub fn get_router(path_prefix: &str) -> Router<AppState> {
format!("{}/{}", path_prefix, "vendor-data").as_str(),
get(vendor_data),
)
.route(
format!("{}/{}", path_prefix, "network-config").as_str(),
get(network_config),
)
}

#[cfg(test)]
Expand All @@ -345,12 +395,6 @@ mod tests {
let interface_id = "91609f10-c91d-470d-a260-6293ea0c1234".parse().unwrap();
let config = generate_forge_agent_config(interface_id, None);

// The intent here is to actually test what the written
// configuration file looks like, so we can visualize to
// make sure it's going to look like what we think it's
// supposed to look like. Obviously as various new fields
// get added to AgentConfig, then our test config will also
// need to be updated accordingly, but that should be ok.
let test_config = fs::read_to_string(format!("{TEST_DATA_DIR}/agent_config.toml")).unwrap();
assert_eq!(config, test_config);

Expand All @@ -366,11 +410,8 @@ mod tests {
interface_id.to_string().as_str(),
);

// No forge-system section when no override is provided.
assert!(data.get("forge-system").is_none());

// Check to make sure is_fake_dpu gets skipped
// from the serialized output.
let skipped = match data.get("machine").unwrap().get("is_fake_dpu") {
Some(_val) => false,
None => true,
Expand Down Expand Up @@ -416,7 +457,6 @@ mod tests {
let template_glob = concat!(env!("CARGO_MANIFEST_DIR"), "/../../pxe/templates/**/*");
let tera = tera::Tera::new(template_glob).unwrap();

// Use the same string-valued context shape the route handler passes to Tera.
let context = HashMap::from([
(
"api_url".to_string(),
Expand Down Expand Up @@ -455,7 +495,6 @@ mod tests {
)
.unwrap();

// The mlxconfig value and DHCP drop rules should use the configured count.
assert!(rendered.contains("NUM_OF_VFS=3"));
assert!(!rendered.contains("NUM_OF_VFS=16"));
assert_eq!(rendered.matches("--physdev-in pf0vf").count(), 3);
Expand All @@ -471,7 +510,6 @@ mod tests {
let template_glob = concat!(env!("CARGO_MANIFEST_DIR"), "/../../pxe/templates/**/*");
let tera = tera::Tera::new(template_glob).unwrap();

// Use a non-empty provisioning string so the host representor bridge loop renders.
let context = HashMap::from([
(
"api_url".to_string(),
Expand Down Expand Up @@ -510,7 +548,6 @@ mod tests {
)
.unwrap();

// The loop should emit one assignment and invocation per bridge entry.
assert!(rendered.contains("ovs-vsctl get bridge br-sfc external_ids"));
assert!(rendered.contains("ovs-vsctl --may-exist add-port br-sfc"));
assert!(rendered.contains(
Expand Down Expand Up @@ -610,6 +647,79 @@ mod tests {
);
}

/// Table-driven coverage for `extract_network_config` across its three
/// input variants: a present `network:` key, a missing one, and
/// malformed YAML.
#[test]
fn extract_network_config_handles_various_inputs() {
struct Case {
name: &'static str,
input: &'static str,
expect_some: bool,
}

let cases = [
Case {
name: "network key present",
input: "#cloud-config\nnetwork:\n version: 2\n ethernets:\n eth0:\n addresses:\n - 10.10.10.50/24\nwrite_files:\n - path: /tmp/foo\n content: bar\n",
expect_some: true,
},
Case {
name: "no network key",
input: "#cloud-config\nwrite_files:\n - path: /tmp/foo\n content: bar\n",
expect_some: false,
},
Case {
name: "invalid yaml",
input: "not: valid: yaml: at: all: :::",
expect_some: false,
},
];

for case in cases {
let result = extract_network_config(case.input);
assert_eq!(
result.is_some(),
case.expect_some,
"case '{}' failed",
case.name
);

if case.expect_some {
let parsed: serde_yaml::Value = serde_yaml::from_str(&result.unwrap()).unwrap();
assert_eq!(parsed.get("version").unwrap().as_u64().unwrap(), 2);
assert!(
parsed
.get("ethernets")
.and_then(|e| e.get("eth0"))
.is_some(),
"case '{}': expected eth0 config present",
case.name
);
}
}
}

#[test]
fn resolve_network_config_uses_default_when_no_custom_key() {
let result = resolve_network_config(Some("#cloud-config\nwrite_files: []\n"));
assert_eq!(result, DEFAULT_NETWORK_CONFIG);
}

#[test]
fn resolve_network_config_uses_custom_key_when_present() {
let custom = "#cloud-config\nnetwork:\n version: 2\n ethernets:\n eth0:\n addresses:\n - 10.10.10.50/24\n";
let result = resolve_network_config(Some(custom));
assert_ne!(result, DEFAULT_NETWORK_CONFIG);
assert!(result.contains("10.10.10.50"));
}

#[test]
fn resolve_network_config_uses_default_when_no_custom_cloud_init_at_all() {
let result = resolve_network_config(None);
assert_eq!(result, DEFAULT_NETWORK_CONFIG);
}

/// A meta-data request with no metadata lands in the generic-error
/// funnel, which serves the error template and moves the outcome
/// counter.
Expand Down
1 change: 1 addition & 0 deletions pxe/common_files/disk_imaging.sh
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ function add_cloud_init() {
mkdir -p "$seed_dir"
curl --fail --retry 5 --retry-all-errors -k "$cloud_init_url/user-data" --output "$seed_dir/user-data" 2>&1 | tee "$log_output"
curl --fail --retry 5 --retry-all-errors -k "$cloud_init_url/meta-data" --output "$seed_dir/meta-data" 2>&1 | tee "$log_output"
curl --fail --retry 5 --retry-all-errors -k "$cloud_init_url/network-config" --output "$seed_dir/network-config" 2>&1 | tee "$log_output"
}

function expand_root_fs() {
Expand Down
1 change: 1 addition & 0 deletions pxe/templates/network-config
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{{ network_config }}