Skip to content

Commit

Permalink
PHD: refactor & add support Propolis server "environments" (#547)
Browse files Browse the repository at this point in the history
Rework the way PHD tests create VMs to introduce the notion of an "environment"
in which a VM runs. In this model, starting a test VM requires a test to
describe both the virtualized guest's configuration (CPUs, memory, disks, and so
forth) and the Propolis server environment that should host it (local vs. remote
machine, Propolis server version, etc.). Test VMs store their configurations and
yield them on request. This allows tests to create "successors" to a test VM
that have the same configuration but might run in a different environment.

Set up a basic helper for running instance lifecycle tests using the new
framework. A test case supplies a VM, a list of actions (reboot the VM, start
and stop it in a new Propolis, or migrate it to a new environment), and a
closure that checks invariants after each action. This allows test authors to
write low-boilerplate tests that check that an invariant holds after, say,
migrating from an older Propolis version to a new one, or from one test machine
to another. Add a "starter" test case that runs `lspci` and `lshw` in a guest
and verifies that the results are consistent across a start/stop transition.

To support the notion of migrating between Propolis versions, environments
specify Propolis binaries as artifacts. To maintain compatibility with the test
runner's `--propolis-server-cmd` switch, add artifact store code to import a
runner-supplied Propolis server as an artifact with a well-known name that test
cases can refer to. Future changes will add additional well-known Propolis
binaries like "whatever binary Buildomat says is at the head of the main
Propolis branch."

Change the `phd-build` job to publish the Propolis server binaries it builds.
This is necessary to refer to them as Buildomat artifacts in the artifact store.
(This needs to exist in at least one build to enable further development of the
"implicit Buildomat HEAD artifact" series of changes.)

Finally, touch up the way test cases refer to disks. Previously, tests created
disk handles and passed them to the VM configuration builder. Now, tests just
have to define a VM configuration and can ignore the "disk handle" abstraction
if they don't use it. The tradeoff is that tests that want to work with
interfaces specific to Crucible disks need to explicitly cast their disk handles
to that type, but this seems like a small price to pay to make other tests more
concise.

Tested with local PHD runs.
  • Loading branch information
gjcolombo authored Oct 12, 2023
1 parent 9808671 commit ed584e8
Show file tree
Hide file tree
Showing 28 changed files with 1,145 additions and 739 deletions.
10 changes: 10 additions & 0 deletions .github/buildomat/jobs/phd-build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@
#: output_rules = [
#: "/out/*",
#: ]
#:
#: [[publish]]
#: series = "phd_build"
#: name = "propolis-server.tar.gz"
#: from_output = "/out/propolis-server-debug.tar.gz"
#:
#: [[publish]]
#: series = "phd_build"
#: name = "propolis-server.sha256.txt"
#: from_output = "/out/propolis-server-debug.sha256.txt"

set -o errexit
set -o pipefail
Expand Down
7 changes: 4 additions & 3 deletions phd-tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ path = "/home/oxide/propolis/target/debug"
## Authoring tests

PHD's test cases live in the `tests` crate. To write a new test, add a function
of the form `fn my_test(ctx: &TestContext)` and tag it with the
of the form `fn my_test(ctx: &Framework)` and tag it with the
`#[phd_testcase]` attribute macro. The framework will automatically register the
test into the crate's test inventory for the runner to discover.

Expand All @@ -156,8 +156,9 @@ function body is reached. This means that

### Test context

The `TestContext` structure contains a `VmFactory` helper object that tests can
use to construct VMs. See the module documentation for more information.
Every test gets a `phd_testcase::Framework` that contains helper methods for
constructing VMs and their execution environments. See the module documentation
for more information.

The tests in `tests/src/smoke.rs` provide some simple examples of using the
factory to customize and launch a new VM.
Expand Down
2 changes: 2 additions & 0 deletions phd-tests/framework/src/artifacts/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ mod store;

pub use store::Store as ArtifactStore;

