Skip to content

Commit 13f7980

Browse files
Jakob-Nauckeuril
authored andcommitted
tests: Add Azure backend
Add integration tests backend for Azure (confidential VMs, no cluster join). Use Azure CLI to create a resource group per test, same name as the namespace (which can be prefixed). Auto-shutdown VMs after one hour. Signed-off-by: Jakob Naucke <jnaucke@redhat.com> Co-authored-by: Uri Lublin <uril@redhat.com> Assisted-by: Gemini, Claude
1 parent 5cdfdb4 commit 13f7980

5 files changed

Lines changed: 289 additions & 4 deletions

File tree

test_utils/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ pub fn compare_pcrs(actual: &[Pcr], expected: &[Pcr]) -> bool {
4545
true
4646
}
4747

48+
// Large warning frame, e.g. for paid cloud resources that may not have been shut down correctly
49+
pub fn warn_frame(msg: &str) -> String {
50+
format!("{YELLOW}=== WARNING ===\n{msg}{ANSI_RESET}")
51+
}
52+
4853
#[macro_export]
4954
macro_rules! test_info {
5055
($test_name:expr, $($arg:tt)*) => {{

test_utils/src/virt/azure.rs

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
// SPDX-FileCopyrightText: Jakob Naucke <jnaucke@redhat.com>
2+
//
3+
// SPDX-License-Identifier: MIT
4+
5+
use anyhow::{Context, Result, anyhow};
6+
use k8s_openapi::chrono::{self, Utc};
7+
use serde_json::Value;
8+
use std::{env, time};
9+
use tokio::process::Command;
10+
11+
use super::{VmBackend, VmConfig, generate_ignition, ssh_exec};
12+
use crate::{Poller, ensure_command, warn_frame};
13+
14+
const KEEP_ALIVE_MINUTES: i64 = 60;
15+
16+
pub struct AzureBackend {
17+
config: VmConfig,
18+
resource_group: String,
19+
}
20+
21+
impl AzureBackend {
22+
pub fn new(config: VmConfig) -> Result<Self> {
23+
let resource_group = config.namespace.clone();
24+
Ok(Self {
25+
config,
26+
resource_group,
27+
})
28+
}
29+
30+
async fn az(&self, args: &[&str]) -> Result<Value> {
31+
ensure_command("az")?;
32+
let out = ["--output", "json"];
33+
let output = Command::new("az").args(args).args(out).output().await?;
34+
35+
if !output.status.success() {
36+
let stderr = String::from_utf8_lossy(&output.stderr);
37+
return Err(anyhow!("az {} failed: {stderr}", args.join(" ")));
38+
}
39+
40+
let stdout = String::from_utf8_lossy(&output.stdout);
41+
if stdout.trim().is_empty() {
42+
return Ok(Value::Null);
43+
}
44+
serde_json::from_str(&stdout).context("Failed to parse az CLI output as JSON")
45+
}
46+
47+
async fn az_rg(&self, mut args: Vec<&str>) -> Result<()> {
48+
ensure_command("az")?;
49+
args.extend(["--resource-group", &self.resource_group, "--output", "none"]);
50+
let output = Command::new("az").args(&args).output().await?;
51+
52+
if !output.status.success() {
53+
let stderr = String::from_utf8_lossy(&output.stderr);
54+
return Err(anyhow!("az {} failed: {stderr}", args.join(" ")));
55+
}
56+
Ok(())
57+
}
58+
}
59+
60+
#[async_trait::async_trait]
61+
impl VmBackend for AzureBackend {
62+
async fn create_vm(&self) -> Result<()> {
63+
let vm = &self.config.vm_name;
64+
let vnet = &format!("{vm}-vnet");
65+
let ip = &format!("{vm}-ip");
66+
let nsg = &format!("{vm}-nsg");
67+
let nic = &format!("{vm}-nic");
68+
69+
let location = env::var("AZURE_LOCATION").unwrap_or("eastus".to_string());
70+
let mut args = vec!["group", "create", "--name", &self.resource_group];
71+
args.extend(["--location", &location]);
72+
let output = Command::new("az").args(args).output().await?;
73+
if !output.status.success() {
74+
let stderr = String::from_utf8_lossy(&output.stderr);
75+
return Err(anyhow!("az group create failed: {stderr}"));
76+
}
77+
78+
let mut args = vec!["network", "vnet", "create", "--name", vnet];
79+
args.extend(["--address-prefix", "10.0.0.0/16"]);
80+
args.extend(["--subnet-name", "default", "--subnet-prefix", "10.0.0.0/24"]);
81+
self.az_rg(args).await?;
82+
83+
let mut args = vec!["network", "public-ip", "create", "--name", ip];
84+
args.extend(["--sku", "Standard", "--allocation-method", "Static"]);
85+
self.az_rg(args).await?;
86+
87+
self.az_rg(vec!["network", "nsg", "create", "--name", nsg])
88+
.await?;
89+
90+
let mut args = vec!["network", "nsg", "rule", "create", "--nsg-name", nsg];
91+
args.extend(["--name", "AllowSSH", "--protocol", "Tcp"]);
92+
args.extend(["--priority", "1000", "--destination-port-range", "22"]);
93+
args.extend(["--access", "Allow", "--direction", "Inbound"]);
94+
self.az_rg(args).await?;
95+
96+
let mut args = vec!["network", "nic", "create", "--name", nic];
97+
args.extend(["--vnet-name", vnet, "--subnet", "default"]);
98+
args.extend(["--network-security-group", nsg, "--public-ip-address", ip]);
99+
self.az_rg(args).await?;
100+
101+
let ign = generate_ignition(&self.config, false).await?;
102+
let custom_data = ign.to_string();
103+
if !self.config.image.starts_with('/') && self.config.image.split(':').count() < 4 {
104+
let err = "Invalid Image URN. Expected 'Publisher:Offer:Sku:Version'";
105+
return Err(anyhow!(err));
106+
}
107+
108+
let mut args = vec!["vm", "create", "--name", vm, "--nics", nic];
109+
args.extend(["--image", &self.config.image, "--size", "Standard_DC2as_v5"]);
110+
args.extend(["--os-disk-delete-option", "Delete"]);
111+
args.extend(["--storage-sku", "StandardSSD_LRS"]);
112+
args.extend(["--admin-username", "core"]);
113+
args.extend(["--ssh-key-values", &self.config.ssh_public_key]);
114+
args.extend(["--custom-data", &custom_data]);
115+
args.extend(["--security-type", "ConfidentialVM"]);
116+
args.extend(["--enable-secure-boot", "true", "--enable-vtpm", "true"]);
117+
args.extend(["--os-disk-security-encryption-type", "VMGuestStateOnly"]);
118+
119+
let err = format!(
120+
"Request to create the VM {vm} has failed, but it may still have been created. \
121+
Log in manually to verify the VM does not keep running."
122+
);
123+
self.az_rg(args).await.context(warn_frame(&err))?;
124+
125+
// Schedule auto-shutdown to control costs if cleanup fails
126+
let shutdown_time = Utc::now() + chrono::Duration::minutes(KEEP_ALIVE_MINUTES);
127+
let shutdown_str = shutdown_time.format("%H%M").to_string();
128+
let mut args = vec!["vm", "auto-shutdown", "--name", vm];
129+
args.extend(["--time", &shutdown_str]);
130+
let err = format!(
131+
"Request to auto-shutdown the VM {vm} has failed. \
132+
Log in manually to verify the VM was removed correctly."
133+
);
134+
self.az_rg(args).await.context(warn_frame(&err))?;
135+
136+
Ok(())
137+
}
138+
139+
async fn wait_for_running(&self, timeout_secs: u64) -> Result<()> {
140+
let vm_name = &self.config.vm_name;
141+
let poller = Poller::new()
142+
.with_timeout(time::Duration::from_secs(timeout_secs))
143+
.with_interval(time::Duration::from_secs(5))
144+
.with_error_message(format!(
145+
"virtualMachine {vm_name} did not reach PowerState/running after {timeout_secs}s",
146+
));
147+
148+
let args = [
149+
"vm",
150+
"get-instance-view",
151+
"--resource-group",
152+
&self.resource_group,
153+
"--name",
154+
&self.config.vm_name,
155+
];
156+
let check_fn = || async move {
157+
let result = self.az(&args).await?;
158+
let statuses = result["instanceView"]["statuses"].as_array().unwrap();
159+
let check = |s: &&Value| s["code"] == "PowerState/running";
160+
let err = anyhow!("virtualMachine {vm_name} is not in running PowerState yet",);
161+
statuses.iter().find(check).map(|_| ()).ok_or(err)
162+
};
163+
poller.poll_async(check_fn).await
164+
}
165+
166+
async fn ssh_exec(&self, command: &str) -> Result<String> {
167+
let (rg, ip_name) = (&self.resource_group, format!("{}-ip", self.config.vm_name));
168+
let mut args = vec!["network", "public-ip", "show", "--resource-group", rg];
169+
args.extend(["--name", &ip_name]);
170+
let result = self.az(&args).await?;
171+
172+
let public_ip = result["ipAddress"].as_str().unwrap();
173+
ssh_exec(&format!(
174+
"ssh -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null core@{public_ip} '{command}'",
175+
self.config.ssh_private_key.display()
176+
)).await
177+
}
178+
179+
async fn get_root_key(&self) -> Result<Option<Vec<u8>>> {
180+
Ok(None)
181+
}
182+
183+
async fn cleanup(&self) -> Result<()> {
184+
self.config.cleanup();
185+
let rg = &self.resource_group;
186+
187+
let args1 = ["group", "delete", "--name", rg];
188+
let args2 = ["--yes", "--no-wait"];
189+
let output = Command::new("az").args(args1).args(args2).output().await?;
190+
191+
if !output.status.success() {
192+
let stderr = String::from_utf8_lossy(&output.stderr);
193+
let err = format!(
194+
"Request to cleanup the Azure resource group {rg} failed. \
195+
Log in manually to verify the resource group was removed correctly."
196+
);
197+
return Err(anyhow!("az group delete failed: {stderr}").context(warn_frame(&err)));
198+
}
199+
Ok(())
200+
}
201+
}

test_utils/src/virt/mod.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
//
44
// SPDX-License-Identifier: MIT
55

6+
pub mod azure;
67
pub mod kubevirt;
78

89
use anyhow::{Context, Result, anyhow};
@@ -179,14 +180,16 @@ pub async fn get_root_key(config: &VmConfig, ip: &str) -> Result<Vec<u8>> {
179180
pub enum VirtProvider {
180181
#[default]
181182
Kubevirt,
183+
Azure,
182184
}
183185

184186
fn get_virt_provider() -> Result<VirtProvider> {
185187
match env::var(VIRT_PROVIDER_ENV) {
186188
Ok(val) => match val.to_lowercase().as_str() {
187189
"kubevirt" => Ok(VirtProvider::Kubevirt),
190+
"azure" => Ok(VirtProvider::Azure),
188191
v => Err(anyhow!(
189-
"Unknown {VIRT_PROVIDER_ENV} '{v}'. Supported providers: kubevirt"
192+
"Unknown {VIRT_PROVIDER_ENV} '{v}'. Supported providers: kubevirt, azure"
190193
)),
191194
},
192195
Err(env::VarError::NotPresent) => Ok(VirtProvider::default()),
@@ -212,6 +215,7 @@ pub fn create_backend(
212215
};
213216
match provider {
214217
VirtProvider::Kubevirt => Ok(Box::new(kubevirt::KubevirtBackend(config))),
218+
VirtProvider::Azure => Ok(Box::new(azure::AzureBackend::new(config)?)),
215219
}
216220
}
217221

tests/README.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,78 @@ Each test can also be run independently using cargo test. Example:
2626
```bash
2727
$ cargo test test_trusted_execution_cluster_uninstall -- --no-capture
2828
```
29+
30+
## Run integration tests distributedly
31+
32+
When running integration tests elsewhere than on a local Kind cluster, set `$PLATFORM` to something else than `kind`.
33+
Take into consideration:
34+
35+
- how VMs you are attesting can connect to services like Trustee
36+
- how container images can be made available to the cluster
37+
38+
For enabling connection, you can export `$CLUSTER_URL`.
39+
On OpenShift, you can instead export `PLATFORM=openshift` and the integration tests will use `oc expose` to expose services automatically.
40+
41+
For container images, you can export `REGISTRY` to something non-local, e.g. something public like quay.io.
42+
On OpenShift, you can also use the internal registry:
43+
44+
```bash
45+
# Define a namespace to push images to. Here, we name it after the user itself.
46+
NAMESPACE=$(oc whoami)
47+
oc create ns $NAMESPACE
48+
HOST=$(oc get route default-route -n openshift-image-registry --template='{{ .spec.host }}')
49+
podman login $HOST -u $(oc whoami) -p $(oc whoami -t)
50+
export REGISTRY=$HOST/$NAMESPACE
51+
make push
52+
# Export REGISTRY to the internal URL
53+
export REGISTRY=image-registry.openshift-image-registry.svc:5000/$NAMESPACE
54+
```
55+
56+
### Run integration tests on Azure
57+
58+
Like the KubeVirt tests, the Azure integration tests create CoreOS VMs that retrieve a disk encryption key from Trustee, but do not execute a cluster join.
59+
They rely on real [Azure Confidential VMs](https://learn.microsoft.com/en-us/azure/confidential-computing/confidential-vm-overview) and Trustee's az-snp-vtpm attester.
60+
61+
#### Preparing an image
62+
63+
The image to boot the VM from must reside in a _storage container_ inside a _storage account_ and be referenced to from a _compute gallery_, all of which exist in a _resource group_.
64+
This guide assumes that you have created the Azure resources in italics, and that you are logged in with the Azure CLI.
65+
66+
Create a CoreOS Azure image VHD, e.g. as per the [investigations repository](https://github.com/trusted-execution-clusters/investigations), **without the `tpm-attester` feature** in its trustee-attester until [trustee-gc#1277](https://github.com/confidential-containers/guest-components/issues/1277) is resolved.
67+
Upload this blob and create an image definition and version based on it:
68+
69+
```bash
70+
# Export $AZURE_SUBSCRIPTION_ID
71+
# Set $resource_group, $storage_account, $storage_container, $compute_gallery
72+
# Set $image, e.g. fedora-coreos-<version>-azure.x86_64.vhd
73+
# Set image definition name, e.g. image_definition=fcos-cvm, and image version, e.g. image_version=0.1.0
74+
75+
# Retrieve connection string & storage account ID
76+
cs=$(az storage account show-connection-string -g $resource_group -n $storage_account | jq -r .connectionString)
77+
# Upload blob. We keep the file name here, but you can set the blob name in Azure
78+
# to something else by changing the argument to -n.
79+
az storage blob upload --connection-string $cs -c $storage_container -f $image -n $image
80+
# Create image definition
81+
az sig image-definition create -g $resource_group -r $compute_gallery -i $image_definition \
82+
--publisher example --offer example --sku standard \
83+
--features SecurityType=ConfidentialVmSupported --os-type Linux --hyper-v-generation V2
84+
# Create image version. Adapt the name of the blob accordingly if you changed it above.
85+
az sig image-version create -g $resource_group -r $compute_gallery -i $image_definition -e $image_version \
86+
--os-vhd-storage-account /subscriptions/$AZURE_SUBSCRIPTION_ID/resourceGroups/$resource_group/providers/Microsoft.Storage/storageAccounts/$storage_account \
87+
--os-vhd-uri https://$storage_account.blob.core.windows.net/$storage_container/$image
88+
89+
# Set $TEST_IMAGE for the test
90+
export TEST_IMAGE=/subscriptions/$AZURE_SUBSCRIPTION_ID/resourceGroups/$resource_group/providers/Microsoft.Compute/galleries/$compute_gallery/images/$image_definition/versions/$image_version
91+
```
92+
93+
#### Running the tests
94+
95+
To avoid confusion on shared subscriptions and/or clusters, you can export `TEST_NAMESPACE_PREFIX` and the created namespaces and resource groups will bear that prefix.
96+
97+
```bash
98+
# Export $AZURE_SUBSCRIPTION_ID, $TEST_IMAGE as above
99+
# Set VIRT_PROVIDER
100+
export VIRT_PROVIDER=azure
101+
# Run tests, or run individual tests as described above
102+
make integration-tests
103+
```

tests/attestation.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ impl SingleAttestationContext {
4444
backend.create_vm().await?;
4545

4646
test_ctx.info(format!("Waiting for VM {} to reach Running state", vm_name));
47-
backend.wait_for_running(300).await?;
47+
backend.wait_for_running(600).await?;
4848
test_ctx.info(format!("VM {} is Running", vm_name));
4949

5050
test_ctx.info(format!("Waiting for SSH access to VM {}", vm_name));
@@ -101,8 +101,8 @@ async fn test_parallel_vm_attestation() -> anyhow::Result<()> {
101101
// Wait for both VMs to reach Running state in parallel
102102
test_ctx.info("Waiting for both VMs to reach Running state");
103103
let (vm1_running, vm2_running) = tokio::join!(
104-
backend1.wait_for_running(300),
105-
backend2.wait_for_running(300)
104+
backend1.wait_for_running(600),
105+
backend2.wait_for_running(600)
106106
);
107107

108108
vm1_running?;

0 commit comments

Comments
 (0)