Skip to content

Commit 3b11b66

Browse files
committed
ephemeral: Implement cloud-init ConfigDrive support
Implement cloud-init support for ephemeral VMs using the ConfigDrive datasource approach, using a VFAT filesystem (more compatible than ISOs). This uses the same approach as systemd for populating VFAT filesystems: mkfs.vfat to create the filesystem, and mcopy (from mtools) to populate it. We avoid using systemd-repart itself as it creates GPT-partitioned disks rather than raw VFAT filesystems. The ConfigDrive is attached as a raw disk image and will be automatically detected by cloud-init in the guest VM. Fixes: #108 Assisted-by: Claude Code (Sonnet 4.5) Signed-off-by: Colin Walters <[email protected]>
1 parent d870f45 commit 3b11b66

File tree

17 files changed

+903
-6
lines changed

17 files changed

+903
-6
lines changed

.github/workflows/main.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
- name: Install dependencies
2222
run: |
2323
sudo apt update
24-
sudo apt install -y just pkg-config go-md2man libvirt-daemon libvirt-clients qemu-kvm qemu-system qemu-utils virtiofsd
24+
sudo apt install -y just pkg-config go-md2man libvirt-daemon libvirt-clients qemu-kvm qemu-system qemu-utils virtiofsd dosfstools mtools
2525
2626
- name: Install podman for heredoc support
2727
run: |

Cargo.lock

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Justfile

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,16 @@ unit *ARGS:
2222
pull-test-images:
2323
podman pull -q {{ALL_BASE_IMAGES}} >/dev/null
2424

25+
# Build cloud-init test image
26+
build-cloud-init-image:
27+
#!/usr/bin/env bash
28+
set -euo pipefail
29+
echo "Building cloud-init test image..."
30+
podman build -t localhost/bootc-cloud-init tests/fixtures/cloud-init/
31+
echo "✓ Cloud-init test image built: localhost/bootc-cloud-init"
32+
2533
# Run integration tests (auto-detects nextest, with cleanup)
26-
test-integration *ARGS: build pull-test-images
34+
test-integration *ARGS: build pull-test-images build-cloud-init-image
2735
#!/usr/bin/env bash
2836
set -euo pipefail
2937
export BCVK_PATH=$(pwd)/target/release/bcvk

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,34 @@ disk images that can be imported into other virtualization frameworks.
77

88
See [docs/src/installation.md](./docs/src/installation.md).
99

10+
## Dependencies
11+
12+
bcvk requires the following runtime dependencies:
13+
14+
### Core virtualization
15+
- **QEMU** - The core virtualization engine
16+
- **virtiofsd** - VirtIO filesystem daemon for sharing directories with VMs
17+
- **podman** - Container runtime for managing bootc images
18+
19+
### For libvirt integration
20+
- **libvirt** - Virtualization management for persistent VMs
21+
22+
### For cloud-init support
23+
- **dosfstools** - Provides `mkfs.vfat` for creating VFAT filesystems
24+
- **mtools** - Provides `mcopy` for populating VFAT images (used for cloud-init ConfigDrive)
25+
26+
### Package installation
27+
28+
**Debian/Ubuntu:**
29+
```bash
30+
sudo apt install qemu-kvm qemu-system qemu-utils virtiofsd podman libvirt-daemon libvirt-clients dosfstools mtools
31+
```
32+
33+
**Fedora/RHEL:**
34+
```bash
35+
sudo dnf install qemu-kvm qemu-img virtiofsd podman libvirt libvirt-client dosfstools mtools
36+
```
37+
1038
## Quick Start
1139

1240
### Running a bootc container as ephemeral VM

