Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

phd: add smoke test for VCR replacement #872

Merged
merged 4 commits into from
Feb 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 81 additions & 45 deletions phd-tests/framework/src/disk/crucible.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use std::{
net::{Ipv4Addr, SocketAddr, SocketAddrV4},
path::{Path, PathBuf},
process::Stdio,
sync::atomic::{AtomicU64, Ordering},
sync::Mutex,
};

use anyhow::Context;
Expand Down Expand Up @@ -62,12 +62,73 @@ impl Drop for Downstairs {
/// An RAII wrapper around a Crucible disk.
#[derive(Debug)]
pub struct CrucibleDisk {
/// The name to use in instance specs that include this disk.
device_name: DeviceName,
disk_id: Uuid,
guest_os: Option<GuestOsKind>,
inner: Mutex<Inner>,
}

impl CrucibleDisk {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
device_name: DeviceName,
min_disk_size_gib: u64,
block_size: BlockSize,
downstairs_binary_path: &impl AsRef<std::ffi::OsStr>,
downstairs_ports: &[u16],
data_dir_root: &impl AsRef<Path>,
read_only_parent: Option<&impl AsRef<Path>>,
guest_os: Option<GuestOsKind>,
log_mode: ServerLogMode,
) -> anyhow::Result<Self> {
Ok(Self {
device_name,
disk_id: Uuid::new_v4(),
guest_os,
inner: Mutex::new(Inner::new(
min_disk_size_gib,
block_size,
downstairs_binary_path,
downstairs_ports,
data_dir_root,
read_only_parent,
log_mode,
)?),
})
}

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

/// Sets the generation number to use in subsequent calls to create a
/// backend spec for this disk.
pub fn set_generation(&self, generation: u64) {
self.inner.lock().unwrap().generation = generation;
}
}

impl super::DiskConfig for CrucibleDisk {
fn device_name(&self) -> &DeviceName {
&self.device_name
}

fn backend_spec(&self) -> ComponentV0 {
self.inner.lock().unwrap().backend_spec(self.disk_id)
}

fn guest_os(&self) -> Option<GuestOsKind> {
self.guest_os
}

fn as_crucible(&self) -> Option<&CrucibleDisk> {
Some(self)
}
}

#[derive(Debug)]
struct Inner {
/// The disk's block size.
block_size: BlockSize,

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

/// The kind of guest OS that can be found on this disk, if there is one.
guest_os: Option<GuestOsKind>,

/// The base64-encoded encryption key to use for this disk.
encryption_key: String,

/// The generation number to insert into this disk's
/// `VolumeConstructionRequest`s.
generation: AtomicU64,
generation: u64,
}

impl CrucibleDisk {
impl Inner {
/// Constructs a new Crucible disk that stores its files in the supplied
/// `data_dir`.
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
device_name: DeviceName,
min_disk_size_gib: u64,
block_size: BlockSize,
downstairs_binary_path: &impl AsRef<std::ffi::OsStr>,
downstairs_ports: &[u16],
data_dir_root: &impl AsRef<Path>,
read_only_parent: Option<&impl AsRef<Path>>,
guest_os: Option<GuestOsKind>,
log_mode: ServerLogMode,
) -> anyhow::Result<Self> {
// To create a region, Crucible requires a block size, an extent size
Expand Down Expand Up @@ -253,8 +309,6 @@ impl CrucibleDisk {
}

Ok(Self {
device_name,
id: disk_uuid,
block_size,
blocks_per_extent,
extent_count: extents_in_disk as u32,
Expand All @@ -269,37 +323,33 @@ impl CrucibleDisk {
bytes
},
),
guest_os,
generation: AtomicU64::new(1),
generation: 1,
})
}

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

