Skip to content

Commit 396d0fa

Browse files
committed
Add scp subcommand to ephemeral and libvirt
Since we offer `ssh`, we should absolutely offer `scp`. I had an agent try to hack this by piping to ssh and try to base64 encode things. Motivated by simple use cases like "copy out a log file" or "copy in a new bootc binary" (though that one is better with a sysext flow, but that's a whole other concern). The libvirt implementation validates that exactly one of source or destination uses the `domain:` prefix, uses `.status()` so scp progress streams to the terminal in real time, and shares the SSH readiness retry loop with the `ssh` subcommand via a `wait_for_ssh_ready()` helper. Assisted-by: OpenCode (Claude Sonnet 4.6) Signed-off-by: Colin Walters <walters@verbum.org>
1 parent c1adfa9 commit 396d0fa

15 files changed

Lines changed: 1002 additions & 58 deletions

File tree

crates/integration-tests/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ mod tests {
2121
pub mod mount_feature;
2222
pub mod run_ephemeral;
2323
pub mod run_ephemeral_ignition;
24+
pub mod run_ephemeral_scp;
2425
pub mod run_ephemeral_ssh;
2526
pub mod to_disk;
2627
pub mod varlink;

crates/integration-tests/src/tests/libvirt_verb.rs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
//! - `bcvk libvirt list` - List bootc domains
66
//! - `bcvk libvirt list-volumes` - List available bootc volumes
77
//! - `bcvk libvirt ssh` - SSH into domains
8+
//! - `bcvk libvirt scp` - Copy files to/from domains
89
//! - Domain lifecycle management (start/stop/rm/inspect)
910
1011
use integration_tests::integration_test;
@@ -143,6 +144,50 @@ fn test_libvirt_ssh_integration() -> TestResult {
143144
}
144145
integration_test!(test_libvirt_ssh_integration);
145146

147+
/// Test SCP integration with domains (syntax only, no running VM needed)
148+
fn test_libvirt_scp_integration() -> TestResult {
149+
let sh = shell()?;
150+
let bck = get_bck_command()?;
151+
152+
// Test that SCP command fails gracefully with nonexistent domain
153+
let output = cmd!(
154+
sh,
155+
"{bck} libvirt scp test-domain domain:/etc/hostname ./hostname"
156+
)
157+
.ignore_status()
158+
.output()?;
159+
let stderr = String::from_utf8_lossy(&output.stderr);
160+
161+
// Will fail since no domain exists, but should not crash
162+
if !output.status.success() {
163+
assert!(
164+
stderr.contains("domain") || stderr.contains("not found"),
165+
"SCP integration should fail gracefully with domain error: {}",
166+
stderr
167+
);
168+
}
169+
170+
// Test that SCP requires exactly one domain: prefix
171+
let output = cmd!(sh, "{bck} libvirt scp test-domain /local/a /local/b")
172+
.ignore_status()
173+
.output()?;
174+
let stderr = String::from_utf8_lossy(&output.stderr);
175+
176+
assert!(
177+
!output.status.success(),
178+
"SCP with two local paths should fail"
179+
);
180+
assert!(
181+
stderr.contains("domain:"),
182+
"Error should mention domain: prefix: {}",
183+
stderr
184+
);
185+
186+
println!("libvirt SCP integration tested");
187+
Ok(())
188+
}
189+
integration_test!(test_libvirt_scp_integration);
190+
146191
/// Comprehensive workflow test: creates a VM and tests multiple features
147192
/// This consolidates several smaller tests to reduce expensive disk image creation
148193
fn test_libvirt_comprehensive_workflow() -> TestResult {
@@ -1158,3 +1203,105 @@ fn test_libvirt_run_console_log() -> TestResult {
11581203
Ok(())
11591204
}
11601205
integration_test!(test_libvirt_run_console_log);
1206+
1207+
/// End-to-end SCP test: creates a VM, copies files to and from it
1208+
fn test_libvirt_scp_end_to_end() -> TestResult {
1209+
use std::fs;
1210+
use tempfile::TempDir;
1211+
1212+
let sh = shell()?;
1213+
let bck = get_bck_command()?;
1214+
let test_image = get_test_image();
1215+
let label = LIBVIRT_INTEGRATION_TEST_LABEL;
1216+
1217+
let domain_name = format!("test-scp-{}", random_suffix());
1218+
println!("Testing SCP end-to-end with domain: {}", domain_name);
1219+
1220+
cleanup_domain(&domain_name);
1221+
defer! {
1222+
cleanup_domain(&domain_name);
1223+
}
1224+
1225+
// Create domain with --ssh-wait so SSH is ready when run returns
1226+
println!("Creating domain with --ssh-wait...");
1227+
cmd!(
1228+
sh,
1229+
"{bck} libvirt run --name {domain_name} --label {label} --filesystem ext4 --ssh-wait {test_image}"
1230+
)
1231+
.run()?;
1232+
println!("✓ Domain created and SSH ready: {}", domain_name);
1233+
1234+
let sh = shell()?;
1235+
1236+
// --- Upload a file to the VM ---
1237+
let upload_dir = TempDir::new()?;
1238+
let upload_file = upload_dir.path().join("upload-test.txt");
1239+
fs::write(&upload_file, "hello from host")?;
1240+
let upload_path = upload_file.to_str().expect("non-UTF-8 temp path");
1241+
1242+
println!("Uploading file to VM...");
1243+
cmd!(
1244+
sh,
1245+
"{bck} libvirt scp {domain_name} {upload_path} domain:/tmp/upload-test.txt"
1246+
)
1247+
.run()?;
1248+
println!("✓ File uploaded");
1249+
1250+
// Verify it arrived
1251+
let cat_stdout = cmd!(
1252+
sh,
1253+
"{bck} libvirt ssh {domain_name} -- cat /tmp/upload-test.txt"
1254+
)
1255+
.read()?;
1256+
assert_eq!(
1257+
cat_stdout.trim(),
1258+
"hello from host",
1259+
"Uploaded file content mismatch"
1260+
);
1261+
println!("✓ Uploaded file content verified");
1262+
1263+
// --- Download a file from the VM ---
1264+
let download_dir = TempDir::new()?;
1265+
let download_path = download_dir.path().join("hostname");
1266+
let download_str = download_path.to_str().expect("non-UTF-8 temp path");
1267+
1268+
println!("Downloading /etc/hostname from VM...");
1269+
cmd!(
1270+
sh,
1271+
"{bck} libvirt scp {domain_name} domain:/etc/hostname {download_str}"
1272+
)
1273+
.run()?;
1274+
1275+
let downloaded = fs::read_to_string(&download_path)?;
1276+
assert!(
1277+
!downloaded.trim().is_empty(),
1278+
"Downloaded hostname should not be empty"
1279+
);
1280+
println!("✓ Downloaded /etc/hostname: {}", downloaded.trim());
1281+
1282+
// --- Recursive copy from the VM ---
1283+
let rec_dir = download_dir.path().join("etc-yum");
1284+
let rec_str = rec_dir.to_str().expect("non-UTF-8 temp path");
1285+
1286+
println!("Recursive download of /etc/yum.repos.d/ from VM...");
1287+
cmd!(
1288+
sh,
1289+
"{bck} libvirt scp {domain_name} -r domain:/etc/yum.repos.d {rec_str}"
1290+
)
1291+
.run()?;
1292+
1293+
assert!(
1294+
rec_dir.exists(),
1295+
"Recursive download directory should exist"
1296+
);
1297+
let entries: Vec<_> = fs::read_dir(&rec_dir)?.collect();
1298+
assert!(
1299+
!entries.is_empty(),
1300+
"Recursive download should have produced files"
1301+
);
1302+
println!("✓ Recursive download got {} entries", entries.len());
1303+
1304+
println!("✓ SCP end-to-end test passed");
1305+
Ok(())
1306+
}
1307+
integration_test!(test_libvirt_scp_end_to_end);
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
//! Integration tests for ephemeral scp command
2+
//!
3+
//! ⚠️ **CRITICAL INTEGRATION TEST POLICY** ⚠️
4+
//!
5+
//! INTEGRATION TESTS MUST NEVER "warn and continue" ON FAILURES!
6+
//!
7+
//! If something is not working:
8+
//! - Use `todo!("reason why this doesn't work yet")`
9+
//! - Use `panic!("clear error message")`
10+
//! - Use `assert!()` and `unwrap()` to fail hard
11+
//!
12+
//! NEVER use patterns like:
13+
//! - "Note: test failed - likely due to..."
14+
//! - "This is acceptable in CI/testing environments"
15+
//! - Warning and continuing on failures
16+
17+
use integration_tests::integration_test;
18+
use itest::TestResult;
19+
use scopeguard::defer;
20+
use xshell::cmd;
21+
22+
use std::fs;
23+
use tempfile::TempDir;
24+
25+
use crate::{get_bck_command, get_test_image, shell, INTEGRATION_TEST_LABEL};
26+
27+
/// Test that ephemeral SCP validates syntax correctly
28+
fn test_ephemeral_scp_syntax() -> TestResult {
29+
let sh = shell()?;
30+
let bck = get_bck_command()?;
31+
32+
// Test that SCP requires at least one DOMAIN: prefix
33+
let output = cmd!(sh, "{bck} ephemeral scp test-container /local/a /local/b")
34+
.ignore_status()
35+
.output()?;
36+
let stderr = String::from_utf8_lossy(&output.stderr);
37+
38+
assert!(
39+
!output.status.success(),
40+
"SCP with two local paths should fail"
41+
);
42+
assert!(
43+
stderr.contains("DOMAIN:"),
44+
"Error should mention DOMAIN: prefix: {}",
45+
stderr
46+
);
47+
48+
// Test that SCP with two remote paths fails
49+
let output2 = cmd!(
50+
sh,
51+
"{bck} ephemeral scp test-container DOMAIN:/remote/a DOMAIN:/remote/b"
52+
)
53+
.ignore_status()
54+
.output()?;
55+
let stderr2 = String::from_utf8_lossy(&output2.stderr);
56+
57+
assert!(
58+
!output2.status.success(),
59+
"SCP with two remote paths should fail"
60+
);
61+
assert!(
62+
stderr2.contains("DOMAIN:"),
63+
"Error should mention DOMAIN: prefix: {}",
64+
stderr2
65+
);
66+
67+
println!("ephemeral SCP syntax tested");
68+
Ok(())
69+
}
70+
integration_test!(test_ephemeral_scp_syntax);
71+
72+
/// End-to-end SCP test: starts an ephemeral VM, copies files to and from it
73+
fn test_ephemeral_scp_end_to_end() -> TestResult {
74+
let sh = shell()?;
75+
let bck = get_bck_command()?;
76+
let image = get_test_image();
77+
let label = INTEGRATION_TEST_LABEL;
78+
let container_name = format!("test-scp-{}", std::process::id());
79+
80+
println!("Starting ephemeral VM for SCP end-to-end test...");
81+
cmd!(
82+
sh,
83+
"{bck} ephemeral run --ssh-keygen --label {label} --detach --name {container_name} {image}"
84+
)
85+
.run()?;
86+
87+
// Ensure the container is cleaned up even if the test fails
88+
defer! {
89+
let sh = shell().unwrap();
90+
let _ = cmd!(sh, "podman rm -f {container_name}")
91+
.ignore_status()
92+
.quiet()
93+
.run();
94+
}
95+
96+
// Wait a little for SSH connectivity to be verified / ready
97+
println!("Waiting for SSH access to become ready...");
98+
let _ = cmd!(sh, "{bck} ephemeral ssh {container_name} echo READY").read()?;
99+
100+
// --- Upload a file to the VM ---
101+
let upload_dir = TempDir::new()?;
102+
let upload_file = upload_dir.path().join("upload-test.txt");
103+
fs::write(&upload_file, "hello from host to ephemeral VM")?;
104+
let upload_path = upload_file.to_str().expect("non-UTF-8 temp path");
105+
106+
println!("Uploading file to ephemeral VM...");
107+
cmd!(
108+
sh,
109+
"{bck} ephemeral scp {container_name} {upload_path} DOMAIN:/tmp/upload-test.txt"
110+
)
111+
.run()?;
112+
println!("✓ File uploaded");
113+
114+
// Verify it arrived
115+
let cat_stdout = cmd!(
116+
sh,
117+
"{bck} ephemeral ssh {container_name} -- cat /tmp/upload-test.txt"
118+
)
119+
.read()?;
120+
assert_eq!(
121+
cat_stdout.trim(),
122+
"hello from host to ephemeral VM",
123+
"Uploaded file content mismatch"
124+
);
125+
println!("✓ Uploaded file content verified");
126+
127+
// --- Download a file from the VM ---
128+
let download_dir = TempDir::new()?;
129+
let download_path = download_dir.path().join("os-release");
130+
let download_str = download_path.to_str().expect("non-UTF-8 temp path");
131+
132+
// Get the expected os-release content first, and normalize CRLF to LF
133+
let expected_os_release = cmd!(
134+
sh,
135+
"{bck} ephemeral ssh {container_name} -- cat /etc/os-release"
136+
)
137+
.read()?
138+
.trim()
139+
.replace("\r", "");
140+
141+
println!("Downloading /etc/os-release from ephemeral VM...");
142+
cmd!(
143+
sh,
144+
"{bck} ephemeral scp {container_name} DOMAIN:/etc/os-release {download_str}"
145+
)
146+
.run()?;
147+
148+
let downloaded = fs::read_to_string(&download_path)?;
149+
assert_eq!(
150+
downloaded.trim(),
151+
expected_os_release,
152+
"Downloaded os-release mismatch"
153+
);
154+
println!(
155+
"✓ Downloaded /etc/os-release verified: {}",
156+
downloaded.trim()
157+
);
158+
159+
// --- Recursive Copy to VM ---
160+
let local_rec_dir = TempDir::new()?;
161+
let file1 = local_rec_dir.path().join("file1.txt");
162+
let file2 = local_rec_dir.path().join("file2.txt");
163+
fs::write(&file1, "nested content 1")?;
164+
fs::write(&file2, "nested content 2")?;
165+
let local_rec_path = local_rec_dir.path().to_str().expect("non-UTF-8 path");
166+
167+
println!("Recursive upload of directory to ephemeral VM...");
168+
cmd!(
169+
sh,
170+
"{bck} ephemeral scp {container_name} -r {local_rec_path} DOMAIN:/tmp/rec_upload"
171+
)
172+
.run()?;
173+
174+
// Verify contents of the recursively uploaded directory
175+
let verify_rec_1 = cmd!(
176+
sh,
177+
"{bck} ephemeral ssh {container_name} -- cat /tmp/rec_upload/file1.txt"
178+
)
179+
.read()?;
180+
assert_eq!(verify_rec_1.trim(), "nested content 1");
181+
182+
let verify_rec_2 = cmd!(
183+
sh,
184+
"{bck} ephemeral ssh {container_name} -- cat /tmp/rec_upload/file2.txt"
185+
)
186+
.read()?;
187+
assert_eq!(verify_rec_2.trim(), "nested content 2");
188+
println!("✓ Recursive upload verified successfully");
189+
190+
// --- Recursive Download from VM ---
191+
let download_rec_dir = TempDir::new()?;
192+
let download_rec_path = download_rec_dir.path().join("downloaded_rec");
193+
let download_rec_str = download_rec_path.to_str().expect("non-UTF-8 path");
194+
195+
println!("Recursive download of directory from ephemeral VM...");
196+
cmd!(
197+
sh,
198+
"{bck} ephemeral scp {container_name} -r DOMAIN:/tmp/rec_upload {download_rec_str}"
199+
)
200+
.run()?;
201+
202+
assert!(
203+
download_rec_path.exists(),
204+
"Downloaded recursive directory should exist"
205+
);
206+
let downloaded_file1 = download_rec_path.join("file1.txt");
207+
let downloaded_file2 = download_rec_path.join("file2.txt");
208+
assert_eq!(
209+
fs::read_to_string(downloaded_file1)?.trim(),
210+
"nested content 1"
211+
);
212+
assert_eq!(
213+
fs::read_to_string(downloaded_file2)?.trim(),
214+
"nested content 2"
215+
);
216+
println!("✓ Recursive download verified successfully");
217+
218+
println!("✓ Ephemeral SCP end-to-end test passed");
219+
Ok(())
220+
}
221+
integration_test!(test_ephemeral_scp_end_to_end);

0 commit comments

Comments
 (0)