diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 898c4cb..983eae3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -42,9 +42,6 @@ jobs: - name: Run unit tests run: just unit - - name: Pull test images - run: just pull-test-images - - name: Create nextest archive run: | cargo nextest archive --release -p integration-tests --archive-file nextest-archive.tar.zst @@ -95,16 +92,16 @@ jobs: with: libvirt: 'true' - - name: Extract image lists from Justfile - run: | - echo "PRIMARY_IMAGE=$(just --evaluate PRIMARY_IMAGE)" >> $GITHUB_ENV - echo "ALL_BASE_IMAGES=$(just --evaluate ALL_BASE_IMAGES)" >> $GITHUB_ENV + - name: Install additional dependencies + run: sudo apt install -y go-md2man dosfstools mtools - name: Setup Rust uses: ./.github/actions/setup-rust - - name: Pull test images - run: just pull-test-images + - name: Extract image lists from Justfile + run: | + echo "PRIMARY_IMAGE=$(just --evaluate PRIMARY_IMAGE)" >> $GITHUB_ENV + echo "ALL_BASE_IMAGES=$(just --evaluate ALL_BASE_IMAGES)" >> $GITHUB_ENV - name: Download nextest archive uses: actions/download-artifact@v4 @@ -121,17 +118,7 @@ jobs: run: chmod +x target/release/bcvk - name: Run integration tests (partition ${{ matrix.partition }}/4) - run: | - # Clean up any leftover containers before starting - cargo run --release --bin test-cleanup -p integration-tests 2>/dev/null || true - - # Run the partitioned tests - cargo nextest run --archive-file nextest-archive.tar.zst \ - --profile integration \ - --partition hash:${{ matrix.partition }}/4 - - # Clean up containers after tests complete - cargo run --release --bin test-cleanup -p integration-tests 2>/dev/null || true + run: just test-integration-partition nextest-archive.tar.zst ${{ matrix.partition }} env: BCVK_PATH: ${{ github.workspace }}/target/release/bcvk BCVK_PRIMARY_IMAGE: ${{ env.PRIMARY_IMAGE }} diff --git a/Justfile b/Justfile index 72a534b..f1496bf 100644 --- a/Justfile +++ b/Justfile @@ -22,8 +22,16 @@ unit *ARGS: pull-test-images: podman pull -q {{ALL_BASE_IMAGES}} >/dev/null +# Build cloud-init test image +build-cloud-init-image: + #!/usr/bin/env bash + set -euo pipefail + echo "Building cloud-init test image..." + podman build -t localhost/bootc-cloud-init tests/fixtures/cloud-init/ + echo "✓ Cloud-init test image built: localhost/bootc-cloud-init" + # Run integration tests (auto-detects nextest, with cleanup) -test-integration *ARGS: build pull-test-images +test-integration *ARGS: build pull-test-images build-cloud-init-image #!/usr/bin/env bash set -euo pipefail export BCVK_PATH=$(pwd)/target/release/bcvk @@ -48,6 +56,25 @@ test-integration *ARGS: build pull-test-images exit $TEST_EXIT_CODE +# Run integration tests from a partition (used by CI) +test-integration-partition ARCHIVE PARTITION: pull-test-images build-cloud-init-image + #!/usr/bin/env bash + set -euo pipefail + + # Clean up any leftover containers before starting + cargo run --release --bin test-cleanup -p integration-tests 2>/dev/null || true + + # Run the partitioned tests + cargo nextest run --archive-file {{ARCHIVE}} \ + --profile integration \ + --partition hash:{{ PARTITION }}/4 + TEST_EXIT_CODE=$? + + # Clean up containers after tests complete + cargo run --release --bin test-cleanup -p integration-tests 2>/dev/null || true + + exit $TEST_EXIT_CODE + # Clean up integration test containers test-cleanup: cargo run --release --bin test-cleanup -p integration-tests diff --git a/README.md b/README.md index 18d33a5..db76d96 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,34 @@ disk images that can be imported into other virtualization frameworks. See [docs/src/installation.md](./docs/src/installation.md). +## Dependencies + +bcvk requires the following runtime dependencies: + +### Core virtualization +- **QEMU** - The core virtualization engine +- **virtiofsd** - VirtIO filesystem daemon for sharing directories with VMs +- **podman** - Container runtime for managing bootc images + +### For libvirt integration +- **libvirt** - Virtualization management for persistent VMs + +### For cloud-init support +- **dosfstools** - Provides `mkfs.vfat` for creating VFAT filesystems +- **mtools** - Provides `mcopy` for populating VFAT images (used for cloud-init ConfigDrive) + +### Package installation + +**Debian/Ubuntu:** +```bash +sudo apt install qemu-kvm qemu-system qemu-utils virtiofsd podman libvirt-daemon libvirt-clients dosfstools mtools +``` + +**Fedora/RHEL:** +```bash +sudo dnf install qemu-kvm qemu-img virtiofsd podman libvirt libvirt-client dosfstools mtools +``` + ## Quick Start ### Running a bootc container as ephemeral VM diff --git a/crates/integration-tests/src/main.rs b/crates/integration-tests/src/main.rs index 9c71461..490eb28 100644 --- a/crates/integration-tests/src/main.rs +++ b/crates/integration-tests/src/main.rs @@ -16,6 +16,7 @@ pub(crate) use integration_tests::{ }; mod tests { + pub mod cloud_init; pub mod libvirt_base_disks; pub mod libvirt_port_forward; pub mod libvirt_upload_disk; diff --git a/crates/integration-tests/src/tests/cloud_init.rs b/crates/integration-tests/src/tests/cloud_init.rs new file mode 100644 index 0000000..7051d49 --- /dev/null +++ b/crates/integration-tests/src/tests/cloud_init.rs @@ -0,0 +1,269 @@ +//! Integration tests for cloud-init ConfigDrive functionality +//! +//! These tests verify: +//! - ConfigDrive generation from user-provided cloud-config files +//! - ConfigDrive device creation and accessibility +//! - ConfigDrive content structure (OpenStack format) +//! - Kernel cmdline does NOT contain `ds=iid-datasource-none` when using ConfigDrive +//! - Cloud-init processing of the ConfigDrive (using localhost/bootc-cloud-init image) + +use color_eyre::eyre::Context as _; +use color_eyre::Result; +use integration_tests::integration_test; + +use crate::{run_bcvk, INTEGRATION_TEST_LABEL}; + +/// Get the cloud-init test image (built from tests/fixtures/cloud-init/) +fn get_cloud_init_test_image() -> String { + std::env::var("BCVK_CLOUD_INIT_TEST_IMAGE") + .unwrap_or_else(|_| "localhost/bootc-cloud-init".to_string()) +} + +/// Test basic cloud-init ConfigDrive functionality +/// +/// Creates a cloud-config file, runs an ephemeral VM with --cloud-init, +/// and verifies that: +/// - The ConfigDrive device exists at /dev/disk/by-id/virtio-config-2 +/// - The ConfigDrive can be mounted and contains expected OpenStack structure +/// - The user_data file contains the cloud-config content +/// - The meta_data.json contains the instance-id +fn test_cloud_init_configdrive_basic() -> Result<()> { + let test_image = get_cloud_init_test_image(); + + println!("Testing basic cloud-init ConfigDrive functionality"); + + // Create a temporary cloud-config file + let cloud_config_dir = tempfile::tempdir().context("Failed to create temp directory")?; + let cloud_config_path = cloud_config_dir + .path() + .join("cloud-config.yaml") + .to_str() + .ok_or_else(|| color_eyre::eyre::eyre!("Invalid UTF-8 in temp path"))? + .to_string(); + + // Create a simple cloud-config with identifiable content + let cloud_config_content = r#"#cloud-config +write_files: + - path: /tmp/test-marker + content: | + ConfigDrive test content + permissions: '0644' + +runcmd: + - echo "Test command from cloud-config" +"#; + + std::fs::write(&cloud_config_path, cloud_config_content) + .context("Failed to write cloud-config file")?; + + println!("Created cloud-config file at: {}", cloud_config_path); + + // Run ephemeral VM and verify ConfigDrive structure + println!("Running ephemeral VM with --cloud-init..."); + let output = run_bcvk(&[ + "ephemeral", + "run", + "--rm", + "--label", + INTEGRATION_TEST_LABEL, + "--cloud-init", + &cloud_config_path, + "--execute", + "/bin/sh -c 'ls -la /dev/disk/by-id/virtio-config-2 && mkdir -p /mnt/configdrive && mount /dev/disk/by-id/virtio-config-2 /mnt/configdrive && ls -la /mnt/configdrive/ && cat /mnt/configdrive/openstack/latest/user_data && cat /mnt/configdrive/openstack/latest/meta_data.json'", + &test_image, + ])?; + + println!("VM execution completed"); + + // Check the output + println!("=== STDOUT ==="); + println!("{}", output.stdout); + println!("=== STDERR ==="); + println!("{}", output.stderr); + + let combined_output = format!("{}\n{}", output.stdout, output.stderr); + + // Verify ConfigDrive device symlink exists + assert!( + combined_output.contains("virtio-config-2"), + "ConfigDrive device symlink 'virtio-config-2' not found in output. Output: {}", + combined_output + ); + + // Verify user_data contains the cloud-config header + assert!( + combined_output.contains("#cloud-config"), + "user_data does not contain #cloud-config header. Output: {}", + combined_output + ); + + // Verify user_data contains our test content + assert!( + combined_output.contains("ConfigDrive test content"), + "user_data does not contain expected test content. Output: {}", + combined_output + ); + + // Verify meta_data.json contains uuid (which cloud-init maps to instance-id) + assert!( + combined_output.contains("uuid"), + "meta_data.json does not contain uuid. Output: {}", + combined_output + ); + + // Also verify it contains the expected uuid value + assert!( + combined_output.contains("iid-local01"), + "meta_data.json does not contain expected uuid value 'iid-local01'. Output: {}", + combined_output + ); + + println!("✓ Basic cloud-init ConfigDrive test passed"); + output.assert_success("ephemeral run with cloud-init"); + Ok(()) +} +integration_test!(test_cloud_init_configdrive_basic); + +/// Test that kernel cmdline does NOT contain `ds=iid-datasource-none` when using ConfigDrive +/// +/// When a ConfigDrive is provided, the kernel cmdline should NOT contain the +/// `ds=iid-datasource-none` parameter which would disable cloud-init. +/// This test verifies the cmdline directly without depending on cloud-init. +fn test_cloud_init_no_datasource_cmdline() -> Result<()> { + let test_image = get_cloud_init_test_image(); + + println!("Testing kernel cmdline does NOT contain ds=iid-datasource-none with ConfigDrive"); + + // Create a temporary cloud-config file + let cloud_config_dir = tempfile::tempdir().context("Failed to create temp directory")?; + let cloud_config_path = cloud_config_dir + .path() + .join("cloud-config.yaml") + .to_str() + .ok_or_else(|| color_eyre::eyre::eyre!("Invalid UTF-8 in temp path"))? + .to_string(); + + // Create a minimal cloud-config + let cloud_config_content = r#"#cloud-config +runcmd: + - echo "test" +"#; + + std::fs::write(&cloud_config_path, cloud_config_content) + .context("Failed to write cloud-config file")?; + + println!("Created cloud-config file"); + + // Run ephemeral VM and check /proc/cmdline directly + println!("Running ephemeral VM to check kernel cmdline..."); + let output = run_bcvk(&[ + "ephemeral", + "run", + "--rm", + "--label", + INTEGRATION_TEST_LABEL, + "--cloud-init", + &cloud_config_path, + "--execute", + "cat /proc/cmdline", + &test_image, + ])?; + + println!("VM execution completed"); + println!("=== Output ==="); + println!("{}", output.stdout); + + // Get the kernel cmdline from the output + let combined_output = format!("{}\n{}", output.stdout, output.stderr); + + // Verify that ds=iid-datasource-none is NOT present in the cmdline + assert!( + !combined_output.contains("ds=iid-datasource-none"), + "Kernel cmdline should NOT contain 'ds=iid-datasource-none' when using ConfigDrive.\nOutput: {}", + combined_output + ); + + println!("✓ Kernel cmdline does NOT contain ds=iid-datasource-none"); + output.assert_success("ephemeral run with cloud-init"); + Ok(()) +} +integration_test!(test_cloud_init_no_datasource_cmdline); + +/// Test that ConfigDrive contains expected user_data content +/// +/// Creates a cloud-config with multiple runcmd directives, +/// then verifies the ConfigDrive user_data contains all expected content. +/// This test does NOT depend on cloud-init being installed - it directly +/// inspects the ConfigDrive contents. +fn test_cloud_init_configdrive_content() -> Result<()> { + let test_image = get_cloud_init_test_image(); + + println!("Testing ConfigDrive content verification"); + + // Create a temporary cloud-config file + let cloud_config_dir = tempfile::tempdir().context("Failed to create temp directory")?; + let cloud_config_path = cloud_config_dir + .path() + .join("cloud-config.yaml") + .to_str() + .ok_or_else(|| color_eyre::eyre::eyre!("Invalid UTF-8 in temp path"))? + .to_string(); + + // Create a cloud-config with multiple runcmd directives + let cloud_config_content = r#"#cloud-config +runcmd: + - echo "RUNCMD_TEST_1_SUCCESS" + - echo "RUNCMD_TEST_2_SUCCESS" + - echo "RUNCMD_TEST_3_SUCCESS" +"#; + + std::fs::write(&cloud_config_path, cloud_config_content) + .context("Failed to write cloud-config file")?; + + println!("Created cloud-config with runcmd directives"); + + // Run ephemeral VM and verify ConfigDrive user_data content + println!("Running ephemeral VM to verify ConfigDrive content..."); + let output = run_bcvk(&[ + "ephemeral", + "run", + "--rm", + "--label", + INTEGRATION_TEST_LABEL, + "--cloud-init", + &cloud_config_path, + "--execute", + "/bin/sh -c 'mkdir -p /mnt && mount /dev/disk/by-id/virtio-config-2 /mnt && cat /mnt/openstack/latest/user_data'", + &test_image, + ])?; + + println!("VM execution completed"); + println!("=== Output ==="); + println!("{}", output.stdout); + + // Verify user_data contains all runcmd directives + let combined_output = format!("{}\n{}", output.stdout, output.stderr); + + assert!( + combined_output.contains("RUNCMD_TEST_1_SUCCESS"), + "First runcmd directive not found in user_data. Output: {}", + combined_output + ); + + assert!( + combined_output.contains("RUNCMD_TEST_2_SUCCESS"), + "Second runcmd directive not found in user_data. Output: {}", + combined_output + ); + + assert!( + combined_output.contains("RUNCMD_TEST_3_SUCCESS"), + "Third runcmd directive not found in user_data. Output: {}", + combined_output + ); + + println!("✓ All expected content found in ConfigDrive user_data"); + output.assert_success("ephemeral run with cloud-init configdrive content"); + Ok(()) +} +integration_test!(test_cloud_init_configdrive_content); diff --git a/crates/integration-tests/src/tests/internals_bollard.rs b/crates/integration-tests/src/tests/internals_bollard.rs new file mode 100644 index 0000000..846d9fd --- /dev/null +++ b/crates/integration-tests/src/tests/internals_bollard.rs @@ -0,0 +1,114 @@ +//! Integration tests for bollard-based podman API +//! +//! ⚠️ **CRITICAL INTEGRATION TEST POLICY** ⚠️ +//! +//! INTEGRATION TESTS MUST NEVER "warn and continue" ON FAILURES! +//! +//! If something is not working: +//! - Use `todo!("reason why this doesn't work yet")` +//! - Use `panic!("clear error message")` +//! - Use `assert!()` and `unwrap()` to fail hard +//! +//! NEVER use patterns like: +//! - "Note: test failed - likely due to..." +//! - "This is acceptable in CI/testing environments" +//! - Warning and continuing on failures + +use color_eyre::Result; +use integration_tests::integration_test; + +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::{get_bck_command, INTEGRATION_TEST_LABEL}; + +fn test_bollard_container_removal() -> Result<()> { + // Generate unique container name + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + let container_name = format!("bcvk-bollard-test-{}", timestamp); + + // Create a test container with the integration test label + let create_output = Command::new("podman") + .args([ + "run", + "-d", + "--name", + &container_name, + "--label", + INTEGRATION_TEST_LABEL, + "docker.io/library/alpine:latest", + "sleep", + "300", + ]) + .output() + .expect("Failed to create test container"); + + assert!( + create_output.status.success(), + "Failed to create test container: {}", + String::from_utf8_lossy(&create_output.stderr) + ); + + // Verify container was created + let verify_output = Command::new("podman") + .args(["ps", "-a", "--filter", &format!("name={}", container_name)]) + .output() + .expect("Failed to verify container creation"); + + assert!( + verify_output.status.success(), + "Failed to verify container creation: {}", + String::from_utf8_lossy(&verify_output.stderr) + ); + + let ps_output = String::from_utf8_lossy(&verify_output.stdout); + assert!( + ps_output.contains(&container_name), + "Container {} not found in podman ps output", + container_name + ); + + // Use bollard to remove the container via the CLI + let bcvk_path = get_bck_command().expect("Failed to get bcvk command"); + let remove_output = Command::new(&bcvk_path) + .args([ + "internals", + "bollard", + "remove-container", + "--force", + &container_name, + ]) + .output() + .expect("Failed to run bcvk internals bollard remove-container"); + + assert!( + remove_output.status.success(), + "Failed to remove container via bollard: {}", + String::from_utf8_lossy(&remove_output.stderr) + ); + + // Verify container was removed + let verify_removal = Command::new("podman") + .args(["ps", "-a", "--filter", &format!("name={}", container_name)]) + .output() + .expect("Failed to verify container removal"); + + assert!( + verify_removal.status.success(), + "Failed to verify container removal: {}", + String::from_utf8_lossy(&verify_removal.stderr) + ); + + let removal_output = String::from_utf8_lossy(&verify_removal.stdout); + assert!( + !removal_output.contains(&container_name), + "Container {} still exists after removal", + container_name + ); + + Ok(()) +} +integration_test!(test_bollard_container_removal); diff --git a/crates/kit/src/cloud_init.rs b/crates/kit/src/cloud_init.rs new file mode 100644 index 0000000..566f552 --- /dev/null +++ b/crates/kit/src/cloud_init.rs @@ -0,0 +1,412 @@ +//! Cloud-init ConfigDrive generation for VM configuration. +//! +//! Creates cloud-init ConfigDrive VFAT filesystems that can be attached to VMs to provide +//! initial configuration. The ConfigDrive follows the OpenStack ConfigDrive v2 format. +//! +//! This implementation uses the same approach as systemd-repart for populating VFAT filesystems: +//! - `mkfs.vfat` to create the VFAT filesystem +//! - `mcopy` (from mtools) to populate files into the VFAT image + +use camino::{Utf8Path, Utf8PathBuf}; +use color_eyre::eyre::{eyre, Context as _}; +use color_eyre::Result; +use serde::{Deserialize, Serialize}; +use std::fs::{self, File}; +use std::io::Write; +use std::process::Command; +use tracing::debug; + +/// Volume label used by cloud-init for ConfigDrive datasource +/// Using uppercase as cloud-init documentation specifies "CONFIG-2" or "config-2" +const CONFIG_DRIVE_LABEL: &str = "CONFIG-2"; + +/// Cloud-init configuration for generating ConfigDrive images. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CloudInitConfig { + /// Hostname to set in the guest + pub hostname: Option, + /// Custom user-data YAML content (will be merged with generated config) + pub user_data_yaml: Option, +} + +impl CloudInitConfig { + /// Create a new cloud-init configuration builder + pub fn new() -> Self { + Self::default() + } + + /// Set custom user-data YAML content + pub fn with_user_data(mut self, user_data: String) -> Self { + self.user_data_yaml = Some(user_data); + self + } + + /// Generate a VFAT filesystem with ConfigDrive format at the specified path. + /// + /// Creates a VFAT filesystem image containing cloud-init data in the ConfigDrive + /// format expected by OpenStack and other cloud environments. The filesystem has + /// the label "config-2" and contains the directory structure: + /// + /// ```text + /// openstack/ + /// latest/ + /// meta_data.json + /// user_data + /// ``` + /// + /// This uses `mkfs.vfat` which is more commonly available than `genisoimage`. + pub fn generate_vfat_configdrive(&self, output_path: impl AsRef) -> Result<()> { + let output_path = output_path.as_ref(); + + // Create temporary directory for ConfigDrive structure + let temp_dir = tempfile::tempdir() + .context("Failed to create temporary directory for ConfigDrive files")?; + let temp_path = Utf8PathBuf::try_from(temp_dir.path().to_path_buf()) + .context("Invalid UTF-8 in temp directory path")?; + + debug!( + "Creating ConfigDrive structure in temporary directory: {}", + temp_path + ); + + // Create openstack/latest directory structure + let openstack_dir = temp_path.join("openstack").join("latest"); + fs::create_dir_all(&openstack_dir).with_context(|| { + format!( + "Failed to create openstack/latest directory at {}", + openstack_dir + ) + })?; + + // Write meta_data.json + self.write_configdrive_metadata(&openstack_dir)?; + + // Write user_data + self.write_configdrive_userdata(&openstack_dir)?; + + // Create VFAT filesystem image + self.create_vfat_image(&temp_path, output_path)?; + + debug!( + "ConfigDrive VFAT image created successfully at: {}", + output_path + ); + + Ok(()) + } + + /// Write meta_data.json for ConfigDrive format + fn write_configdrive_metadata(&self, openstack_dir: &Utf8Path) -> Result<()> { + let meta_data_path = openstack_dir.join("meta_data.json"); + + // Create metadata JSON structure following OpenStack schema + // IMPORTANT: cloud-init expects specific field names (see KEY_COPIES in + // cloudinit/sources/helpers/openstack.py): + // - "uuid" (required) -> cloud-init copies to "instance-id" + // - "hostname" (optional) -> cloud-init copies to "local-hostname" + let mut meta_obj = serde_json::Map::new(); + meta_obj.insert( + "uuid".to_string(), + serde_json::Value::String("iid-local01".to_string()), + ); + + if let Some(ref hostname) = self.hostname { + meta_obj.insert( + "hostname".to_string(), + serde_json::Value::String(hostname.clone()), + ); + } + + let meta_json = serde_json::to_string_pretty(&meta_obj) + .context("Failed to serialize meta_data.json")?; + + fs::write(&meta_data_path, meta_json) + .with_context(|| format!("Failed to write meta_data.json to {}", meta_data_path))?; + + debug!("Wrote meta_data.json file: {}", meta_data_path); + Ok(()) + } + + /// Write user_data for ConfigDrive format + fn write_configdrive_userdata(&self, openstack_dir: &Utf8Path) -> Result<()> { + let user_data_path = openstack_dir.join("user_data"); + + let mut user_data = if let Some(ref custom_yaml) = self.user_data_yaml { + // Parse existing YAML and merge + serde_yaml::from_str::(custom_yaml) + .context("Failed to parse custom user-data YAML")? + } else { + serde_yaml::Value::Mapping(serde_yaml::Mapping::new()) + }; + + // Ensure we have a mapping + let user_data_map = user_data + .as_mapping_mut() + .ok_or_else(|| eyre!("user-data must be a YAML mapping/object"))?; + + // Ensure we don't have conflicting SSH key configuration + if user_data_map.contains_key("ssh_authorized_keys") { + debug!("User provided ssh_authorized_keys in custom YAML"); + } + + // Write user_data file with #cloud-config shebang + let mut file = File::create(&user_data_path) + .with_context(|| format!("Failed to create user_data file at {}", user_data_path))?; + + // Write shebang + file.write_all(b"#cloud-config\n") + .context("Failed to write cloud-config shebang")?; + + // Write YAML content + serde_yaml::to_writer(&file, &user_data) + .context("Failed to write user_data YAML content")?; + + debug!("Wrote user_data file: {}", user_data_path); + Ok(()) + } + + /// Create VFAT filesystem image using mkfs.vfat and mtools + fn create_vfat_image(&self, source_dir: &Utf8Path, output_path: &Utf8Path) -> Result<()> { + // Check if mkfs.vfat is available + if which::which("mkfs.vfat").is_err() { + return Err(eyre!( + "mkfs.vfat not found. Please install dosfstools package:\n\ + - Fedora/RHEL: sudo dnf install dosfstools\n\ + - Debian/Ubuntu: sudo apt install dosfstools" + )); + } + + // Check if mtools (mcopy, mmd) is available + if which::which("mcopy").is_err() { + return Err(eyre!( + "mcopy not found. Please install mtools package:\n\ + - Fedora/RHEL: sudo dnf install mtools\n\ + - Debian/Ubuntu: sudo apt install mtools" + )); + } + + // Create a 10MB VFAT image (sufficient for cloud-init data) + let image_size_mb = 10; + debug!( + "Creating {}MB VFAT image at: {}", + image_size_mb, output_path + ); + + // Create sparse file + let create_status = Command::new("dd") + .args([ + "if=/dev/zero", + &format!("of={}", output_path), + "bs=1M", + &format!("count={}", image_size_mb), + "status=none", + ]) + .status() + .context("Failed to execute dd command to create image file")?; + + if !create_status.success() { + return Err(eyre!( + "Failed to create image file with dd (exit code: {})", + create_status.code().unwrap_or(-1) + )); + } + + // Format as VFAT with the config-2 label + let mkfs_output = Command::new("mkfs.vfat") + .args(["-n", CONFIG_DRIVE_LABEL, output_path.as_str()]) + .output() + .context("Failed to execute mkfs.vfat")?; + + if !mkfs_output.status.success() { + let stderr = String::from_utf8_lossy(&mkfs_output.stderr); + return Err(eyre!( + "Failed to format VFAT filesystem (exit code: {}): {}", + mkfs_output.status.code().unwrap_or(-1), + stderr + )); + } + + debug!( + "Formatted VFAT filesystem with label: {}", + CONFIG_DRIVE_LABEL + ); + + // Use mtools to copy files into the VFAT image without mounting + // First, create the openstack/latest directory structure + let mmd_output = Command::new("mmd") + .args([ + "-i", + output_path.as_str(), + "::openstack", + "::openstack/latest", + ]) + .output() + .context("Failed to execute mmd command")?; + + if !mmd_output.status.success() { + let stderr = String::from_utf8_lossy(&mmd_output.stderr); + return Err(eyre!( + "Failed to create openstack directory in VFAT image: {}", + stderr + )); + } + + debug!("Created openstack/latest directory structure in VFAT image"); + + // Copy meta_data.json + let meta_data_src = source_dir.join("openstack/latest/meta_data.json"); + let mcopy_meta_output = Command::new("mcopy") + .args([ + "-i", + output_path.as_str(), + meta_data_src.as_str(), + "::openstack/latest/meta_data.json", + ]) + .output() + .context("Failed to execute mcopy for meta_data.json")?; + + if !mcopy_meta_output.status.success() { + let stderr = String::from_utf8_lossy(&mcopy_meta_output.stderr); + return Err(eyre!( + "Failed to copy meta_data.json to VFAT image: {}", + stderr + )); + } + + debug!("Copied meta_data.json to VFAT image"); + + // Copy user_data + let user_data_src = source_dir.join("openstack/latest/user_data"); + let mcopy_user_output = Command::new("mcopy") + .args([ + "-i", + output_path.as_str(), + user_data_src.as_str(), + "::openstack/latest/user_data", + ]) + .output() + .context("Failed to execute mcopy for user_data")?; + + if !mcopy_user_output.status.success() { + let stderr = String::from_utf8_lossy(&mcopy_user_output.stderr); + return Err(eyre!("Failed to copy user_data to VFAT image: {}", stderr)); + } + + debug!("Copied user_data to VFAT image"); + + Ok(()) + } +} + +/// Generate a ConfigDrive VFAT filesystem from a user-provided cloud-config file. +/// +/// This is a convenience function that creates a ConfigDrive with the contents of the +/// specified cloud-config file. +/// +/// # Arguments +/// +/// * `user_data_path` - Path to a cloud-config YAML file +/// * `output_path` - Path where the ConfigDrive image will be created +/// +/// # Returns +/// +/// Returns the path to the created ConfigDrive image. +/// +/// # Errors +/// +/// Returns an error if: +/// - The user-data file cannot be read +/// - The user-data file is not valid YAML +/// - Required tools (mkfs.vfat, mtools) are not available +/// - ConfigDrive generation fails +pub fn generate_configdrive_from_file( + user_data_path: impl AsRef, + output_path: impl AsRef, +) -> Result { + let user_data_path = user_data_path.as_ref(); + let output_path = output_path.as_ref(); + + debug!( + "Generating ConfigDrive from user-data file: {}", + user_data_path + ); + + // Read the user-provided cloud-config file + let user_data_content = fs::read_to_string(user_data_path).with_context(|| { + format!( + "Failed to read cloud-config file at {}. Please ensure the file exists and is readable.", + user_data_path + ) + })?; + + // Validate it's valid YAML + let _: serde_yaml::Value = serde_yaml::from_str(&user_data_content).with_context(|| { + format!( + "Failed to parse cloud-config file at {} as YAML. Please ensure it's valid YAML format.", + user_data_path + ) + })?; + + // Create CloudInitConfig with the user-provided data + let config = CloudInitConfig::new().with_user_data(user_data_content); + + // Generate the VFAT ConfigDrive + config.generate_vfat_configdrive(output_path)?; + + debug!("ConfigDrive generated at: {}", output_path); + + Ok(output_path.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cloud_init_config_builder() { + let config = CloudInitConfig::new(); + assert_eq!(config.hostname, None); + assert_eq!(config.user_data_yaml, None); + } + + #[test] + fn test_meta_data_generation() -> Result<()> { + let temp_dir = tempfile::tempdir()?; + let temp_path = Utf8PathBuf::try_from(temp_dir.path().to_path_buf())?; + + let config = CloudInitConfig::new(); + config.write_configdrive_metadata(&temp_path)?; + + let meta_data_path = temp_path.join("meta_data.json"); + assert!(meta_data_path.exists()); + + let content = fs::read_to_string(&meta_data_path)?; + assert!(content.contains("uuid")); + assert!(content.contains("iid-local01")); + + Ok(()) + } + + #[test] + fn test_user_data_generation() -> Result<()> { + let temp_dir = tempfile::tempdir()?; + let temp_path = Utf8PathBuf::try_from(temp_dir.path().to_path_buf())?; + + let custom_yaml = r#" +runcmd: + - echo "Hello World" +"#; + + let config = CloudInitConfig::new().with_user_data(custom_yaml.to_string()); + config.write_configdrive_userdata(&temp_path)?; + + let user_data_path = temp_path.join("user_data"); + assert!(user_data_path.exists()); + + let content = fs::read_to_string(&user_data_path)?; + assert!(content.starts_with("#cloud-config")); + assert!(content.contains("Hello World")); + + Ok(()) + } +} diff --git a/crates/kit/src/main.rs b/crates/kit/src/main.rs index b630093..494a783 100644 --- a/crates/kit/src/main.rs +++ b/crates/kit/src/main.rs @@ -8,6 +8,7 @@ mod arch; mod boot_progress; mod cache_metadata; mod cli_json; +mod cloud_init; mod common_opts; mod container_entrypoint; mod credentials; diff --git a/crates/kit/src/run_ephemeral.rs b/crates/kit/src/run_ephemeral.rs index 28576c5..c26810e 100644 --- a/crates/kit/src/run_ephemeral.rs +++ b/crates/kit/src/run_ephemeral.rs @@ -278,6 +278,21 @@ pub struct RunEphemeralOpts { #[clap(long = "karg", help = "Additional kernel command line arguments")] pub kernel_args: Vec, + + #[clap( + long = "cloud-init", + value_name = "PATH", + help = "Path to cloud-config file (user-data) for cloud-init ConfigDrive", + conflicts_with = "cloud_init_empty" + )] + pub cloud_init: Option, + + #[clap( + long = "cloud-init-empty", + help = "Create an empty cloud-init ConfigDrive (no custom user-data)", + conflicts_with = "cloud_init" + )] + pub cloud_init_empty: bool, } /// Launch privileged container with QEMU+KVM for ephemeral VM, spawning as subprocess. @@ -490,6 +505,68 @@ fn prepare_run_command_with_temp( cmd.args(["-v", &format!("{}:/run/systemd-units:ro", units_dir)]); } + // Generate cloud-init ConfigDrive if provided + // This must happen on the host before entering the container, since we need + // mkfs.vfat and mtools which may not be available in the target bootc image. + // We create a uniquely-named file in the tempdir to avoid file locking conflicts when + // running tests in parallel. The file is cleaned up when the TempDir is dropped. + if let Some(ref cloud_init_file) = opts.cloud_init { + let cloud_init_path = std::path::Path::new(cloud_init_file.as_str()); + if !cloud_init_path.exists() { + return Err(color_eyre::eyre::eyre!( + "Cloud-init config file does not exist: {}", + cloud_init_file + )); + } + + debug!( + "Generating cloud-init ConfigDrive from: {}", + cloud_init_file + ); + + // Create a unique temporary file path in the tempdir + // We use a simple approach: generate a file in the existing tempdir + let configdrive_filename = format!("cloud-init-configdrive-{}.img", std::process::id()); + let configdrive_path = Utf8PathBuf::from(td_path).join(configdrive_filename); + + debug!("Creating ConfigDrive at: {}", configdrive_path); + + // Generate the ConfigDrive on the host (before entering container) + crate::cloud_init::generate_configdrive_from_file(cloud_init_file, &configdrive_path)?; + + debug!("Cloud-init ConfigDrive generated at: {}", configdrive_path); + + // Mount the ConfigDrive into the container at a well-known location + // Note: We mount it as rw (not ro) because QEMU needs write access for file locking + cmd.args([ + "-v", + &format!("{}:/run/cloud-init-configdrive.img:rw", configdrive_path), + ]); + } else if opts.cloud_init_empty { + debug!("Generating empty cloud-init ConfigDrive"); + + // Create a unique temporary file path in the tempdir + let configdrive_filename = format!("cloud-init-configdrive-{}.img", std::process::id()); + let configdrive_path = Utf8PathBuf::from(td_path).join(configdrive_filename); + + debug!("Creating empty ConfigDrive at: {}", configdrive_path); + + // Generate an empty ConfigDrive on the host (before entering container) + crate::cloud_init::CloudInitConfig::new().generate_vfat_configdrive(&configdrive_path)?; + + debug!( + "Empty cloud-init ConfigDrive generated at: {}", + configdrive_path + ); + + // Mount the ConfigDrive into the container at a well-known location + // Note: We mount it as rw (not ro) because QEMU needs write access for file locking + cmd.args([ + "-v", + &format!("{}:/run/cloud-init-configdrive.img:rw", configdrive_path), + ]); + } + // Pass configuration as JSON via BCK_CONFIG environment variable let config = serde_json::to_string(&opts).unwrap(); cmd.args(["-e", &format!("BCK_CONFIG={config}")]); @@ -522,6 +599,8 @@ fn prepare_run_command_with_temp( cmd.args([&opts.image, ENTRYPOINT]); + // The TempDir will keep all temporary files (including cloud-init configdrive) alive + // until it's dropped at the end of the container's lifetime Ok((cmd, td)) } @@ -1064,14 +1143,35 @@ StandardOutput=file:/dev/virtio-ports/executestatus let vsock_enabled = !vsock_force_disabled && qemu_config.enable_vsock().is_ok(); // Handle SSH key generation and credential injection + let mut cloud_init_disk_path = None; if opts.common.ssh_keygen { let key_pair = crate::ssh::generate_default_keypair()?; // Create credential and add to kernel args let pubkey = std::fs::read_to_string(key_pair.public_key_path.as_path())?; + + // Always use SMBIOS credential injection for SSH keys (not cloud-init) let credential = crate::credentials::smbios_cred_for_root_ssh(&pubkey)?; qemu_config.add_smbios_credential(credential); } + // Handle cloud-init ConfigDrive if user provided a cloud-config file or requested empty ConfigDrive + // The ConfigDrive was already generated on the host in prepare_run_command_with_temp, + // and mounted into the container at /run/cloud-init-configdrive.img + if opts.cloud_init.is_some() || opts.cloud_init_empty { + let configdrive_path = Utf8PathBuf::from("/run/cloud-init-configdrive.img"); + + if cloudinit { + debug!( + "Using pre-generated cloud-init ConfigDrive at: {}", + configdrive_path + ); + cloud_init_disk_path = Some(configdrive_path); + } else { + debug!("Warning: --cloud-init or --cloud-init-empty provided but target image does not have cloud-init installed. ConfigDrive will be attached but may not be used."); + cloud_init_disk_path = Some(configdrive_path); + } + } + // Build kernel command line for direct boot let mut kernel_cmdline = [ // At the core we boot from the mounted container's root, @@ -1094,10 +1194,8 @@ StandardOutput=file:/dev/virtio-ports/executestatus if opts.common.console { kernel_cmdline.push("console=hvc0".to_string()); } - if cloudinit { - // We don't provide any cloud-init datasource right now, - // though in the future it would make sense to do so, - // and switch over our SSH key injection. + if !cloudinit { + // If cloud-init is not installed, disable the datasource search kernel_cmdline.push("ds=iid-datasource-none".to_string()); } @@ -1250,6 +1348,16 @@ Options= ); } + // Attach cloud-init ConfigDrive if generated + if let Some(ref configdrive_path) = cloud_init_disk_path { + debug!("Attaching cloud-init ConfigDrive as virtio-blk device"); + qemu_config.add_virtio_blk_device_with_format( + configdrive_path.to_string(), + "config-2".to_string(), + crate::to_disk::Format::Raw, + ); + } + let status_writer_clone = StatusWriter::new("/run/supervisor-status.json"); // Only enable systemd notification debugging if the systemd version supports it diff --git a/crates/kit/src/to_disk.rs b/crates/kit/src/to_disk.rs index b2f3ce4..2d75cf0 100644 --- a/crates/kit/src/to_disk.rs +++ b/crates/kit/src/to_disk.rs @@ -453,6 +453,8 @@ pub fn run(opts: ToDiskOpts) -> Result<()> { opts.additional.format.as_str() )], // Attach target disk kernel_args: Default::default(), + cloud_init: None, // No cloud-init for to-disk installation + cloud_init_empty: false, // No cloud-init for to-disk installation }; // Phase 5: SSH-based VM configuration and execution diff --git a/docs/src/man/bcvk-ephemeral-run-ssh.md b/docs/src/man/bcvk-ephemeral-run-ssh.md index 0a4cee1..8dd295e 100644 --- a/docs/src/man/bcvk-ephemeral-run-ssh.md +++ b/docs/src/man/bcvk-ephemeral-run-ssh.md @@ -117,6 +117,14 @@ Run ephemeral VM and SSH into it Additional kernel command line arguments +**--cloud-init**=*PATH* + + Path to cloud-config file (user-data) for cloud-init ConfigDrive + +**--cloud-init-empty** + + Create an empty cloud-init ConfigDrive (no custom user-data) + # EXAMPLES diff --git a/docs/src/man/bcvk-ephemeral-run.md b/docs/src/man/bcvk-ephemeral-run.md index 91b29a9..12defa7 100644 --- a/docs/src/man/bcvk-ephemeral-run.md +++ b/docs/src/man/bcvk-ephemeral-run.md @@ -143,6 +143,14 @@ This design allows bcvk to provide VM-like isolation and boot behavior while lev Additional kernel command line arguments +**--cloud-init**=*PATH* + + Path to cloud-config file (user-data) for cloud-init ConfigDrive + +**--cloud-init-empty** + + Create an empty cloud-init ConfigDrive (no custom user-data) + # EXAMPLES diff --git a/tests/fixtures/cloud-init/10_bootc.cfg b/tests/fixtures/cloud-init/10_bootc.cfg new file mode 100644 index 0000000..14edd07 --- /dev/null +++ b/tests/fixtures/cloud-init/10_bootc.cfg @@ -0,0 +1,8 @@ +# In image mode, the host root filesystem is mounted at /sysroot, not / +# That is the one we should attempt to resize, not what is mounted at / + +growpart: + mode: auto + devices: ["/sysroot"] + +resize_rootfs: false diff --git a/tests/fixtures/cloud-init/Containerfile b/tests/fixtures/cloud-init/Containerfile new file mode 100644 index 0000000..7dbb084 --- /dev/null +++ b/tests/fixtures/cloud-init/Containerfile @@ -0,0 +1,10 @@ +# This image contains cloud-init, which makes it usable out of the box +# for e.g. a pre-generated AWS or KVM guest system. +FROM quay.io/centos-bootc/centos-bootc:stream10 + +RUN dnf -y install cloud-init && \ + ln -s ../cloud-init.target /usr/lib/systemd/system/default.target.wants && \ + rm -rf /var/{cache,log} /var/lib/{dnf,rhsm} +COPY --chmod=0644 10_bootc.cfg /etc/cloud/cloud.cfg.d +COPY usr/ /usr/ + diff --git a/tests/fixtures/cloud-init/README.md b/tests/fixtures/cloud-init/README.md new file mode 100644 index 0000000..f9cf09c --- /dev/null +++ b/tests/fixtures/cloud-init/README.md @@ -0,0 +1,2 @@ +This is imported from + diff --git a/tests/fixtures/cloud-init/usr/lib/bootc/install/05-cloud-kargs.toml b/tests/fixtures/cloud-init/usr/lib/bootc/install/05-cloud-kargs.toml new file mode 100644 index 0000000..4c071d6 --- /dev/null +++ b/tests/fixtures/cloud-init/usr/lib/bootc/install/05-cloud-kargs.toml @@ -0,0 +1,5 @@ +[install] +# See also: +# - https://github.com/coreos/fedora-coreos-config/blob/testing-devel/platforms.yaml +# - https://github.com/osbuild/images/blob/63a1eead26a7c802dbcebe863439f591be6dc6e5/pkg/distro/rhel9/qcow2.go#L159 +kargs = ["console=tty0", "console=ttyS0,115200n8"]