pub const DEFAULT_PROPOLIS_ARTIFACT: &str = "__DEFAULT_PROPOLIS";

#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum ArtifactKind {
Expand Down
96 changes: 76 additions & 20 deletions phd-tests/framework/src/artifacts/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

use crate::{
artifacts::{manifest::Manifest, ArtifactSource},
artifacts::{
manifest::Manifest, ArtifactSource, DEFAULT_PROPOLIS_ARTIFACT,
},
guest_os::GuestOsKind,
};

Expand All @@ -24,6 +26,10 @@ struct StoredArtifact {
}

impl StoredArtifact {
fn new(description: super::Artifact) -> Self {
Self { description, cached_path: None }
}

fn ensure(
&mut self,
local_dir: &Utf8Path,
Expand Down Expand Up @@ -194,31 +200,63 @@ impl Store {
let Manifest { artifacts, remote_server_uris } = manifest;
let artifacts = artifacts
.into_iter()
.map(|(k, v)| {
(
k,
Mutex::new(StoredArtifact {
description: v,
cached_path: None,
}),
)
})
.map(|(k, v)| (k, Mutex::new(StoredArtifact::new(v))))
.collect();

let store = Self { local_dir, artifacts, remote_server_uris };
info!(?store, "Created new artifact store from manifest");
store
}

pub fn add_propolis_from_local_cmd(
&mut self,
propolis_server_cmd: &Utf8Path,
) -> anyhow::Result<()> {
if self.artifacts.contains_key(DEFAULT_PROPOLIS_ARTIFACT) {
anyhow::bail!(
"artifact store already contains key {}",
DEFAULT_PROPOLIS_ARTIFACT
);
}

let full_path = propolis_server_cmd.canonicalize_utf8()?;
let filename = full_path.file_name().ok_or_else(|| {
anyhow::anyhow!(
"Propolis server command '{}' contains no file component",
propolis_server_cmd
)
})?;
let dir = full_path.parent().ok_or_else(|| {
anyhow::anyhow!(
"canonicalized Propolis path '{}' has no directory component",
full_path
)
})?;

let artifact = super::Artifact {
filename: filename.to_string(),
kind: super::ArtifactKind::PropolisServer,
source: super::ArtifactSource::LocalPath {
path: dir.to_path_buf(),
sha256: None,
},
};

let _old = self.artifacts.insert(
DEFAULT_PROPOLIS_ARTIFACT.to_string(),
Mutex::new(StoredArtifact::new(artifact)),
);
assert!(_old.is_none());
Ok(())
}

pub fn get_guest_os_image(
&self,
artifact_name: &str,
) -> anyhow::Result<(Utf8PathBuf, GuestOsKind)> {
let entry = if let Some(e) = self.artifacts.get(artifact_name) {
e
} else {
anyhow::bail!("artifact {} not found in store", artifact_name);
};
let entry = self.artifacts.get(artifact_name).ok_or_else(|| {
anyhow::anyhow!("artifact {} not found in store", artifact_name)
})?;

let mut guard = entry.lock().unwrap();
match guard.description.kind {
Expand All @@ -238,11 +276,9 @@ impl Store {
&self,
artifact_name: &str,
) -> anyhow::Result<Utf8PathBuf> {
let entry = if let Some(e) = self.artifacts.get(artifact_name) {
e
} else {
anyhow::bail!("artifact {} not found in store", artifact_name);
};
let entry = self.artifacts.get(artifact_name).ok_or_else(|| {
anyhow::anyhow!("artifact {} not found in store", artifact_name)
})?;

let mut guard = entry.lock().unwrap();
match guard.description.kind {
Expand All @@ -255,6 +291,26 @@ impl Store {
)),
}
}

pub fn get_propolis_server(
&self,
artifact_name: &str,
) -> anyhow::Result<Utf8PathBuf> {
let entry = self.artifacts.get(artifact_name).ok_or_else(|| {
anyhow::anyhow!("artifact {} not found in store", artifact_name)
})?;

let mut guard = entry.lock().unwrap();
match guard.description.kind {
super::ArtifactKind::PropolisServer => {
guard.ensure(&self.local_dir, &self.remote_server_uris)
}
_ => Err(anyhow::anyhow!(
"artifact {} is not a Propolis server",
artifact_name
)),
}
}
}

fn sha256_digest(file: &mut File) -> anyhow::Result<Digest> {
Expand Down
26 changes: 19 additions & 7 deletions phd-tests/framework/src/disk/crucible.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ impl Drop for Downstairs {
/// An RAII wrapper around a Crucible disk.
#[derive(Debug)]
pub struct CrucibleDisk {
/// The name of the backend to use in instance specs that include this disk.
backend_name: String,

/// The UUID to insert into this disk's `VolumeConstructionRequest`s.
id: Uuid,

Expand Down Expand Up @@ -91,6 +94,7 @@ impl CrucibleDisk {
/// `data_dir`.
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
backend_name: String,
disk_size_gib: u64,
block_size: BlockSize,
downstairs_binary_path: &impl AsRef<std::ffi::OsStr>,
Expand Down Expand Up @@ -205,6 +209,7 @@ impl CrucibleDisk {
}

Ok(Self {
backend_name,
id: disk_uuid,
block_size,
blocks_per_extent,
Expand Down Expand Up @@ -233,7 +238,7 @@ impl CrucibleDisk {
}

impl super::DiskConfig for CrucibleDisk {
fn backend_spec(&self) -> instance_spec::v0::StorageBackendV0 {
fn backend_spec(&self) -> (String, instance_spec::v0::StorageBackendV0) {
let gen = self.generation.load(Ordering::Relaxed);
let downstairs_addrs =
self.downstairs_instances.iter().map(|ds| ds.address).collect();
Expand Down Expand Up @@ -268,18 +273,25 @@ impl super::DiskConfig for CrucibleDisk {
}),
};

instance_spec::v0::StorageBackendV0::Crucible(
instance_spec::components::backends::CrucibleStorageBackend {
request_json: serde_json::to_string(&vcr)
.expect("VolumeConstructionRequest should serialize"),
readonly: false,
},
(
self.backend_name.clone(),
instance_spec::v0::StorageBackendV0::Crucible(
instance_spec::components::backends::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)
}
}

fn run_crucible_downstairs(
Expand Down
19 changes: 13 additions & 6 deletions phd-tests/framework/src/disk/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ use crate::guest_os::GuestOsKind;
/// An RAII wrapper for a disk wrapped by a file.
#[derive(Debug)]
pub struct FileBackedDisk {
/// The name to use for instance spec backends that refer to this disk.
backend_name: String,

/// The path at which the disk is stored.
disk_path: PathBuf,

Expand All @@ -29,6 +32,7 @@ impl FileBackedDisk {
/// Creates a new file-backed disk whose initial contents are copied from
/// the specified artifact on the host file system.
pub(crate) fn new_from_artifact(
backend_name: String,
artifact_path: &impl AsRef<Path>,
data_dir: &impl AsRef<Path>,
guest_os: Option<GuestOsKind>,
Expand All @@ -55,16 +59,19 @@ impl FileBackedDisk {
permissions.set_readonly(false);
disk_file.set_permissions(permissions)?;

Ok(Self { disk_path, guest_os })
Ok(Self { backend_name, disk_path, guest_os })
}
}

impl super::DiskConfig for FileBackedDisk {
fn backend_spec(&self) -> StorageBackendV0 {
StorageBackendV0::File(FileStorageBackend {
path: self.disk_path.to_string_lossy().to_string(),
readonly: false,
})
fn backend_spec(&self) -> (String, StorageBackendV0) {
(
self.backend_name.clone(),
StorageBackendV0::File(FileStorageBackend {
path: self.disk_path.to_string_lossy().to_string(),
readonly: false,
}),
)
}

fn guest_os(&self) -> Option<GuestOsKind> {
Expand Down
Loading

0 comments on commit ed584e8

Please sign in to comment.