Skip to content

Commit 73a0034

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 cec4402 commit 73a0034

File tree

16 files changed

+992
-5
lines changed

16 files changed

+992
-5
lines changed

.github/workflows/main.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ jobs:
9595
with:
9696
libvirt: 'true'
9797

98+
- name: Install additional dependencies
99+
run: sudo apt install -y go-md2man dosfstools mtools
100+
98101
- name: Extract image lists from Justfile
99102
run: |
100103
echo "PRIMARY_IMAGE=$(just --evaluate PRIMARY_IMAGE)" >> $GITHUB_ENV

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
@@ -16,6 +16,7 @@ pub(crate) use integration_tests::{
1616
};
1717

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

0 commit comments

Comments
 (0)