impl super::DiskConfig for CrucibleDisk {
fn device_name(&self) -> &DeviceName {
&self.device_name
ComponentV0::CrucibleStorageBackend(CrucibleStorageBackend {
request_json: serde_json::to_string(&vcr)
.expect("VolumeConstructionRequest should serialize"),
readonly: false,
})
}

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

let vcr = VolumeConstructionRequest::Volume {
id: self.id,
VolumeConstructionRequest::Volume {
id: disk_id,
block_size: self.block_size.bytes(),
sub_volumes: vec![VolumeConstructionRequest::Region {
block_size: self.block_size.bytes(),
blocks_per_extent: self.blocks_per_extent,
extent_count: self.extent_count,
opts: CrucibleOpts {
id: Uuid::new_v4(),
id: disk_id,
target: downstairs_addrs,
lossy: false,
flush_timeout: None,
Expand All @@ -310,7 +360,7 @@ impl super::DiskConfig for CrucibleDisk {
control: None,
read_only: false,
},
gen,
r#gen: self.generation,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what I wanted to see!

}],
read_only_parent: self.read_only_parent.as_ref().map(|p| {
Box::new(VolumeConstructionRequest::File {
Expand All @@ -319,21 +369,7 @@ impl super::DiskConfig for CrucibleDisk {
path: p.to_string_lossy().to_string(),
})
}),
};

ComponentV0::CrucibleStorageBackend(CrucibleStorageBackend {
request_json: serde_json::to_string(&vcr)
.expect("VolumeConstructionRequest should serialize"),
readonly: false,
})
}

fn guest_os(&self) -> Option<GuestOsKind> {
self.guest_os
}

fn as_crucible(&self) -> Option<&CrucibleDisk> {
Some(self)
}
}
}

Expand Down
11 changes: 9 additions & 2 deletions phd-tests/framework/src/disk/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,8 @@ impl DiskFactory {
mut min_disk_size_gib: u64,
block_size: BlockSize,
) -> Result<Arc<CrucibleDisk>, DiskError> {
const BYTES_PER_GIB: u64 = 1024 * 1024 * 1024;

let binary_path = self.artifact_store.get_crucible_downstairs().await?;

let (artifact_path, guest_os) = match source {
Expand All @@ -287,8 +289,13 @@ impl DiskFactory {
(Some(path), Some(os))
}
DiskSource::Blank(size) => {
min_disk_size_gib = min_disk_size_gib
.max(u64::try_from(*size).map_err(anyhow::Error::from)?);
let blank_size =
u64::try_from(*size).map_err(anyhow::Error::from)?;

let min_disk_size_b =
(min_disk_size_gib * BYTES_PER_GIB).max(blank_size);

min_disk_size_gib = min_disk_size_b.div_ceil(BYTES_PER_GIB);
(None, None)
}
// It's possible in theory to have a Crucible-backed disk with
Expand Down
21 changes: 18 additions & 3 deletions phd-tests/framework/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,12 +252,27 @@ impl Framework {
config: &VmConfig<'_>,
environment: Option<&EnvironmentSpec>,
) -> anyhow::Result<TestVm> {
TestVm::new(
self,
self.spawn_vm_with_spec(
config
.vm_spec(self)
.await
.context("building VM config for test VM")?,
.context("building VM spec from VmConfig")?,
environment,
)
.await
}

/// Spawns a new test VM using the supplied `spec`. If `environment` is
/// `Some`, the VM is spawned using the supplied environment; otherwise it
/// is spawned using the default `environment_builder`.
pub async fn spawn_vm_with_spec(
&self,
spec: VmSpec,
environment: Option<&EnvironmentSpec>,
) -> anyhow::Result<TestVm> {
TestVm::new(
self,
spec,
environment.unwrap_or(&self.environment_builder()),
)
.await
Expand Down
2 changes: 1 addition & 1 deletion phd-tests/framework/src/test_vm/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ impl<'dr> VmConfig<'dr> {
self
}

pub(crate) async fn vm_spec(
pub async fn vm_spec(
&self,
framework: &Framework,
) -> anyhow::Result<VmSpec> {
Expand Down
34 changes: 34 additions & 0 deletions phd-tests/framework/src/test_vm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use std::{
};

use crate::{
disk::{crucible::CrucibleDisk, DiskConfig},
guest_os::{
self, windows::WindowsVm, CommandSequence, CommandSequenceEntry,
GuestOs, GuestOsKind,
Expand Down Expand Up @@ -603,6 +604,39 @@ impl TestVm {
Ok(self.client.instance_migrate_status().send().await?.into_inner())
}

pub async fn replace_crucible_vcr(
&self,
disk: &CrucibleDisk,
) -> anyhow::Result<()> {
let vcr = disk.vcr();
let body = propolis_client::types::InstanceVcrReplace {
vcr_json: serde_json::to_string(&vcr)
.with_context(|| format!("serializing VCR {vcr:?}"))?,
};

info!(
disk_name = disk.device_name().as_str(),
vcr = ?vcr,
"issuing Crucible VCR replacement request"
);

let response_value = self
.client
.instance_issue_crucible_vcr_request()
.id(disk.device_name().clone().into_backend_name().into_string())
.body(body)
.send()
.await?;

anyhow::ensure!(
response_value.status().is_success(),
"VCR replacement request returned an error value: \
{response_value:?}"
);

Ok(())
}

pub async fn get_serial_console_history(
&self,
from_start: u64,
Expand Down
9 changes: 9 additions & 0 deletions phd-tests/framework/src/test_vm/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ pub struct VmSpec {
}

impl VmSpec {
pub fn get_disk_by_device_name(
&self,
name: &str,
) -> Option<&Arc<dyn disk::DiskConfig>> {
self.disk_handles
.iter()
.find(|disk| disk.device_name().as_str() == name)
}

/// Update the Crucible backend specs in the instance spec to match the
/// current backend specs given by this specification's disk handles.
pub(crate) fn refresh_crucible_backends(&mut self) {
Expand Down
37 changes: 37 additions & 0 deletions phd-tests/tests/src/crucible/smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

use std::time::Duration;

use phd_framework::{
disk::{BlockSize, DiskSource},
test_vm::{DiskBackend, DiskInterface},
};
use phd_testcase::*;
use propolis_client::types::InstanceState;

Expand Down Expand Up @@ -82,3 +86,36 @@ async fn shutdown_persistence_test(ctx: &Framework) {
let lsout = vm.run_shell_command("ls foo.bar 2> /dev/null").await?;
assert_eq!(lsout, "foo.bar");
}

#[phd_testcase]
async fn vcr_replace_test(ctx: &Framework) {
let mut config = ctx.vm_config_builder("crucible_vcr_replace_test");

// Create a blank data disk on which to perform VCR replacement. This is
// necessary because Crucible doesn't permit VCR replacements for volumes
// whose read-only parents are local files (which is true for artifact-based
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should update that. Crucible does support replacement of read only parents now, so perhaps we need to update the file based backend?

Though, testing on a data disk is also needed, so not like this is bad or wrong.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're tracking this with oxidecomputer/crucible#1658.

// Crucible disks).
const DATA_DISK_NAME: &str = "vcr-replacement-target";
config.data_disk(
DATA_DISK_NAME,
DiskSource::Blank(1024 * 1024 * 1024),
DiskInterface::Nvme,
DiskBackend::Crucible {
min_disk_size_gib: 1,
block_size: BlockSize::Bytes512,
},
5,
);

let spec = config.vm_spec(ctx).await?;
let disk_hdl =
spec.get_disk_by_device_name(DATA_DISK_NAME).cloned().unwrap();
let disk = disk_hdl.as_crucible().unwrap();

let mut vm = ctx.spawn_vm_with_spec(spec, None).await?;
vm.launch().await?;
vm.wait_to_boot().await?;

disk.set_generation(2);
vm.replace_crucible_vcr(disk).await?;
}
Loading