Skip to content
Open
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
25 changes: 22 additions & 3 deletions .github/workflows/coverage-report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,31 @@ jobs:
tool: cargo-llvm-cov
fallback: none

- name: Generate code coverage
run: cargo llvm-cov --locked --all-features --workspace --lcov --output-path lcov.info
- name: Test the isolated provisioning runner
run: python3 -B -m unittest discover -s testinit/testing-server -p 'test_*.py' -v

- name: Build the isolated provisioning image
run: |
. /etc/os-release
docker build --build-arg "BASE_IMAGE=ubuntu:${VERSION_ID}" -f testinit/Dockerfile.isolated -t azure-init-main-tests:local testinit

- name: Collect unit and CLI coverage
run: |
cargo llvm-cov clean --workspace
cargo llvm-cov --locked --all-features --workspace --no-report

- name: Collect real entrypoint coverage
run: cargo llvm-cov --locked --all-features --package azure-init --test testinit --no-report -- --ignored agent_provisioning_in_container

- name: Generate combined code coverage
run: cargo llvm-cov report --package azure-init --package libazureinit --package libazureinit-kvp --lcov --output-path lcov.info

- name: Cover compile-time version variants
run: python3 -B testinit/testing-server/version_coverage.py

- name: Upload coverage to Codecov
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: lcov.info
files: lcov.info,target/version-override.lcov,target/version-sha.lcov,target/version-package.lcov
fail_ci_if_error: true
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ path = "src/main.rs"
name = "functional_tests"
path = "tests/functional_tests.rs"

[[test]]
name = "testinit"
path = "testinit/tests/provisioning.rs"

[workspace]
members = [
"libazureinit",
Expand Down
102 changes: 91 additions & 11 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,17 @@ fn failure_report(error: &anyhow::Error, vm_id: &str) -> ProvisioningReport {
})
}

fn provisioning_error_exit_code(error: &anyhow::Error) -> ExitCode {
let config: u8 = exitcode::CONFIG
.try_into()
.expect("Error code must be less than 256");
match error.root_cause().downcast_ref::<LibError>() {
Some(LibError::UserMissing { user: _ }) => ExitCode::from(config),
Some(LibError::NonEmptyPassword) => ExitCode::from(config),
Some(_) | None => ExitCode::FAILURE,
}
}

