Skip to content

Commit 42b8735

Browse files
authored
phd: add smoke test for VCR replacement (#872)
Add a smoke test for Crucible VCR replacement: - Add a `CrucibleDisk` function to get a disk's current VCR. - Add a `TestVm` framework to send a VCR replacement request. - Fix a couple of bugs in Crucible disk setup: - The `id` field in the VCR's `CrucibleOpts` needs to be the same in the old and new VCRs, so use the disk ID for it instead of calling `Uuid::new_v4()` every time the VCR is generated. - When using a `Blank` disk source, properly account for the fact that the disk source size is given in bytes, not gibibytes. Also add a couple of bells and whistles to allow this test to be transformed into a test of VCR replacement during VM startup: - Make PHD's `VmSpec` type a public type and amend `Framework` to allow tests to create a VM from a spec. This gives tests a way to access a config's Crucible disks before actually launching a VM (and sending an instance spec to Propolis). - Reorganize the `CrucibleDisk` types to wrap the disk innards in a `Mutex`, allowing them to be mutated through the `Arc` references that tests get. This will eventually be used to allow tests to override the downstairs addresses in a disk's VCRs before launching a VM that uses that disk, which will be used to test #841. In the meantime, use the mutex to protect the VCR generation number, which no longer needs to be an `AtomicU64`.
1 parent 6c1c2f4 commit 42b8735

File tree

7 files changed

+189
-51
lines changed

7 files changed

+189
-51
lines changed

phd-tests/framework/src/disk/crucible.rs

+81-45
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use std::{
88
net::{Ipv4Addr, SocketAddr, SocketAddrV4},
99
path::{Path, PathBuf},
1010
process::Stdio,
11-
sync::atomic::{AtomicU64, Ordering},
11+
sync::Mutex,
1212
};
1313

1414
use anyhow::Context;
@@ -62,12 +62,73 @@ impl Drop for Downstairs {
6262
/// An RAII wrapper around a Crucible disk.
6363
#[derive(Debug)]
6464
pub struct CrucibleDisk {
65-
/// The name to use in instance specs that include this disk.
6665
device_name: DeviceName,
66+
disk_id: Uuid,
67+
guest_os: Option<GuestOsKind>,
68+
inner: Mutex<Inner>,
69+
}
70+
71+
impl CrucibleDisk {
72+
#[allow(clippy::too_many_arguments)]
73+
pub(crate) fn new(
74+
device_name: DeviceName,
75+
min_disk_size_gib: u64,
76+
block_size: BlockSize,
77+
downstairs_binary_path: &impl AsRef<std::ffi::OsStr>,
78+
downstairs_ports: &[u16],
79+
data_dir_root: &impl AsRef<Path>,
80+
read_only_parent: Option<&impl AsRef<Path>>,
81+
guest_os: Option<GuestOsKind>,
82+
log_mode: ServerLogMode,
83+
) -> anyhow::Result<Self> {
84+
Ok(Self {
85+
device_name,
86+
disk_id: Uuid::new_v4(),
87+
guest_os,
88+
inner: Mutex::new(Inner::new(
89+
min_disk_size_gib,
90+
block_size,
91+
downstairs_binary_path,
92+
downstairs_ports,
93+
data_dir_root,
94+
read_only_parent,
95+
log_mode,
96+
)?),
97+
})
98+
}
6799

68-
/// The UUID to insert into this disk's `VolumeConstructionRequest`s.
69-
id: Uuid,
100+
/// Obtains the current volume construction request for this disk.
101+
pub fn vcr(&self) -> VolumeConstructionRequest {
102+
self.inner.lock().unwrap().vcr(self.disk_id)
103+
}
70104

105+
/// Sets the generation number to use in subsequent calls to create a
106+
/// backend spec for this disk.
107+
pub fn set_generation(&self, generation: u64) {
108+
self.inner.lock().unwrap().generation = generation;
109+
}
110+
}
111+
112+
impl super::DiskConfig for CrucibleDisk {
113+
fn device_name(&self) -> &DeviceName {
114+
&self.device_name
115+
}
116+
117+
fn backend_spec(&self) -> ComponentV0 {
118+
self.inner.lock().unwrap().backend_spec(self.disk_id)
119+
}
120+
121+
fn guest_os(&self) -> Option<GuestOsKind> {
122+
self.guest_os
123+
}
124+
125+
fn as_crucible(&self) -> Option<&CrucibleDisk> {
126+
Some(self)
127+
}
128+
}
129+
130+
#[derive(Debug)]
131+
struct Inner {
71132
/// The disk's block size.
72133
block_size: BlockSize,
73134

@@ -83,30 +144,25 @@ pub struct CrucibleDisk {
83144
/// An optional path to a file to use as a read-only parent for this disk.
84145
read_only_parent: Option<PathBuf>,
85146

86-
/// The kind of guest OS that can be found on this disk, if there is one.
87-
guest_os: Option<GuestOsKind>,
88-
89147
/// The base64-encoded encryption key to use for this disk.
90148
encryption_key: String,
91149

92150
/// The generation number to insert into this disk's
93151
/// `VolumeConstructionRequest`s.
94-
generation: AtomicU64,
152+
generation: u64,
95153
}
96154

97-
impl CrucibleDisk {
155+
impl Inner {
98156
/// Constructs a new Crucible disk that stores its files in the supplied
99157
/// `data_dir`.
100158
#[allow(clippy::too_many_arguments)]
101159
pub(crate) fn new(
102-
device_name: DeviceName,
103160
min_disk_size_gib: u64,
104161
block_size: BlockSize,
105162
downstairs_binary_path: &impl AsRef<std::ffi::OsStr>,
106163
downstairs_ports: &[u16],
107164
data_dir_root: &impl AsRef<Path>,
108165
read_only_parent: Option<&impl AsRef<Path>>,
109-
guest_os: Option<GuestOsKind>,
110166
log_mode: ServerLogMode,
111167
) -> anyhow::Result<Self> {
112168
// To create a region, Crucible requires a block size, an extent size
@@ -253,8 +309,6 @@ impl CrucibleDisk {
253309
}
254310

255311
Ok(Self {
256-
device_name,
257-
id: disk_uuid,
258312
block_size,
259313
blocks_per_extent,
260314
extent_count: extents_in_disk as u32,
@@ -269,37 +323,33 @@ impl CrucibleDisk {
269323
bytes
270324
},
271325
),
272-
guest_os,
273-
generation: AtomicU64::new(1),
326+
generation: 1,
274327
})
275328
}
276329

277-
/// Sets the generation number to use in subsequent calls to create a
278-
/// backend spec for this disk.
279-
pub fn set_generation(&self, gen: u64) {
280-
self.generation.store(gen, Ordering::Relaxed);
281-
}
282-
}
330+
fn backend_spec(&self, disk_id: Uuid) -> ComponentV0 {
331+
let vcr = self.vcr(disk_id);
283332

284-
impl super::DiskConfig for CrucibleDisk {
285-
fn device_name(&self) -> &DeviceName {
286-
&self.device_name
333+
ComponentV0::CrucibleStorageBackend(CrucibleStorageBackend {
334+
request_json: serde_json::to_string(&vcr)
335+
.expect("VolumeConstructionRequest should serialize"),
336+
readonly: false,
337+
})
287338
}
288339

289-
fn backend_spec(&self) -> ComponentV0 {
290-
let gen = self.generation.load(Ordering::Relaxed);
340+
fn vcr(&self, disk_id: Uuid) -> VolumeConstructionRequest {
291341
let downstairs_addrs =
292342
self.downstairs_instances.iter().map(|ds| ds.address).collect();
293343

294-
let vcr = VolumeConstructionRequest::Volume {
295-
id: self.id,
344+
VolumeConstructionRequest::Volume {
345+
id: disk_id,
296346
block_size: self.block_size.bytes(),
297347
sub_volumes: vec![VolumeConstructionRequest::Region {
298348
block_size: self.block_size.bytes(),
299349
blocks_per_extent: self.blocks_per_extent,
300350
extent_count: self.extent_count,
301351
opts: CrucibleOpts {
302-
id: Uuid::new_v4(),
352+
id: disk_id,
303353
target: downstairs_addrs,
304354
lossy: false,
305355
flush_timeout: None,
@@ -310,7 +360,7 @@ impl super::DiskConfig for CrucibleDisk {
310360
control: None,
311361
read_only: false,
312362
},
313-
gen,
363+
r#gen: self.generation,
314364
}],
315365
read_only_parent: self.read_only_parent.as_ref().map(|p| {
316366
Box::new(VolumeConstructionRequest::File {
@@ -319,21 +369,7 @@ impl super::DiskConfig for CrucibleDisk {
319369
path: p.to_string_lossy().to_string(),
320370
})
321371
}),
322-
};
323-
324-
ComponentV0::CrucibleStorageBackend(CrucibleStorageBackend {
325-
request_json: serde_json::to_string(&vcr)
326-
.expect("VolumeConstructionRequest should serialize"),
327-
readonly: false,
328-
})
329-
}
330-
331-
fn guest_os(&self) -> Option<GuestOsKind> {
332-
self.guest_os
333-
}
334-
335-
fn as_crucible(&self) -> Option<&CrucibleDisk> {
336-
Some(self)
372+
}
337373
}
338374
}
339375

phd-tests/framework/src/disk/mod.rs

+9-2
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,8 @@ impl DiskFactory {
279279
mut min_disk_size_gib: u64,
280280
block_size: BlockSize,
281281
) -> Result<Arc<CrucibleDisk>, DiskError> {
282+
const BYTES_PER_GIB: u64 = 1024 * 1024 * 1024;
283+
282284
let binary_path = self.artifact_store.get_crucible_downstairs().await?;
283285

284286
let (artifact_path, guest_os) = match source {
@@ -287,8 +289,13 @@ impl DiskFactory {
287289
(Some(path), Some(os))
288290
}
289291
DiskSource::Blank(size) => {
290-
min_disk_size_gib = min_disk_size_gib
291-
.max(u64::try_from(*size).map_err(anyhow::Error::from)?);
292+
let blank_size =
293+
u64::try_from(*size).map_err(anyhow::Error::from)?;
294+
295+
let min_disk_size_b =
296+
(min_disk_size_gib * BYTES_PER_GIB).max(blank_size);
297+
298+
min_disk_size_gib = min_disk_size_b.div_ceil(BYTES_PER_GIB);
292299
(None, None)
293300
}
294301
// It's possible in theory to have a Crucible-backed disk with

phd-tests/framework/src/lib.rs

+18-3
Original file line numberDiff line numberDiff line change
@@ -252,12 +252,27 @@ impl Framework {
252252
config: &VmConfig<'_>,
253253
environment: Option<&EnvironmentSpec>,
254254
) -> anyhow::Result<TestVm> {
255-
TestVm::new(
256-
self,
255+
self.spawn_vm_with_spec(
257256
config
258257
.vm_spec(self)
259258
.await
260-
.context("building VM config for test VM")?,
259+
.context("building VM spec from VmConfig")?,
260+
environment,
261+
)
262+
.await
263+
}
264+
265+
/// Spawns a new test VM using the supplied `spec`. If `environment` is
266+
/// `Some`, the VM is spawned using the supplied environment; otherwise it
267+
/// is spawned using the default `environment_builder`.
268+
pub async fn spawn_vm_with_spec(
269+
&self,
270+
spec: VmSpec,
271+
environment: Option<&EnvironmentSpec>,
272+
) -> anyhow::Result<TestVm> {
273+
TestVm::new(
274+
self,
275+
spec,
261276
environment.unwrap_or(&self.environment_builder()),
262277
)
263278
.await

phd-tests/framework/src/test_vm/config.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ impl<'dr> VmConfig<'dr> {
208208
self
209209
}
210210

211-
pub(crate) async fn vm_spec(
211+
pub async fn vm_spec(
212212
&self,
213213
framework: &Framework,
214214
) -> anyhow::Result<VmSpec> {

phd-tests/framework/src/test_vm/mod.rs

+34
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use std::{
1111
};
1212

1313
use crate::{
14+
disk::{crucible::CrucibleDisk, DiskConfig},
1415
guest_os::{
1516
self, windows::WindowsVm, CommandSequence, CommandSequenceEntry,
1617
GuestOs, GuestOsKind,
@@ -603,6 +604,39 @@ impl TestVm {
603604
Ok(self.client.instance_migrate_status().send().await?.into_inner())
604605
}
605606

607+
pub async fn replace_crucible_vcr(
608+
&self,
609+
disk: &CrucibleDisk,
610+
) -> anyhow::Result<()> {
611+
let vcr = disk.vcr();
612+
let body = propolis_client::types::InstanceVcrReplace {
613+
vcr_json: serde_json::to_string(&vcr)
614+
.with_context(|| format!("serializing VCR {vcr:?}"))?,
615+
};
616+
617+
info!(
618+
disk_name = disk.device_name().as_str(),
619+
vcr = ?vcr,
620+
"issuing Crucible VCR replacement request"
621+
);
622+
623+
let response_value = self
624+
.client
625+
.instance_issue_crucible_vcr_request()
626+
.id(disk.device_name().clone().into_backend_name().into_string())
627+
.body(body)
628+
.send()
629+
.await?;
630+
631+
anyhow::ensure!(
632+
response_value.status().is_success(),
633+
"VCR replacement request returned an error value: \
634+
{response_value:?}"
635+
);
636+
637+
Ok(())
638+
}
639+
606640
pub async fn get_serial_console_history(
607641
&self,
608642
from_start: u64,

phd-tests/framework/src/test_vm/spec.rs

+9
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ pub struct VmSpec {
3434
}
3535

3636
impl VmSpec {
37+
pub fn get_disk_by_device_name(
38+
&self,
39+
name: &str,
40+
) -> Option<&Arc<dyn disk::DiskConfig>> {
41+
self.disk_handles
42+
.iter()
43+
.find(|disk| disk.device_name().as_str() == name)
44+
}
45+
3746
/// Update the Crucible backend specs in the instance spec to match the
3847
/// current backend specs given by this specification's disk handles.
3948
pub(crate) fn refresh_crucible_backends(&mut self) {

phd-tests/tests/src/crucible/smoke.rs

+37
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
use std::time::Duration;
66

7+
use phd_framework::{
8+
disk::{BlockSize, DiskSource},
9+
test_vm::{DiskBackend, DiskInterface},
10+
};
711
use phd_testcase::*;
812
use propolis_client::types::InstanceState;
913

@@ -82,3 +86,36 @@ async fn shutdown_persistence_test(ctx: &Framework) {
8286
let lsout = vm.run_shell_command("ls foo.bar 2> /dev/null").await?;
8387
assert_eq!(lsout, "foo.bar");
8488
}
89+
90+
#[phd_testcase]
91+
async fn vcr_replace_test(ctx: &Framework) {
92+
let mut config = ctx.vm_config_builder("crucible_vcr_replace_test");
93+
94+
// Create a blank data disk on which to perform VCR replacement. This is
95+
// necessary because Crucible doesn't permit VCR replacements for volumes
96+
// whose read-only parents are local files (which is true for artifact-based
97+
// Crucible disks).
98+
const DATA_DISK_NAME: &str = "vcr-replacement-target";
99+
config.data_disk(
100+
DATA_DISK_NAME,
101+
DiskSource::Blank(1024 * 1024 * 1024),
102+
DiskInterface::Nvme,
103+
DiskBackend::Crucible {
104+
min_disk_size_gib: 1,
105+
block_size: BlockSize::Bytes512,
106+
},
107+
5,
108+
);
109+
110+
let spec = config.vm_spec(ctx).await?;
111+
let disk_hdl =
112+
spec.get_disk_by_device_name(DATA_DISK_NAME).cloned().unwrap();
113+
let disk = disk_hdl.as_crucible().unwrap();
114+
115+
let mut vm = ctx.spawn_vm_with_spec(spec, None).await?;
116+
vm.launch().await?;
117+
vm.wait_to_boot().await?;
118+
119+
disk.set_generation(2);
120+
vm.replace_crucible_vcr(disk).await?;
121+
}

0 commit comments

Comments
 (0)