crates/integration-tests/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pub(crate) use integration_tests::{
1717
use linkme::distributed_slice;
1818

1919
mod tests {
20+
pub mod cloud_init;
2021
pub mod libvirt_base_disks;
2122
pub mod libvirt_port_forward;
2223
pub mod libvirt_upload_disk;
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
//! Integration tests for cloud-init ConfigDrive functionality
2+
//!
3+
//! These tests verify:
4+
//! - ConfigDrive generation from user-provided cloud-config files
5+
//! - ConfigDrive device creation and accessibility
6+
//! - ConfigDrive content structure (OpenStack format)
7+
//! - Kernel cmdline does NOT contain `ds=iid-datasource-none` when using ConfigDrive
8+
//! - Cloud-init processing of the ConfigDrive (using localhost/bootc-cloud-init image)
9+
10+
use color_eyre::eyre::Context as _;
11+
use color_eyre::Result;
12+
use integration_tests::integration_test;
13+
use linkme::distributed_slice;
14+
15+
use crate::{run_bcvk, INTEGRATION_TEST_LABEL};
16+
17+
/// Get the cloud-init test image (built from tests/fixtures/cloud-init/)
18+
fn get_cloud_init_test_image() -> String {
19+
std::env::var("BCVK_CLOUD_INIT_TEST_IMAGE")
20+
.unwrap_or_else(|_| "localhost/bootc-cloud-init".to_string())
21+
}
22+
23+
/// Test basic cloud-init ConfigDrive functionality
24+
///
25+
/// Creates a cloud-config file, runs an ephemeral VM with --cloud-init,
26+
/// and verifies that:
27+
/// - The ConfigDrive device exists at /dev/disk/by-id/virtio-config-2
28+
/// - The ConfigDrive can be mounted and contains expected OpenStack structure
29+
/// - The user_data file contains the cloud-config content
30+
/// - The meta_data.json contains the instance-id
31+
fn test_cloud_init_configdrive_basic() -> Result<()> {
32+
let test_image = get_cloud_init_test_image();
33+
34+
println!("Testing basic cloud-init ConfigDrive functionality");
35+
36+
// Create a temporary cloud-config file
37+
let cloud_config_dir = tempfile::tempdir().context("Failed to create temp directory")?;
38+
let cloud_config_path = cloud_config_dir
39+
.path()
40+
.join("cloud-config.yaml")
41+
.to_str()
42+
.ok_or_else(|| color_eyre::eyre::eyre!("Invalid UTF-8 in temp path"))?
43+
.to_string();
44+
45+
// Create a simple cloud-config with identifiable content
46+
let cloud_config_content = r#"#cloud-config
47+
write_files:
48+
- path: /tmp/test-marker
49+
content: |
50+
ConfigDrive test content
51+
permissions: '0644'
52+
53+
runcmd:
54+
- echo "Test command from cloud-config"
55+
"#;
56+
57+
std::fs::write(&cloud_config_path, cloud_config_content)
58+
.context("Failed to write cloud-config file")?;
59+
60+
println!("Created cloud-config file at: {}", cloud_config_path);
61+
62+
// Run ephemeral VM and verify ConfigDrive structure
63+
println!("Running ephemeral VM with --cloud-init...");
64+
let output = run_bcvk(&[
65+
"ephemeral",
66+
"run",
67+
"--rm",
68+
"--label",
69+
INTEGRATION_TEST_LABEL,
70+
"--cloud-init",
71+
&cloud_config_path,
72+
"--execute",
73+
"/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'",
74+
&test_image,
75+
])?;
76+
77+
println!("VM execution completed");
78+
79+
// Check the output
80+
println!("=== STDOUT ===");
81+
println!("{}", output.stdout);
82+
println!("=== STDERR ===");
83+
println!("{}", output.stderr);
84+
85+
let combined_output = format!("{}\n{}", output.stdout, output.stderr);
86+
87+
// Verify ConfigDrive device symlink exists
88+
assert!(
89+
combined_output.contains("virtio-config-2"),
90+
"ConfigDrive device symlink 'virtio-config-2' not found in output. Output: {}",
91+
combined_output
92+
);
93+
94+
// Verify user_data contains the cloud-config header
95+
assert!(
96+
combined_output.contains("#cloud-config"),
97+
"user_data does not contain #cloud-config header. Output: {}",
98+
combined_output
99+
);
100+
101+
// Verify user_data contains our test content
102+
assert!(
103+
combined_output.contains("ConfigDrive test content"),
104+
"user_data does not contain expected test content. Output: {}",
105+
combined_output
106+
);
107+
108+
// Verify meta_data.json contains uuid (which cloud-init maps to instance-id)
109+
assert!(
110+
combined_output.contains("uuid"),
111+
"meta_data.json does not contain uuid. Output: {}",
112+
combined_output
113+
);
114+
115+
// Also verify it contains the expected uuid value
116+
assert!(
117+
combined_output.contains("iid-local01"),
118+
"meta_data.json does not contain expected uuid value 'iid-local01'. Output: {}",
119+
combined_output
120+
);
121+
122+
println!("✓ Basic cloud-init ConfigDrive test passed");
123+
output.assert_success("ephemeral run with cloud-init");
124+
Ok(())
125+
}
126+
integration_test!(test_cloud_init_configdrive_basic);
127+
128+
/// Test that kernel cmdline does NOT contain `ds=iid-datasource-none` when using ConfigDrive
129+
///
130+
/// When a ConfigDrive is provided, the kernel cmdline should NOT contain the
131+
/// `ds=iid-datasource-none` parameter which would disable cloud-init.
132+
/// This test verifies the cmdline directly without depending on cloud-init.
133+
fn test_cloud_init_no_datasource_cmdline() -> Result<()> {
134+
let test_image = get_cloud_init_test_image();
135+
136+
println!("Testing kernel cmdline does NOT contain ds=iid-datasource-none with ConfigDrive");
137+
138+
// Create a temporary cloud-config file
139+
let cloud_config_dir = tempfile::tempdir().context("Failed to create temp directory")?;
140+
let cloud_config_path = cloud_config_dir
141+
.path()
142+
.join("cloud-config.yaml")
143+
.to_str()
144+
.ok_or_else(|| color_eyre::eyre::eyre!("Invalid UTF-8 in temp path"))?
145+
.to_string();
146+
147+
// Create a minimal cloud-config
148+
let cloud_config_content = r#"#cloud-config
149+
runcmd:
150+
- echo "test"
151+
"#;
152+
153+
std::fs::write(&cloud_config_path, cloud_config_content)
154+
.context("Failed to write cloud-config file")?;
155+
156+
println!("Created cloud-config file");
157+
158+
// Run ephemeral VM and check /proc/cmdline directly
159+
println!("Running ephemeral VM to check kernel cmdline...");
160+
let output = run_bcvk(&[
161+
"ephemeral",
162+
"run",
163+
"--rm",
164+
"--label",
165+
INTEGRATION_TEST_LABEL,
166+
"--cloud-init",
167+
&cloud_config_path,
168+
"--execute",
169+
"cat /proc/cmdline",
170+
&test_image,
171+
])?;
172+
173+
println!("VM execution completed");
174+
println!("=== Output ===");
175+
println!("{}", output.stdout);
176+
177+
// Get the kernel cmdline from the output
178+
let combined_output = format!("{}\n{}", output.stdout, output.stderr);
179+
180+
// Verify that ds=iid-datasource-none is NOT present in the cmdline
181+
assert!(
182+
!combined_output.contains("ds=iid-datasource-none"),
183+
"Kernel cmdline should NOT contain 'ds=iid-datasource-none' when using ConfigDrive.\nOutput: {}",
184+
combined_output
185+
);
186+
187+
println!("✓ Kernel cmdline does NOT contain ds=iid-datasource-none");
188+
output.assert_success("ephemeral run with cloud-init");
189+
Ok(())
190+
}
191+
integration_test!(test_cloud_init_no_datasource_cmdline);
192+
193+
/// Test that ConfigDrive contains expected user_data content
194+
///
195+
/// Creates a cloud-config with multiple runcmd directives,
196+
/// then verifies the ConfigDrive user_data contains all expected content.
197+
/// This test does NOT depend on cloud-init being installed - it directly
198+
/// inspects the ConfigDrive contents.
199+
fn test_cloud_init_configdrive_content() -> Result<()> {
200+
let test_image = get_cloud_init_test_image();
201+
202+
println!("Testing ConfigDrive content verification");
203+
204+
// Create a temporary cloud-config file
205+
let cloud_config_dir = tempfile::tempdir().context("Failed to create temp directory")?;
206+
let cloud_config_path = cloud_config_dir
207+
.path()
208+
.join("cloud-config.yaml")
209+
.to_str()
210+
.ok_or_else(|| color_eyre::eyre::eyre!("Invalid UTF-8 in temp path"))?
211+
.to_string();
212+
213+
// Create a cloud-config with multiple runcmd directives
214+
let cloud_config_content = r#"#cloud-config
215+
runcmd:
216+
- echo "RUNCMD_TEST_1_SUCCESS"
217+
- echo "RUNCMD_TEST_2_SUCCESS"
218+
- echo "RUNCMD_TEST_3_SUCCESS"
219+
"#;
220+
221+
std::fs::write(&cloud_config_path, cloud_config_content)
222+
.context("Failed to write cloud-config file")?;
223+
224+
println!("Created cloud-config with runcmd directives");
225+
226+
// Run ephemeral VM and verify ConfigDrive user_data content
227+
println!("Running ephemeral VM to verify ConfigDrive content...");
228+
let output = run_bcvk(&[
229+
"ephemeral",
230+
"run",
231+
"--rm",
232+
"--label",
233+
INTEGRATION_TEST_LABEL,
234+
"--cloud-init",
235+
&cloud_config_path,
236+
"--execute",
237+
"/bin/sh -c 'mkdir -p /mnt && mount /dev/disk/by-id/virtio-config-2 /mnt && cat /mnt/openstack/latest/user_data'",
238+
&test_image,
239+
])?;
240+
241+
println!("VM execution completed");
242+
println!("=== Output ===");
243+
println!("{}", output.stdout);
244+
245+
// Verify user_data contains all runcmd directives
246+
let combined_output = format!("{}\n{}", output.stdout, output.stderr);
247+
248+
assert!(
249+
combined_output.contains("RUNCMD_TEST_1_SUCCESS"),
250+
"First runcmd directive not found in user_data. Output: {}",
251+
combined_output
252+
);
253+
254+
assert!(
255+
combined_output.contains("RUNCMD_TEST_2_SUCCESS"),
256+
"Second runcmd directive not found in user_data. Output: {}",
257+
combined_output
258+
);
259+
260+
assert!(
261+
combined_output.contains("RUNCMD_TEST_3_SUCCESS"),
262+
"Third runcmd directive not found in user_data. Output: {}",
263+
combined_output
264+
);
265+
266+
println!("✓ All expected content found in ConfigDrive user_data");
267+
output.assert_success("ephemeral run with cloud-init configdrive content");
268+
Ok(())
269+
}
270+
integration_test!(test_cloud_init_configdrive_content);

0 commit comments

Comments
 (0)