async fn write_kvp_report(
store: &KvpPoolStore,
report: &ProvisioningReport,
Expand Down Expand Up @@ -436,17 +447,7 @@ async fn main() -> ExitCode {
}
Err(e) => {
tracing::error!("Provisioning failed with error: {e:?}");

let config: u8 = exitcode::CONFIG
.try_into()
.expect("Error code must be less than 256");
match e.root_cause().downcast_ref::<LibError>() {
Some(LibError::UserMissing { user: _ }) => {
ExitCode::from(config)
}
Some(LibError::NonEmptyPassword) => ExitCode::from(config),
Some(_) | None => ExitCode::FAILURE,
}
provisioning_error_exit_code(&e)
}
}
};
Expand Down Expand Up @@ -557,6 +558,85 @@ mod tests {

const VM_ID: &str = "00000000-0000-0000-0000-000000000001";

#[rstest]
#[case::imds_preferred(true, true, Some("imds-user"))]
#[case::imds_only(true, false, Some("imds-user"))]
#[case::ovf_fallback(false, true, Some("ovf-user"))]
#[case::missing_sources(false, false, None)]
fn username_source_precedence(
#[case] imds_available: bool,
#[case] ovf_available: bool,
#[case] expected: Option<&str>,
) {
let metadata: InstanceMetadata = serde_json::from_value(json!({
"compute": {
"osProfile": {
"adminUsername": "imds-user",
"computerName": "test-host",
"disablePasswordAuthentication": "true"
},
"publicKeys": []
}
}))
.unwrap();
let mut environment = Environment::default();
environment
.provisioning_section
.linux_prov_conf_set
.username = "ovf-user".into();
let result = get_username(
imds_available.then_some(&metadata),
ovf_available.then_some(&environment),
);
match expected {
Some(username) => assert_eq!(result.unwrap(), username),
None => assert!(matches!(
result.unwrap_err().downcast_ref::<LibError>(),
Some(LibError::UsernameFailure)
)),
}
}

#[rstest]
#[case::existing("file", Ok(()))]
#[case::missing("missing", Ok(()))]
#[case::directory("directory", Err(io::ErrorKind::IsADirectory))]
fn log_cleanup_preserves_io_results(
#[case] entry: &str,
#[case] expected: Result<(), io::ErrorKind>,
) {
let dir = TempDir::new().unwrap();
let log = dir.path().join("azure-init.log");
match entry {
"file" => std::fs::write(&log, "log").unwrap(),
"directory" => std::fs::create_dir(&log).unwrap(),
_ => {}
}
let mut config = Config::default();
config.azure_init_log_path.path = log.clone();
assert_eq!(
clean_log_file(&config).map_err(|error| error.kind()),
expected
);
assert_eq!(log.exists(), expected.is_err());
}

#[rstest]
#[case::missing_user(LibError::UserMissing { user: "missing".into() }.into(), 78)]
#[case::nonempty_password(LibError::NonEmptyPassword.into(), 78)]
#[case::other_typed_error(LibError::UsernameFailure.into(), 1)]
#[case::untyped_error(anyhow::anyhow!("unexpected failure"), 1)]
fn provisioning_error_exit_codes_preserve_context(
#[case] error: anyhow::Error,
#[case] expected: u8,
) {
let error = error.context("provisioning failed");
assert_eq!(
format!("{:?}", provisioning_error_exit_code(&error)),
format!("{:?}", ExitCode::from(expected))
);
}

#[test]
fn clean_reports_subscriber_conflict() {
const CHILD: &str = "AZURE_INIT_TEST_MAIN_SUBSCRIBER_CONFLICT";
Expand Down
13 changes: 13 additions & 0 deletions testinit/Dockerfile.isolated
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
ARG BASE_IMAGE=ubuntu:24.04
FROM ${BASE_IMAGE}

RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 libudev1 \
&& rm -rf /var/lib/apt/lists \
&& mkdir -p /test /work /var/lib/hyperv /etc/sudoers.d /run/azure-init/media \
&& touch /test/isolated-provisioning

COPY testing-server/container_provisioning.py testing-server/imds_handler.py \
testing-server/wireserver_handler.py testing-server/utils.py /test/

ENTRYPOINT ["python3", "-B", "/test/container_provisioning.py", "--inside"]
39 changes: 30 additions & 9 deletions testinit/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Azure-Init - Testinit Project

A containerized Azure provisioning agent that simulates Azure Instance Metadata Service (IMDS) and WireServer for testing and development environments.
Testinit provides isolated provisioning tests and systemd-based E2E tests. Both use shared mock Azure Instance Metadata Service (IMDS) and WireServer handlers.

> [!WARNING]
>
Expand All @@ -11,11 +11,12 @@ A containerized Azure provisioning agent that simulates Azure Instance Metadata

## Overview

This project consists of two main components:
- **Provisioning Agent**: A systemd-based service running `azure-init` binary in a container
This project consists of:
- **Isolated Tests**: The real Cargo-built agent running with stubbed OS commands in disposable, unprivileged containers
- **Provisioning Agent**: A systemd-based service running the `azure-init` binary in a privileged container
- **Testing Server**: A mock Azure service providing IMDS and WireServer endpoints

## Architecture
## Systemd Architecture

The setup creates two Docker networks with Azure-like IP addresses:
- `imds-network` (169.254.0.0/16) - For IMDS communication
Expand All @@ -25,9 +26,28 @@ The setup creates two Docker networks with Azure-like IP addresses:

- Docker and Docker Compose
- WSL2 or Linux environment
- The `azure-init` binary and service file
- Python 3, Rust, and libudev development headers for isolated tests

## Quick Start
## Isolated Provisioning Tests

The [Cargo test](tests/provisioning.rs) runs the real agent with stubbed OS
commands and shared mock services, without the privileged Compose setup.
From the repository root on Ubuntu:

```bash
python3 -B -m unittest discover -s testinit/testing-server -p 'test_*.py' -v
. /etc/os-release
docker build --build-arg "BASE_IMAGE=ubuntu:${VERSION_ID}" \
-f testinit/Dockerfile.isolated -t azure-init-main-tests:local testinit
cargo test --locked --all-features --package azure-init --test testinit \
-- --ignored agent_provisioning_in_container
```

This Docker-dependent test is ignored by default. The
[coverage workflow](../.github/workflows/coverage-report.yml) runs it and
uploads combined coverage to Codecov.

## Systemd E2E Quick Start

### Starting Services

Expand Down Expand Up @@ -104,6 +124,7 @@ docker compose logs -f testing-server

### Building

The Dockerfile expects:
- `azure-init/target/debug/azure-init` - The main binary (compiled via `cargo build`)
- `azure-init/config/azure-init.service` - The systemd service file
The systemd [Dockerfile](Dockerfile) builds the repository source with
`cargo build` and installs [azure-init.service](../config/azure-init.service).
The isolated [Dockerfile](Dockerfile.isolated) contains only the fixture runtime
and shared mock handlers; its agent binary is mounted by the Cargo test.
Loading
Loading