From 6d99c9ac4b2627ea8c126acc1f378144b2bcf54e Mon Sep 17 00:00:00 2001 From: peytonr18 Date: Mon, 9 Nov 2026 09:31:37 -0800 Subject: [PATCH 1/7] test(cli): add isolated real-agent provisioning coverage --- .github/workflows/coverage-report.yml | 20 +- tests/cli.rs | 56 ++++ tests/support/Dockerfile | 12 + tests/support/container_provisioning.py | 270 +++++++++++++++++++ tests/support/test_container_provisioning.py | 130 +++++++++ 5 files changed, 486 insertions(+), 2 deletions(-) create mode 100644 tests/support/Dockerfile create mode 100644 tests/support/container_provisioning.py create mode 100644 tests/support/test_container_provisioning.py diff --git a/.github/workflows/coverage-report.yml b/.github/workflows/coverage-report.yml index 52d4eb2d..ca7162fa 100644 --- a/.github/workflows/coverage-report.yml +++ b/.github/workflows/coverage-report.yml @@ -45,8 +45,24 @@ 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 -m unittest discover -s tests/support -p test_container_provisioning.py -v + + - name: Build the isolated provisioning image + run: | + . /etc/os-release + docker build --build-arg "BASE_IMAGE=ubuntu:${VERSION_ID}" -t azure-init-main-tests:local tests/support + + - name: Collect unit and CLI coverage + run: | + cargo llvm-cov clean --workspace + cargo llvm-cov --locked --all-features --workspace --no-clean --no-report + + - name: Collect real entrypoint coverage + run: cargo llvm-cov --locked --all-features --package azure-init --test cli --no-clean --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: Upload coverage to Codecov uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 diff --git a/tests/cli.rs b/tests/cli.rs index e5d2cf4d..68d63ebf 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -7,6 +7,62 @@ use std::fs::{self, File}; use std::io::{ErrorKind, Write}; use tempfile::tempdir; +#[test] +#[ignore = "requires Docker and the azure-init-main-tests:local image"] +fn agent_provisioning_in_container() { + use libazureinit_kvp::{ + KvpPool, KvpPoolStore, PoolMode, ProvisioningReport, + PROVISIONING_REPORT_KEY, + }; + + let artifacts = tempdir().unwrap(); + let runner = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/support/container_provisioning.py"); + assert_cmd::Command::new("python3") + .arg(runner) + .arg("--agent") + .arg(assert_cmd::cargo::cargo_bin!("azure-init")) + .arg("--artifacts") + .arg(artifacts.path()) + .timeout(std::time::Duration::from_secs(240)) + .assert() + .success(); + + for (scenario, result) in [ + ("success", "success"), + ("failure", "error"), + ("config-error", "error"), + ] { + let directory = artifacts.path().join(scenario); + let store = + KvpPoolStore::new_in(KvpPool::Guest, &directory, PoolMode::Safe) + .unwrap(); + let encoded = store.read(PROVISIONING_REPORT_KEY).unwrap().unwrap(); + let report: ProvisioningReport = encoded.parse().unwrap(); + let report = serde_json::to_value(report).unwrap(); + assert_eq!(report["result"], result); + assert_eq!(report["vm_id"], "00000000-0000-0000-0000-000000000000"); + assert_eq!( + store + .dump() + .unwrap() + .iter() + .filter(|(key, _)| key == PROVISIONING_REPORT_KEY) + .count(), + 1 + ); + let http: serde_json::Value = serde_json::from_slice( + &fs::read(directory.join("http-report.json")).unwrap(), + ) + .unwrap(); + if result == "success" { + assert_eq!(http, serde_json::json!({"state": "Ready"})); + } else { + assert_eq!(http["details"]["description"], encoded); + } + } +} + // Assert help text includes the --groups flag #[test] fn help_groups() -> Result<(), Box> { diff --git a/tests/support/Dockerfile b/tests/support/Dockerfile new file mode 100644 index 00000000..c81b9e7c --- /dev/null +++ b/tests/support/Dockerfile @@ -0,0 +1,12 @@ +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 \ + && touch /test/isolated-provisioning + +COPY container_provisioning.py /test/container_provisioning.py + +ENTRYPOINT ["python3", "/test/container_provisioning.py", "--inside"] diff --git a/tests/support/container_provisioning.py b/tests/support/container_provisioning.py new file mode 100644 index 00000000..b92d816b --- /dev/null +++ b/tests/support/container_provisioning.py @@ -0,0 +1,270 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Exercise the real agent against controlled services in a disposable container.""" + +import argparse +from http.server import BaseHTTPRequestHandler, HTTPServer +import json +import os +from pathlib import Path +import pwd +import shutil +import subprocess +import tempfile +import threading +import uuid + + +IMAGE = "azure-init-main-tests:local" +VM_ID = "00000000-0000-0000-0000-000000000000" +USERNAME = "coverage-user" +SCENARIOS = ("success", "failure", "config-error") + + +def check_mount_table(mtab): + for entry in mtab.read_text().splitlines(): + fields = entry.split() + if not fields or fields[0].startswith("#"): + continue + if len(fields) < 3: + raise RuntimeError(f"invalid mount table entry: {entry!r}") + if fields[2] in ("iso9660", "udf"): + raise RuntimeError(f"fixture must not expose mounted {fields[2]} media") + + +def inside_container(scenario): + if not Path("/test/isolated-provisioning").is_file(): + raise RuntimeError("provisioning fixture must only run in its isolated image") + if Path("/sys/class/dmi/id/product_uuid").exists(): + raise RuntimeError("fixture requires an isolated, empty /sys") + if any(user.pw_name == USERNAME for user in pwd.getpwall()): + raise RuntimeError(f"fixture requires a nonexistent account: {USERNAME}") + check_mount_table(Path("/etc/mtab")) + + requests = [] + service_errors = [] + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def handle(self): + try: + super().handle() + except (AssertionError, ValueError) as error: + service_errors.append(str(error)) + raise + + def do_GET(self): + assert self.path.startswith( + "http://169.254.169.254/metadata/instance?" + ), self.path + assert self.headers["Metadata"] == "true" + requests.append(("imds", None)) + keys = ( + [{"keyData": "ssh-ed25519 test-key", "path": "/unused"}] + if scenario == "config-error" + else [] + ) + body = json.dumps({ + "compute": { + "osProfile": { + "adminUsername": USERNAME, + "computerName": "coverage-host", + "disablePasswordAuthentication": "true", + }, + "publicKeys": keys, + } + }).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + assert self.path == "/provisioning/health", self.path + body = self.rfile.read(int(self.headers["Content-Length"])) + requests.append(("report", json.loads(body))) + self.send_response(201) + self.send_header("Content-Length", "0") + self.end_headers() + + artifacts = Path("/artifacts") + work = Path("/work") + commands = work / "commands.jsonl" + stubs = work / "bin" + stubs.mkdir() + stub = stubs / "command-stub" + stub.write_text( + "#!/usr/bin/python3\n" + "import json, os, sys\n" + "name = os.path.basename(sys.argv[0])\n" + "with open('/work/commands.jsonl', 'a') as output:\n" + " output.write(json.dumps([name, *sys.argv[1:]]) + '\\n')\n" + "if name == 'getent':\n" + " sys.exit(1)\n" + "if name == 'hostnamectl' and os.environ['TEST_SCENARIO'] == 'failure':\n" + " print('expected hostname failure', file=sys.stderr)\n" + " sys.exit(17)\n" + "if name not in ('hostnamectl', 'useradd', 'usermod', 'passwd'):\n" + " raise RuntimeError('unexpected command: ' + name)\n" + ) + stub.chmod(0o755) + for name in ("hostnamectl", "getent", "useradd", "usermod", "passwd"): + (stubs / name).symlink_to(stub) + + with HTTPServer(("127.0.0.1", 0), Handler) as server: + endpoint = f"http://127.0.0.1:{server.server_port}" + config = work / "azure-init.toml" + config.write_text( + '[ssh]\nconfigure_sshd_password_authentication = false\n' + 'query_sshd_config = false\n' + '[azure_init_data_dir]\npath = "/work/state"\n' + '[azure_init_log_path]\npath = "/work/azure-init.log"\n' + '[imds]\nconnection_timeout_secs = 1\nrequest_timeout_secs = 1\n' + 'retry_interval_secs = 0.01\ntotal_retry_timeout_secs = 3\n' + '[wireserver]\nconnection_timeout_secs = 0.01\n' + 'read_timeout_secs = 1\ntotal_retry_timeout_secs = 3\n' + f'health_endpoint = "{endpoint}/provisioning/health"\n' + '[telemetry]\nkvp_diagnostics = true\nkvp_filter = "info"\n' + ) + environment = dict( + os.environ, + PATH=str(stubs), + TEST_SCENARIO=scenario, + HTTP_PROXY=endpoint, + http_proxy=endpoint, + NO_PROXY="127.0.0.1,localhost", + no_proxy="127.0.0.1,localhost", + AZURE_INIT_LOG="info", + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + result = subprocess.run( + ["/test/azure-init", "--config", str(config)], + env=environment, + capture_output=True, + text=True, + timeout=20, + ) + (artifacts / "stdout").write_text(result.stdout) + (artifacts / "stderr").write_text(result.stderr) + assert not service_errors, service_errors + expected_status = {"success": 0, "failure": 1, "config-error": 78}[scenario] + assert result.returncode == expected_status, result + assert [kind for kind, _ in requests] == ["imds", "report"], requests + report = requests[1][1] + (artifacts / "http-report.json").write_text(json.dumps(report)) + marker = work / "state" / f"{VM_ID}.provisioned" + calls = [json.loads(line) for line in commands.read_text().splitlines()] + expected_commands = ( + ["hostnamectl"] + if scenario == "failure" + else ["hostnamectl", "getent", "useradd", "passwd"] + ) + assert [call[0] for call in calls] == expected_commands, calls + if scenario == "success": + assert report == {"state": "Ready"}, report + assert marker.is_file() + assert Path("/etc/sudoers.d/azure-init-user").read_text() == ( + f"{USERNAME} ALL=(ALL) NOPASSWD: ALL\n" + ) + else: + assert report["state"] == "NotReady", report + assert report["details"]["subStatus"] == "ProvisioningFailed", report + reason = ( + "failed to provision hostname" + if scenario == "failure" + else "user not found" + ) + assert f"reason={reason}" in report["details"]["description"], report + assert not marker.exists() + log = (work / "azure-init.log").read_text() + expected_log = ( + "Provisioning completed successfully" + if scenario == "success" + else "Provisioning failed with error:" + ) + assert expected_log in log, log + shutil.copyfile("/var/lib/hyperv/.kvp_pool_1", artifacts / ".kvp_pool_1") + print(f"Verified {scenario}: actual agent exit {result.returncode} and health report") + finally: + server.shutdown() + thread.join(timeout=5) + assert not thread.is_alive(), "mock server did not shut down" + log = work / "azure-init.log" + if log.exists(): + shutil.copyfile(log, artifacts / "azure-init.log") + for output in artifacts.iterdir(): + output.chmod(0o644) + + +def run_containers(agent, artifacts): + profile_pattern = os.environ.get("LLVM_PROFILE_FILE") + for scenario in SCENARIOS: + output = artifacts / scenario + output.mkdir() + # Container root lacks DAC_OVERRIDE; only this disposable output is writable. + output.chmod(0o777) + with tempfile.TemporaryDirectory(prefix="azure-init-empty-sys-") as empty_sys: + Path(empty_sys).chmod(0o755) + command = [ + "docker", "create", "--name", f"azure-init-test-{uuid.uuid4().hex}", + "--network", "none", "--cap-drop", "ALL", + "--security-opt", "no-new-privileges", "--read-only", + "--tmpfs", "/work:exec,mode=0700", + "--tmpfs", "/var/lib/hyperv:mode=0700", + "--tmpfs", "/etc/sudoers.d:mode=0700", + "--mount", f"type=bind,src={agent},dst=/test/azure-init,readonly", + "--mount", f"type=bind,src={empty_sys},dst=/sys,readonly", + "--mount", f"type=bind,src={output},dst=/artifacts", + ] + if profile_pattern: + command.extend([ + "--env", + f"LLVM_PROFILE_FILE=/artifacts/{Path(profile_pattern).name}", + ]) + command.extend([IMAGE, scenario]) + created = subprocess.run( + command, check=True, capture_output=True, text=True, timeout=30 + ) + container = created.stdout.strip() + try: + result = subprocess.run( + ["docker", "start", "--attach", container], + capture_output=True, text=True, timeout=60, + ) + print(result.stdout, end="") + print(result.stderr, end="") + result.check_returncode() + if profile_pattern: + profiles = list(output.glob("*.profraw")) + assert profiles, "instrumented agent produced no coverage profile" + destination = Path(profile_pattern).parent + for profile in profiles: + shutil.copyfile( + profile, + destination / f"{profile.stem}-{container[:12]}-{scenario}.profraw", + ) + finally: + subprocess.run( + ["docker", "rm", "--force", container], + check=True, capture_output=True, text=True, timeout=15, + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--inside", choices=SCENARIOS) + parser.add_argument("--agent", type=Path) + parser.add_argument("--artifacts", type=Path) + args = parser.parse_args() + if args.inside: + inside_container(args.inside) + elif args.agent and args.artifacts: + run_containers(args.agent.resolve(strict=True), args.artifacts.resolve(strict=True)) + else: + parser.error("supply --inside or both --agent and --artifacts") diff --git a/tests/support/test_container_provisioning.py b/tests/support/test_container_provisioning.py new file mode 100644 index 00000000..2ae62d93 --- /dev/null +++ b/tests/support/test_container_provisioning.py @@ -0,0 +1,130 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Check fixture isolation and lifecycle using mocked Docker and synthetic files.""" + +from contextlib import redirect_stdout +import io +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +import container_provisioning as fixture + + +class MountTableTests(unittest.TestCase): + def check_mount_table(self, contents): + with tempfile.TemporaryDirectory() as temporary: + mtab = Path(temporary) / "mtab" + mtab.write_text(contents) + fixture.check_mount_table(mtab) + + def test_allows_empty_and_standard_container_mounts(self): + for contents in ("", "# comment\n", "overlay / overlay rw 0 0\n"): + with self.subTest(contents=contents): + self.check_mount_table(contents) + + def test_rejects_provisioning_media(self): + for filesystem in ("iso9660", "udf"): + with self.subTest(filesystem=filesystem): + with self.assertRaisesRegex(RuntimeError, filesystem): + self.check_mount_table(f"/dev/sr0 /media {filesystem} ro 0 0\n") + + def test_rejects_malformed_mount_entries(self): + with self.assertRaisesRegex(RuntimeError, "invalid mount table"): + self.check_mount_table("incomplete\n") + + +class ContainerRunnerTests(unittest.TestCase): + def run_fixture(self, root, docker, profile_pattern=None): + artifacts = root / "artifacts" + artifacts.mkdir() + with patch.dict(os.environ): + os.environ.pop("LLVM_PROFILE_FILE", None) + if profile_pattern: + os.environ["LLVM_PROFILE_FILE"] = str(profile_pattern) + with patch.object(fixture.subprocess, "run", side_effect=docker): + with redirect_stdout(io.StringIO()): + fixture.run_containers(Path("/test-agent"), artifacts) + + def test_collects_profiles_and_removes_each_container(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + profiles = root / "profiles" + profiles.mkdir() + calls = [] + output = None + + def docker(command, **_options): + nonlocal output + calls.append(command[1]) + if command[1] == "create": + mount = next(arg for arg in command if arg.endswith("dst=/artifacts")) + output = Path(mount.split("src=", 1)[1].split(",dst=", 1)[0]) + self.assertEqual(output.stat().st_mode & 0o777, 0o777) + self.assertIn("LLVM_PROFILE_FILE=/artifacts/agent-%p-%m.profraw", command) + self.assertEqual(command[command.index("--network") + 1], "none") + self.assertEqual(command[command.index("--cap-drop") + 1], "ALL") + self.assertIn("no-new-privileges", command) + self.assertIn("--read-only", command) + self.assertIn("/work:exec,mode=0700", command) + return subprocess.CompletedProcess(command, 0, "container-id\n", "") + if command[1] == "start": + (output / "agent-1-123.profraw").write_bytes(b"synthetic profile") + return subprocess.CompletedProcess(command, 0, "", "") + + self.run_fixture(root, docker, profiles / "agent-%p-%m.profraw") + self.assertEqual(calls, ["create", "start", "rm"] * len(fixture.SCENARIOS)) + for scenario in fixture.SCENARIOS: + profile = profiles / f"agent-1-123-container-id-{scenario}.profraw" + self.assertEqual(profile.read_bytes(), b"synthetic profile") + + def test_normal_run_does_not_require_profiles(self): + with tempfile.TemporaryDirectory() as temporary: + calls = [] + + def docker(command, **_options): + calls.append(command[1]) + self.assertFalse(any(arg.startswith("LLVM_PROFILE_FILE=") for arg in command)) + return subprocess.CompletedProcess(command, 0, "container-id\n", "") + + self.run_fixture(Path(temporary), docker) + self.assertEqual(calls, ["create", "start", "rm"] * len(fixture.SCENARIOS)) + + def test_failure_and_timeout_still_remove_the_container(self): + for timed_out in (False, True): + with self.subTest(timed_out=timed_out), tempfile.TemporaryDirectory() as temporary: + calls = [] + + def docker(command, **_options): + calls.append(command[1]) + if command[1] == "start": + if timed_out: + raise subprocess.TimeoutExpired(command, 60) + return subprocess.CompletedProcess(command, 1, "", "fixture failed") + return subprocess.CompletedProcess(command, 0, "container-id\n", "") + + error = subprocess.TimeoutExpired if timed_out else subprocess.CalledProcessError + with self.assertRaises(error): + self.run_fixture(Path(temporary), docker) + self.assertEqual(calls, ["create", "start", "rm"]) + + def test_missing_profile_is_an_error_and_cleans_up(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + calls = [] + + def docker(command, **_options): + calls.append(command[1]) + return subprocess.CompletedProcess(command, 0, "container-id\n", "") + + with self.assertRaisesRegex(AssertionError, "no coverage profile"): + self.run_fixture(root, docker, root / "agent-%p-%m.profraw") + self.assertEqual(calls, ["create", "start", "rm"]) + + +if __name__ == "__main__": + unittest.main() From 5c1b0ae1af3ec1d2104b1c35aee4efef6663a48d Mon Sep 17 00:00:00 2001 From: peytonr18 Date: Mon, 9 Nov 2026 11:12:19 -0800 Subject: [PATCH 2/7] test(cli): fixing errors with .yml file --- .github/workflows/coverage-report.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/coverage-report.yml b/.github/workflows/coverage-report.yml index ca7162fa..2d455105 100644 --- a/.github/workflows/coverage-report.yml +++ b/.github/workflows/coverage-report.yml @@ -56,10 +56,10 @@ jobs: - name: Collect unit and CLI coverage run: | cargo llvm-cov clean --workspace - cargo llvm-cov --locked --all-features --workspace --no-clean --no-report + 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 cli --no-clean --no-report -- --ignored agent_provisioning_in_container + run: cargo llvm-cov --locked --all-features --package azure-init --test cli --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 From 3f5fb868bc90ec14227e3d4ef05f3522eca6d6df Mon Sep 17 00:00:00 2001 From: peytonr18 Date: Mon, 9 Nov 2026 22:50:50 -0800 Subject: [PATCH 3/7] tests: migrating added suite over to testinit setup instead --- .github/workflows/coverage-report.yml | 6 +- Cargo.toml | 4 + .../Dockerfile.isolated | 5 +- testinit/README.md | 40 ++++-- .../testing-server}/container_provisioning.py | 123 +++++++++++------- .../test_container_provisioning.py | 93 ++++++++++++- testinit/testing-server/wireserver_handler.py | 12 +- testinit/tests/provisioning.rs | 61 +++++++++ tests/cli.rs | 56 -------- 9 files changed, 273 insertions(+), 127 deletions(-) rename tests/support/Dockerfile => testinit/Dockerfile.isolated (54%) rename {tests/support => testinit/testing-server}/container_provisioning.py (82%) rename {tests/support => testinit/testing-server}/test_container_provisioning.py (55%) create mode 100644 testinit/tests/provisioning.rs diff --git a/.github/workflows/coverage-report.yml b/.github/workflows/coverage-report.yml index 2d455105..d61835d2 100644 --- a/.github/workflows/coverage-report.yml +++ b/.github/workflows/coverage-report.yml @@ -46,12 +46,12 @@ jobs: fallback: none - name: Test the isolated provisioning runner - run: python3 -m unittest discover -s tests/support -p test_container_provisioning.py -v + 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}" -t azure-init-main-tests:local tests/support + 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: | @@ -59,7 +59,7 @@ jobs: 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 cli --no-report -- --ignored agent_provisioning_in_container + 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 diff --git a/Cargo.toml b/Cargo.toml index 1d752d2f..01a536f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", diff --git a/tests/support/Dockerfile b/testinit/Dockerfile.isolated similarity index 54% rename from tests/support/Dockerfile rename to testinit/Dockerfile.isolated index c81b9e7c..c56c0333 100644 --- a/tests/support/Dockerfile +++ b/testinit/Dockerfile.isolated @@ -7,6 +7,7 @@ RUN apt-get update \ && mkdir -p /test /work /var/lib/hyperv /etc/sudoers.d \ && touch /test/isolated-provisioning -COPY container_provisioning.py /test/container_provisioning.py +COPY testing-server/container_provisioning.py testing-server/imds_handler.py \ + testing-server/wireserver_handler.py testing-server/utils.py /test/ -ENTRYPOINT ["python3", "/test/container_provisioning.py", "--inside"] +ENTRYPOINT ["python3", "-B", "/test/container_provisioning.py", "--inside"] diff --git a/testinit/README.md b/testinit/README.md index 2ce95eba..cf8c18cd 100644 --- a/testinit/README.md +++ b/testinit/README.md @@ -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] > @@ -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 @@ -25,9 +26,29 @@ 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 +``` + +Ordinary `cargo test` skips this Docker-dependent test. The +[coverage workflow](../.github/workflows/coverage-report.yml) runs it through +`cargo llvm-cov`, collects the agent's profiles, and uploads combined coverage +to Codecov. + +## Systemd E2E Quick Start ### Starting Services @@ -104,6 +125,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. diff --git a/tests/support/container_provisioning.py b/testinit/testing-server/container_provisioning.py similarity index 82% rename from tests/support/container_provisioning.py rename to testinit/testing-server/container_provisioning.py index b92d816b..a507d425 100644 --- a/tests/support/container_provisioning.py +++ b/testinit/testing-server/container_provisioning.py @@ -4,7 +4,7 @@ """Exercise the real agent against controlled services in a disposable container.""" import argparse -from http.server import BaseHTTPRequestHandler, HTTPServer +from http.server import HTTPServer import json import os from pathlib import Path @@ -15,6 +15,9 @@ import threading import uuid +from imds_handler import IMDSHandler +from wireserver_handler import WireServerHandler + IMAGE = "azure-init-main-tests:local" VM_ID = "00000000-0000-0000-0000-000000000000" @@ -33,19 +36,8 @@ def check_mount_table(mtab): raise RuntimeError(f"fixture must not expose mounted {fields[2]} media") -def inside_container(scenario): - if not Path("/test/isolated-provisioning").is_file(): - raise RuntimeError("provisioning fixture must only run in its isolated image") - if Path("/sys/class/dmi/id/product_uuid").exists(): - raise RuntimeError("fixture requires an isolated, empty /sys") - if any(user.pw_name == USERNAME for user in pwd.getpwall()): - raise RuntimeError(f"fixture requires a nonexistent account: {USERNAME}") - check_mount_table(Path("/etc/mtab")) - - requests = [] - service_errors = [] - - class Handler(BaseHTTPRequestHandler): +def make_handlers(scenario, requests, service_errors): + class CheckedHandler: def log_message(self, *_args): pass @@ -56,40 +48,62 @@ def handle(self): service_errors.append(str(error)) raise - def do_GET(self): - assert self.path.startswith( - "http://169.254.169.254/metadata/instance?" - ), self.path - assert self.headers["Metadata"] == "true" - requests.append(("imds", None)) - keys = ( - [{"keyData": "ssh-ed25519 test-key", "path": "/unused"}] - if scenario == "config-error" - else [] - ) - body = json.dumps({ + class MetadataHandler(CheckedHandler, IMDSHandler): + _response_position = 0 + _responses = [{ + "status_code": 200, + "headers": {"Content-Type": "application/json"}, + "response": { "compute": { "osProfile": { "adminUsername": USERNAME, "computerName": "coverage-host", "disablePasswordAuthentication": "true", }, - "publicKeys": keys, + "publicKeys": ( + [{"keyData": "ssh-ed25519 test-key", "path": "/unused"}] + if scenario == "config-error" + else [] + ), } - }).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) + }, + }] + + def do_GET(self): + assert self.path.startswith( + "http://169.254.169.254/metadata/instance?" + ), self.path + assert self.headers["Metadata"] == "true" + requests.append(("imds", None)) + super().do_GET() - def do_POST(self): + class HealthHandler(CheckedHandler, WireServerHandler): + _response_position = 0 + _responses = [{ + "status_code": 201, + "headers": {"Content-Length": "0"}, + "response": "", + }] + + def record_report(self, post_data): assert self.path == "/provisioning/health", self.path - body = self.rfile.read(int(self.headers["Content-Length"])) - requests.append(("report", json.loads(body))) - self.send_response(201) - self.send_header("Content-Length", "0") - self.end_headers() + requests.append(("report", json.loads(post_data))) + + return MetadataHandler, HealthHandler + + +def inside_container(scenario): + if not Path("/test/isolated-provisioning").is_file(): + raise RuntimeError("provisioning fixture must only run in its isolated image") + if Path("/sys/class/dmi/id/product_uuid").exists(): + raise RuntimeError("fixture requires an isolated, empty /sys") + if any(user.pw_name == USERNAME for user in pwd.getpwall()): + raise RuntimeError(f"fixture requires a nonexistent account: {USERNAME}") + check_mount_table(Path("/etc/mtab")) + + requests = [] + service_errors = [] + metadata_handler, health_handler = make_handlers(scenario, requests, service_errors) artifacts = Path("/artifacts") work = Path("/work") @@ -115,8 +129,12 @@ def do_POST(self): for name in ("hostnamectl", "getent", "useradd", "usermod", "passwd"): (stubs / name).symlink_to(stub) - with HTTPServer(("127.0.0.1", 0), Handler) as server: - endpoint = f"http://127.0.0.1:{server.server_port}" + with ( + HTTPServer(("127.0.0.1", 0), metadata_handler) as imds_server, + HTTPServer(("127.0.0.1", 0), health_handler) as health_server, + ): + imds_endpoint = f"http://127.0.0.1:{imds_server.server_port}" + health_endpoint = f"http://127.0.0.1:{health_server.server_port}" config = work / "azure-init.toml" config.write_text( '[ssh]\nconfigure_sshd_password_authentication = false\n' @@ -127,21 +145,26 @@ def do_POST(self): 'retry_interval_secs = 0.01\ntotal_retry_timeout_secs = 3\n' '[wireserver]\nconnection_timeout_secs = 0.01\n' 'read_timeout_secs = 1\ntotal_retry_timeout_secs = 3\n' - f'health_endpoint = "{endpoint}/provisioning/health"\n' + f'health_endpoint = "{health_endpoint}/provisioning/health"\n' '[telemetry]\nkvp_diagnostics = true\nkvp_filter = "info"\n' ) environment = dict( os.environ, PATH=str(stubs), TEST_SCENARIO=scenario, - HTTP_PROXY=endpoint, - http_proxy=endpoint, + HTTP_PROXY=imds_endpoint, + http_proxy=imds_endpoint, NO_PROXY="127.0.0.1,localhost", no_proxy="127.0.0.1,localhost", AZURE_INIT_LOG="info", ) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() + servers = (imds_server, health_server) + threads = [ + threading.Thread(target=server.serve_forever, daemon=True) + for server in servers + ] + for thread in threads: + thread.start() try: result = subprocess.run( ["/test/azure-init", "--config", str(config)], @@ -192,9 +215,11 @@ def do_POST(self): shutil.copyfile("/var/lib/hyperv/.kvp_pool_1", artifacts / ".kvp_pool_1") print(f"Verified {scenario}: actual agent exit {result.returncode} and health report") finally: - server.shutdown() - thread.join(timeout=5) - assert not thread.is_alive(), "mock server did not shut down" + for server in servers: + server.shutdown() + for thread in threads: + thread.join(timeout=5) + assert not thread.is_alive(), "mock server did not shut down" log = work / "azure-init.log" if log.exists(): shutil.copyfile(log, artifacts / "azure-init.log") diff --git a/tests/support/test_container_provisioning.py b/testinit/testing-server/test_container_provisioning.py similarity index 55% rename from tests/support/test_container_provisioning.py rename to testinit/testing-server/test_container_provisioning.py index 2ae62d93..99740bf3 100644 --- a/tests/support/test_container_provisioning.py +++ b/testinit/testing-server/test_container_provisioning.py @@ -1,20 +1,109 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -"""Check fixture isolation and lifecycle using mocked Docker and synthetic files.""" +"""Check shared HTTP handlers, fixture isolation, and the Docker lifecycle.""" -from contextlib import redirect_stdout +from contextlib import contextmanager, redirect_stdout +from http.client import HTTPConnection, RemoteDisconnected import io +import json import os from pathlib import Path import subprocess import tempfile +import threading import unittest from unittest.mock import patch import container_provisioning as fixture +class HandlerTests(unittest.TestCase): + @contextmanager + def serve(self, handler): + with fixture.HTTPServer(("127.0.0.1", 0), handler) as server: + thread = threading.Thread( + target=server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True + ) + thread.start() + connection = HTTPConnection(*server.server_address, timeout=5) + try: + yield connection + finally: + connection.close() + server.shutdown() + thread.join(timeout=5) + self.assertFalse(thread.is_alive(), "mock server did not shut down") + + def test_scenarios_share_handlers_with_fresh_response_state(self): + for scenario in fixture.SCENARIOS: + with self.subTest(scenario=scenario): + requests, errors = [], [] + metadata, health = fixture.make_handlers(scenario, requests, errors) + self.assertEqual(metadata._response_position, 0) + self.assertEqual(health._response_position, 0) + with self.serve(metadata) as connection: + connection.request( + "GET", "http://169.254.169.254/metadata/instance?extended=true", + headers={"Metadata": "true"}, + ) + response = connection.getresponse() + self.assertEqual(response.status, 200) + compute = json.loads(response.read())["compute"] + self.assertEqual(compute["osProfile"]["adminUsername"], fixture.USERNAME) + self.assertEqual(bool(compute["publicKeys"]), scenario == "config-error") + report = {"state": "Ready" if scenario == "success" else "NotReady"} + with self.serve(health) as connection: + connection.request("POST", "/provisioning/health", json.dumps(report)) + response = connection.getresponse() + self.assertEqual(response.status, 201) + self.assertEqual(response.read(), b"") + self.assertEqual(requests, [("imds", None), ("report", report)]) + self.assertEqual(errors, []) + self.assertEqual(metadata._response_position, 1) + self.assertEqual(health._response_position, 1) + + def test_invalid_requests_are_reported(self): + requests, errors = [], [] + metadata, health = fixture.make_handlers("success", requests, errors) + for handler, method, path, body in ( + (metadata, "GET", "/unexpected", ""), + (health, "POST", "/provisioning/health", "not-json"), + ): + with self.subTest(method=method), patch.object(fixture.HTTPServer, "handle_error"): + with self.serve(handler) as connection: + connection.request(method, path, body) + with self.assertRaises(RemoteDisconnected): + connection.getresponse() + self.assertEqual(len(errors), 2) + self.assertEqual(requests, []) + + def test_systemd_health_responses_still_snapshot_kvp(self): + for custom in (False, True): + with self.subTest(custom=custom): + class Handler(fixture.WireServerHandler): + _response_position = 0 + + if custom: + response_file = Path(fixture.__file__).parent / ( + "api-responses/wireserver/one-bad-then-success.xml" + ) + Handler.set_response_file_path(response_file) + Handler.load_responses() + statuses = (500, 200) if custom else (200,) + with ( + patch.object(Handler, "store_kvp_file") as snapshot, + patch("wireserver_handler.time.sleep"), + self.serve(Handler) as connection, + ): + for status in statuses: + connection.request("POST", "/provisioning/health", '{"state":"Ready"}') + response = connection.getresponse() + self.assertEqual(response.status, status) + self.assertEqual(response.read(), b"") + self.assertEqual(snapshot.call_count, len(statuses)) + + class MountTableTests(unittest.TestCase): def check_mount_table(self, contents): with tempfile.TemporaryDirectory() as temporary: diff --git a/testinit/testing-server/wireserver_handler.py b/testinit/testing-server/wireserver_handler.py index d997d1ce..91345a1f 100644 --- a/testinit/testing-server/wireserver_handler.py +++ b/testinit/testing-server/wireserver_handler.py @@ -116,16 +116,16 @@ def write_default_response(self): self.wfile.write(response.encode()) return - def do_POST(self): - """Handle POST requests to WireServer endpoints.""" - content_length = int(self.headers.get("Content-Length", 0)) - post_data = self.rfile.read(content_length) - + def record_report(self, post_data): logger.info(f"WireServer POST request: {self.path}") logger.info(f"POST data: {post_data.decode('utf-8', errors='ignore')}") - self.store_kvp_file() + def do_POST(self): + """Handle POST requests to WireServer endpoints.""" + content_length = int(self.headers.get("Content-Length", 0)) + self.record_report(self.rfile.read(content_length)) + if self._responses is not None: self.write_custom_response() return diff --git a/testinit/tests/provisioning.rs b/testinit/tests/provisioning.rs new file mode 100644 index 00000000..349fbb69 --- /dev/null +++ b/testinit/tests/provisioning.rs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use libazureinit_kvp::{ + KvpPool, KvpPoolStore, PoolMode, ProvisioningReport, + PROVISIONING_REPORT_KEY, +}; +use std::fs; +use std::path::Path; + +#[test] +#[ignore = "requires Docker and the azure-init-main-tests:local image"] +fn agent_provisioning_in_container() { + let artifacts = tempfile::tempdir().unwrap(); + let runner = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("testinit/testing-server/container_provisioning.py"); + assert_cmd::Command::new("python3") + .arg("-B") + .arg(runner) + .arg("--agent") + .arg(assert_cmd::cargo::cargo_bin!("azure-init")) + .arg("--artifacts") + .arg(artifacts.path()) + .timeout(std::time::Duration::from_secs(240)) + .assert() + .success(); + + for (scenario, result) in [ + ("success", "success"), + ("failure", "error"), + ("config-error", "error"), + ] { + let directory = artifacts.path().join(scenario); + let store = + KvpPoolStore::new_in(KvpPool::Guest, &directory, PoolMode::Safe) + .unwrap(); + let encoded = store.read(PROVISIONING_REPORT_KEY).unwrap().unwrap(); + let report: ProvisioningReport = encoded.parse().unwrap(); + let report = serde_json::to_value(report).unwrap(); + assert_eq!(report["result"], result); + assert_eq!(report["vm_id"], "00000000-0000-0000-0000-000000000000"); + assert_eq!( + store + .dump() + .unwrap() + .iter() + .filter(|(key, _)| key == PROVISIONING_REPORT_KEY) + .count(), + 1 + ); + let http: serde_json::Value = serde_json::from_slice( + &fs::read(directory.join("http-report.json")).unwrap(), + ) + .unwrap(); + if result == "success" { + assert_eq!(http, serde_json::json!({"state": "Ready"})); + } else { + assert_eq!(http["details"]["description"], encoded); + } + } +} diff --git a/tests/cli.rs b/tests/cli.rs index 68d63ebf..e5d2cf4d 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -7,62 +7,6 @@ use std::fs::{self, File}; use std::io::{ErrorKind, Write}; use tempfile::tempdir; -#[test] -#[ignore = "requires Docker and the azure-init-main-tests:local image"] -fn agent_provisioning_in_container() { - use libazureinit_kvp::{ - KvpPool, KvpPoolStore, PoolMode, ProvisioningReport, - PROVISIONING_REPORT_KEY, - }; - - let artifacts = tempdir().unwrap(); - let runner = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/support/container_provisioning.py"); - assert_cmd::Command::new("python3") - .arg(runner) - .arg("--agent") - .arg(assert_cmd::cargo::cargo_bin!("azure-init")) - .arg("--artifacts") - .arg(artifacts.path()) - .timeout(std::time::Duration::from_secs(240)) - .assert() - .success(); - - for (scenario, result) in [ - ("success", "success"), - ("failure", "error"), - ("config-error", "error"), - ] { - let directory = artifacts.path().join(scenario); - let store = - KvpPoolStore::new_in(KvpPool::Guest, &directory, PoolMode::Safe) - .unwrap(); - let encoded = store.read(PROVISIONING_REPORT_KEY).unwrap().unwrap(); - let report: ProvisioningReport = encoded.parse().unwrap(); - let report = serde_json::to_value(report).unwrap(); - assert_eq!(report["result"], result); - assert_eq!(report["vm_id"], "00000000-0000-0000-0000-000000000000"); - assert_eq!( - store - .dump() - .unwrap() - .iter() - .filter(|(key, _)| key == PROVISIONING_REPORT_KEY) - .count(), - 1 - ); - let http: serde_json::Value = serde_json::from_slice( - &fs::read(directory.join("http-report.json")).unwrap(), - ) - .unwrap(); - if result == "success" { - assert_eq!(http, serde_json::json!({"state": "Ready"})); - } else { - assert_eq!(http["details"]["description"], encoded); - } - } -} - // Assert help text includes the --groups flag #[test] fn help_groups() -> Result<(), Box> { From 64edeb5d9a53c19e76ecbbdbedfe29cdbc049234 Mon Sep 17 00:00:00 2001 From: peytonr18 Date: Tue, 10 Nov 2026 09:43:30 -0800 Subject: [PATCH 4/7] TEST(main): adding temporary modified function to trigger codecov run and see that all other parts of main are covered --- src/main.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main.rs b/src/main.rs index 516c0ae6..27a69241 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,6 +45,11 @@ fn version_string() -> String { PKG_VERSION.to_string() } +// Temporary executable sample for verifying changed-line coverage. +fn hello() { + tracing::info!("Hello from the main entrypoint coverage sample"); +} + /// Minimal provisioning agent for Azure /// /// Create a user, add SSH public keys, and set the hostname. @@ -405,6 +410,7 @@ async fn main() -> ExitCode { }; tracing::info!("Final configuration: {:#?}", config); + hello(); let exit_code = if let Some(Command::Clean { logs }) = opts.command { if clean_provisioning_status(&config).is_err() From 3557faaaf214033d3937d3819a1d1d25e7d61fe6 Mon Sep 17 00:00:00 2001 From: peytonr18 Date: Tue, 10 Nov 2026 10:58:44 -0800 Subject: [PATCH 5/7] test(cli): expand entrypoint coverage and remove the temporary coverage probe --- codecov.yml | 12 + src/main.rs | 284 ++++++++++++++++-- testinit/README.md | 4 + .../testing-server/container_provisioning.py | 21 +- testinit/tests/provisioning.rs | 1 + 5 files changed, 290 insertions(+), 32 deletions(-) create mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..aaf5ca69 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,12 @@ +coverage: + status: + project: + default: + target: auto + main: + target: 100% + threshold: 0% + if_not_found: failure + informational: false + paths: + - src/main.rs diff --git a/src/main.rs b/src/main.rs index 27a69241..fc064344 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,10 @@ mod logging; mod tracing_layer; -use std::{future::Future, path::PathBuf}; +use std::{ + future::Future, + path::{Path, PathBuf}, +}; use anyhow::Context; use clap::{Parser, Subcommand}; @@ -30,14 +33,26 @@ const PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); const REPORT_AGENT: &str = concat!("Azure-Init/", env!("CARGO_PKG_VERSION")); fn version_string() -> String { - if let Some(v) = option_env!("AZURE_INIT_VERSION") { + format_version( + option_env!("AZURE_INIT_VERSION"), + option_env!("AZURE_INIT_BUILD_VERSION"), + option_env!("AZURE_INIT_BUILD_SHA"), + ) +} + +fn format_version( + version: Option<&str>, + build: Option<&str>, + sha: Option<&str>, +) -> String { + if let Some(v) = version { return v.to_string(); } - if let Some(build) = option_env!("AZURE_INIT_BUILD_VERSION") { + if let Some(build) = build { return format!("{PKG_VERSION}-{build}"); } - if let Some(sha) = option_env!("AZURE_INIT_BUILD_SHA") { + if let Some(sha) = sha { let short = &sha[..std::cmp::min(7, sha.len())]; return format!("{PKG_VERSION}-{short}"); } @@ -45,11 +60,6 @@ fn version_string() -> String { PKG_VERSION.to_string() } -// Temporary executable sample for verifying changed-line coverage. -fn hello() { - tracing::info!("Hello from the main entrypoint coverage sample"); -} - /// Minimal provisioning agent for Azure /// /// Create a user, add SSH public keys, and set the hostname. @@ -110,12 +120,18 @@ enum Command { #[instrument] fn get_environment() -> Result { tracing::debug!("Searching for OVF environment on local block devices."); - let ovf_devices = get_mount_device(None)?; + get_environment_from_devices(get_mount_device(None)?, mount_parse_ovf_env) +} + +fn get_environment_from_devices( + ovf_devices: impl IntoIterator, + mut load: impl FnMut(String) -> Result, +) -> Result { let mut environment: Option = None; // loop until it finds a correct device. for dev in ovf_devices { - environment = match mount_parse_ovf_env(dev) { + environment = match load(dev) { Ok(env) => { tracing::info!( target = "libazureinit::media::success", @@ -171,7 +187,6 @@ fn get_username( #[instrument] fn clean_provisioning_status(config: &Config) -> Result<(), std::io::Error> { let data_dir = &config.azure_init_data_dir.path; - let mut found = false; let entries = match std::fs::read_dir(data_dir) { Ok(entries) => entries, @@ -192,9 +207,21 @@ fn clean_provisioning_status(config: &Config) -> Result<(), std::io::Error> { } }; + clean_provisioning_entries( + data_dir, + entries.map(|entry| entry.map(|entry| entry.path())), + ) +} + +fn clean_provisioning_entries( + data_dir: &Path, + entries: impl IntoIterator>, +) -> Result<(), std::io::Error> { + let mut found = false; + for entry in entries { let path = match entry { - Ok(e) => e.path(), + Ok(path) => path, Err(e) => { tracing::error!( "Failed to read directory entry in {:?}: {:?}", @@ -280,6 +307,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::() { + 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, @@ -410,7 +448,6 @@ async fn main() -> ExitCode { }; tracing::info!("Final configuration: {:#?}", config); - hello(); let exit_code = if let Some(Command::Clean { logs }) = opts.command { if clean_provisioning_status(&config).is_err() @@ -442,17 +479,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::() { - Some(LibError::UserMissing { user: _ }) => { - ExitCode::from(config) - } - Some(LibError::NonEmptyPassword) => ExitCode::from(config), - Some(_) | None => ExitCode::FAILURE, - } + provisioning_error_exit_code(&e) } } }; @@ -563,6 +590,213 @@ mod tests { const VM_ID: &str = "00000000-0000-0000-0000-000000000001"; + #[rstest] + #[case::override_version( + Some("custom"), Some("build"), Some("123456789"), "custom".into() + )] + #[case::empty_override( + Some(""), + Some("build"), + Some("123456789"), + String::new() + )] + #[case::build_version( + None, Some("build"), Some("123456789"), format!("{PKG_VERSION}-build") + )] + #[case::long_sha( + None, None, Some("123456789"), format!("{PKG_VERSION}-1234567") + )] + #[case::short_sha( + None, None, Some("abc"), format!("{PKG_VERSION}-abc") + )] + #[case::empty_sha(None, None, Some(""), format!("{PKG_VERSION}-"))] + #[case::package_version(None, None, None, PKG_VERSION.into())] + fn version_metadata_precedence( + #[case] version: Option<&str>, + #[case] build: Option<&str>, + #[case] sha: Option<&str>, + #[case] expected: String, + ) { + assert_eq!(format_version(version, build, sha), expected); + } + + #[rstest] + #[case::no_devices(vec![], None)] + #[case::unreadable_devices(vec!["failed"], None)] + #[case::last_valid_environment( + vec!["failed", "first", "failed", "last", "failed"], Some("last") + )] + fn environment_selection_preserves_success_after_failed_devices( + #[case] devices: Vec<&str>, + #[case] expected: Option<&str>, + ) { + let mut attempted = Vec::new(); + let result = get_environment_from_devices( + devices.iter().map(|device| device.to_string()), + |device| { + attempted.push(device.clone()); + if device == "failed" { + return Err(io::Error::other("unreadable media").into()); + } + let mut environment = Environment::default(); + environment + .provisioning_section + .linux_prov_conf_set + .username = device; + Ok(environment) + }, + ); + assert_eq!(attempted, devices); + match expected { + Some(username) => assert_eq!( + result + .unwrap() + .provisioning_section + .linux_prov_conf_set + .username, + username + ), + None => assert_eq!( + result.unwrap_err().to_string(), + "Unable to get list of block devices" + ), + } + } + + #[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::(), + Some(LibError::UsernameFailure) + )), + } + } + + #[test] + fn cleanup_preserves_unrelated_files_and_tolerates_disappearing_markers() { + let dir = TempDir::new().unwrap(); + let marker = dir.path().join("existing.provisioned"); + let missing = dir.path().join("removed.provisioned"); + let unrelated = dir.path().join("keep.txt"); + std::fs::write(&marker, "complete").unwrap(); + std::fs::write(&unrelated, "keep").unwrap(); + clean_provisioning_entries( + dir.path(), + [Ok(marker.clone()), Ok(missing), Ok(unrelated.clone())], + ) + .unwrap(); + assert!(!marker.exists()); + assert_eq!(std::fs::read_to_string(unrelated).unwrap(), "keep"); + } + + #[test] + fn cleanup_propagates_directory_entry_errors_and_stops() { + let dir = TempDir::new().unwrap(); + let marker = dir.path().join("untouched.provisioned"); + std::fs::write(&marker, "complete").unwrap(); + let error = clean_provisioning_entries( + dir.path(), + [ + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "cannot read directory entry", + )), + Ok(marker.clone()), + ], + ) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + assert_eq!(error.to_string(), "cannot read directory entry"); + assert_eq!(std::fs::read_to_string(marker).unwrap(), "complete"); + } + + #[test] + fn cleanup_propagates_marker_removal_errors() { + let dir = TempDir::new().unwrap(); + let marker = dir.path().join("directory.provisioned"); + std::fs::create_dir(&marker).unwrap(); + let mut config = Config::default(); + config.azure_init_data_dir.path = dir.path().into(); + let error = clean_provisioning_status(&config).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::IsADirectory); + assert!(marker.is_dir()); + } + + #[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, + #[values(false, true)] contextual: bool, + ) { + let error = if contextual { + error.context("provisioning failed") + } else { + error + }; + 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"; diff --git a/testinit/README.md b/testinit/README.md index cf8c18cd..053f7d64 100644 --- a/testinit/README.md +++ b/testinit/README.md @@ -48,6 +48,10 @@ Ordinary `cargo test` skips this Docker-dependent test. The `cargo llvm-cov`, collects the agent's profiles, and uploads combined coverage to Codecov. +Scenarios cover success, hostname failure, missing-user failure, and +provisioning-marker write failure. [Codecov](../codecov.yml) targets 100% +coverage of the full [entrypoint file](../src/main.rs), not just changed lines. + ## Systemd E2E Quick Start ### Starting Services diff --git a/testinit/testing-server/container_provisioning.py b/testinit/testing-server/container_provisioning.py index a507d425..725a9ff9 100644 --- a/testinit/testing-server/container_provisioning.py +++ b/testinit/testing-server/container_provisioning.py @@ -22,7 +22,7 @@ IMAGE = "azure-init-main-tests:local" VM_ID = "00000000-0000-0000-0000-000000000000" USERNAME = "coverage-user" -SCENARIOS = ("success", "failure", "config-error") +SCENARIOS = ("success", "failure", "config-error", "marker-error") def check_mount_table(mtab): @@ -107,6 +107,8 @@ def inside_container(scenario): artifacts = Path("/artifacts") work = Path("/work") + if scenario == "marker-error": + (work / "state").write_text("not a directory") commands = work / "commands.jsonl" stubs = work / "bin" stubs.mkdir() @@ -176,7 +178,9 @@ def inside_container(scenario): (artifacts / "stdout").write_text(result.stdout) (artifacts / "stderr").write_text(result.stderr) assert not service_errors, service_errors - expected_status = {"success": 0, "failure": 1, "config-error": 78}[scenario] + expected_status = { + "success": 0, "failure": 1, "config-error": 78, "marker-error": 1, + }[scenario] assert result.returncode == expected_status, result assert [kind for kind, _ in requests] == ["imds", "report"], requests report = requests[1][1] @@ -198,14 +202,17 @@ def inside_container(scenario): else: assert report["state"] == "NotReady", report assert report["details"]["subStatus"] == "ProvisioningFailed", report - reason = ( - "failed to provision hostname" - if scenario == "failure" - else "user not found" - ) + reason = { + "failure": "failed to provision hostname", + "config-error": "user not found", + "marker-error": "I/O error", + }[scenario] assert f"reason={reason}" in report["details"]["description"], report assert not marker.exists() log = (work / "azure-init.log").read_text() + if scenario == "marker-error": + assert (work / "state").read_text() == "not a directory" + assert "Failed to mark provisioning complete." in log, log expected_log = ( "Provisioning completed successfully" if scenario == "success" diff --git a/testinit/tests/provisioning.rs b/testinit/tests/provisioning.rs index 349fbb69..74e8cc88 100644 --- a/testinit/tests/provisioning.rs +++ b/testinit/tests/provisioning.rs @@ -29,6 +29,7 @@ fn agent_provisioning_in_container() { ("success", "success"), ("failure", "error"), ("config-error", "error"), + ("marker-error", "error"), ] { let directory = artifacts.path().join(scenario); let store = From be8fb941aa1579dbff4d7bce083584824a3f529a Mon Sep 17 00:00:00 2001 From: peytonr18 Date: Tue, 10 Nov 2026 11:40:37 -0800 Subject: [PATCH 6/7] test(cli): trim entrypoint coverage fixtures --- codecov.yml | 12 -- src/main.rs | 91 ++++++--------- testinit/README.md | 11 +- .../testing-server/container_provisioning.py | 16 ++- .../test_container_provisioning.py | 106 ++++++------------ 5 files changed, 79 insertions(+), 157 deletions(-) delete mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index aaf5ca69..00000000 --- a/codecov.yml +++ /dev/null @@ -1,12 +0,0 @@ -coverage: - status: - project: - default: - target: auto - main: - target: 100% - threshold: 0% - if_not_found: failure - informational: false - paths: - - src/main.rs diff --git a/src/main.rs b/src/main.rs index fc064344..08bfb767 100644 --- a/src/main.rs +++ b/src/main.rs @@ -594,12 +594,6 @@ mod tests { #[case::override_version( Some("custom"), Some("build"), Some("123456789"), "custom".into() )] - #[case::empty_override( - Some(""), - Some("build"), - Some("123456789"), - String::new() - )] #[case::build_version( None, Some("build"), Some("123456789"), format!("{PKG_VERSION}-build") )] @@ -609,7 +603,6 @@ mod tests { #[case::short_sha( None, None, Some("abc"), format!("{PKG_VERSION}-abc") )] - #[case::empty_sha(None, None, Some(""), format!("{PKG_VERSION}-"))] #[case::package_version(None, None, None, PKG_VERSION.into())] fn version_metadata_precedence( #[case] version: Option<&str>, @@ -702,54 +695,47 @@ mod tests { } } - #[test] - fn cleanup_preserves_unrelated_files_and_tolerates_disappearing_markers() { + #[rstest] + #[case::success(None)] + #[case::entry_failure(Some(io::ErrorKind::PermissionDenied))] + #[case::removal_failure(Some(io::ErrorKind::IsADirectory))] + fn cleanup_preserves_files_and_stops_on_errors( + #[case] expected: Option, + ) { let dir = TempDir::new().unwrap(); let marker = dir.path().join("existing.provisioned"); - let missing = dir.path().join("removed.provisioned"); + let later = dir.path().join("later.provisioned"); let unrelated = dir.path().join("keep.txt"); - std::fs::write(&marker, "complete").unwrap(); - std::fs::write(&unrelated, "keep").unwrap(); - clean_provisioning_entries( - dir.path(), - [Ok(marker.clone()), Ok(missing), Ok(unrelated.clone())], - ) - .unwrap(); - assert!(!marker.exists()); - assert_eq!(std::fs::read_to_string(unrelated).unwrap(), "keep"); - } - - #[test] - fn cleanup_propagates_directory_entry_errors_and_stops() { - let dir = TempDir::new().unwrap(); - let marker = dir.path().join("untouched.provisioned"); - std::fs::write(&marker, "complete").unwrap(); - let error = clean_provisioning_entries( + if expected == Some(io::ErrorKind::IsADirectory) { + std::fs::create_dir(&marker).unwrap(); + } else { + std::fs::write(&marker, "complete").unwrap(); + } + for path in [&later, &unrelated] { + std::fs::write(path, "keep").unwrap(); + } + let entry = if expected == Some(io::ErrorKind::PermissionDenied) { + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + } else { + Ok(marker.clone()) + }; + let result = clean_provisioning_entries( dir.path(), [ - Err(io::Error::new( - io::ErrorKind::PermissionDenied, - "cannot read directory entry", - )), - Ok(marker.clone()), + entry, + Ok(dir.path().join("removed.provisioned")), + Ok(later.clone()), + Ok(unrelated.clone()), ], - ) - .unwrap_err(); - assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); - assert_eq!(error.to_string(), "cannot read directory entry"); - assert_eq!(std::fs::read_to_string(marker).unwrap(), "complete"); - } - - #[test] - fn cleanup_propagates_marker_removal_errors() { - let dir = TempDir::new().unwrap(); - let marker = dir.path().join("directory.provisioned"); - std::fs::create_dir(&marker).unwrap(); - let mut config = Config::default(); - config.azure_init_data_dir.path = dir.path().into(); - let error = clean_provisioning_status(&config).unwrap_err(); - assert_eq!(error.kind(), io::ErrorKind::IsADirectory); - assert!(marker.is_dir()); + ); + assert_eq!( + result.map_err(|error| error.kind()), + expected.map_or(Ok(()), Err) + ); + for path in [marker, later] { + assert_eq!(path.exists(), expected.is_some(), "{path:?}"); + } + assert_eq!(std::fs::read_to_string(unrelated).unwrap(), "keep"); } #[rstest] @@ -784,13 +770,8 @@ mod tests { fn provisioning_error_exit_codes_preserve_context( #[case] error: anyhow::Error, #[case] expected: u8, - #[values(false, true)] contextual: bool, ) { - let error = if contextual { - error.context("provisioning failed") - } else { - error - }; + let error = error.context("provisioning failed"); assert_eq!( format!("{:?}", provisioning_error_exit_code(&error)), format!("{:?}", ExitCode::from(expected)) diff --git a/testinit/README.md b/testinit/README.md index 053f7d64..42835d5a 100644 --- a/testinit/README.md +++ b/testinit/README.md @@ -43,14 +43,9 @@ cargo test --locked --all-features --package azure-init --test testinit \ -- --ignored agent_provisioning_in_container ``` -Ordinary `cargo test` skips this Docker-dependent test. The -[coverage workflow](../.github/workflows/coverage-report.yml) runs it through -`cargo llvm-cov`, collects the agent's profiles, and uploads combined coverage -to Codecov. - -Scenarios cover success, hostname failure, missing-user failure, and -provisioning-marker write failure. [Codecov](../codecov.yml) targets 100% -coverage of the full [entrypoint file](../src/main.rs), not just changed lines. +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 diff --git a/testinit/testing-server/container_provisioning.py b/testinit/testing-server/container_provisioning.py index 725a9ff9..51c2c59f 100644 --- a/testinit/testing-server/container_provisioning.py +++ b/testinit/testing-server/container_provisioning.py @@ -22,7 +22,12 @@ IMAGE = "azure-init-main-tests:local" VM_ID = "00000000-0000-0000-0000-000000000000" USERNAME = "coverage-user" -SCENARIOS = ("success", "failure", "config-error", "marker-error") +SCENARIOS = { + "success": (0, None), + "failure": (1, "failed to provision hostname"), + "config-error": (78, "user not found"), + "marker-error": (1, "I/O error"), +} def check_mount_table(mtab): @@ -178,9 +183,7 @@ def inside_container(scenario): (artifacts / "stdout").write_text(result.stdout) (artifacts / "stderr").write_text(result.stderr) assert not service_errors, service_errors - expected_status = { - "success": 0, "failure": 1, "config-error": 78, "marker-error": 1, - }[scenario] + expected_status, reason = SCENARIOS[scenario] assert result.returncode == expected_status, result assert [kind for kind, _ in requests] == ["imds", "report"], requests report = requests[1][1] @@ -202,11 +205,6 @@ def inside_container(scenario): else: assert report["state"] == "NotReady", report assert report["details"]["subStatus"] == "ProvisioningFailed", report - reason = { - "failure": "failed to provision hostname", - "config-error": "user not found", - "marker-error": "I/O error", - }[scenario] assert f"reason={reason}" in report["details"]["description"], report assert not marker.exists() log = (work / "azure-init.log").read_text() diff --git a/testinit/testing-server/test_container_provisioning.py b/testinit/testing-server/test_container_provisioning.py index 99740bf3..6f7de3be 100644 --- a/testinit/testing-server/test_container_provisioning.py +++ b/testinit/testing-server/test_container_provisioning.py @@ -6,14 +6,13 @@ from contextlib import contextmanager, redirect_stdout from http.client import HTTPConnection, RemoteDisconnected import io -import json import os from pathlib import Path import subprocess import tempfile import threading import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch import container_provisioning as fixture @@ -35,34 +34,6 @@ def serve(self, handler): thread.join(timeout=5) self.assertFalse(thread.is_alive(), "mock server did not shut down") - def test_scenarios_share_handlers_with_fresh_response_state(self): - for scenario in fixture.SCENARIOS: - with self.subTest(scenario=scenario): - requests, errors = [], [] - metadata, health = fixture.make_handlers(scenario, requests, errors) - self.assertEqual(metadata._response_position, 0) - self.assertEqual(health._response_position, 0) - with self.serve(metadata) as connection: - connection.request( - "GET", "http://169.254.169.254/metadata/instance?extended=true", - headers={"Metadata": "true"}, - ) - response = connection.getresponse() - self.assertEqual(response.status, 200) - compute = json.loads(response.read())["compute"] - self.assertEqual(compute["osProfile"]["adminUsername"], fixture.USERNAME) - self.assertEqual(bool(compute["publicKeys"]), scenario == "config-error") - report = {"state": "Ready" if scenario == "success" else "NotReady"} - with self.serve(health) as connection: - connection.request("POST", "/provisioning/health", json.dumps(report)) - response = connection.getresponse() - self.assertEqual(response.status, 201) - self.assertEqual(response.read(), b"") - self.assertEqual(requests, [("imds", None), ("report", report)]) - self.assertEqual(errors, []) - self.assertEqual(metadata._response_position, 1) - self.assertEqual(health._response_position, 1) - def test_invalid_requests_are_reported(self): requests, errors = [], [] metadata, health = fixture.make_handlers("success", requests, errors) @@ -131,13 +102,15 @@ class ContainerRunnerTests(unittest.TestCase): def run_fixture(self, root, docker, profile_pattern=None): artifacts = root / "artifacts" artifacts.mkdir() - with patch.dict(os.environ): + with ( + patch.dict(os.environ), + patch.object(fixture.subprocess, "run", side_effect=docker), + redirect_stdout(io.StringIO()), + ): os.environ.pop("LLVM_PROFILE_FILE", None) if profile_pattern: os.environ["LLVM_PROFILE_FILE"] = str(profile_pattern) - with patch.object(fixture.subprocess, "run", side_effect=docker): - with redirect_stdout(io.StringIO()): - fixture.run_containers(Path("/test-agent"), artifacts) + fixture.run_containers(Path("/test-agent"), artifacts) def test_collects_profiles_and_removes_each_container(self): with tempfile.TemporaryDirectory() as temporary: @@ -173,46 +146,33 @@ def docker(command, **_options): def test_normal_run_does_not_require_profiles(self): with tempfile.TemporaryDirectory() as temporary: - calls = [] - - def docker(command, **_options): - calls.append(command[1]) - self.assertFalse(any(arg.startswith("LLVM_PROFILE_FILE=") for arg in command)) - return subprocess.CompletedProcess(command, 0, "container-id\n", "") - + docker = Mock(return_value=subprocess.CompletedProcess([], 0, "container-id\n", "")) self.run_fixture(Path(temporary), docker) - self.assertEqual(calls, ["create", "start", "rm"] * len(fixture.SCENARIOS)) - - def test_failure_and_timeout_still_remove_the_container(self): - for timed_out in (False, True): - with self.subTest(timed_out=timed_out), tempfile.TemporaryDirectory() as temporary: - calls = [] - - def docker(command, **_options): - calls.append(command[1]) - if command[1] == "start": - if timed_out: - raise subprocess.TimeoutExpired(command, 60) - return subprocess.CompletedProcess(command, 1, "", "fixture failed") - return subprocess.CompletedProcess(command, 0, "container-id\n", "") - - error = subprocess.TimeoutExpired if timed_out else subprocess.CalledProcessError - with self.assertRaises(error): - self.run_fixture(Path(temporary), docker) - self.assertEqual(calls, ["create", "start", "rm"]) - - def test_missing_profile_is_an_error_and_cleans_up(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - calls = [] - - def docker(command, **_options): - calls.append(command[1]) - return subprocess.CompletedProcess(command, 0, "container-id\n", "") - - with self.assertRaisesRegex(AssertionError, "no coverage profile"): - self.run_fixture(root, docker, root / "agent-%p-%m.profraw") - self.assertEqual(calls, ["create", "start", "rm"]) + commands = [call.args[0] for call in docker.call_args_list] + self.assertEqual([cmd[1] for cmd in commands], ["create", "start", "rm"] * len(fixture.SCENARIOS)) + self.assertFalse(any( + arg.startswith("LLVM_PROFILE_FILE=") for cmd in commands for arg in cmd + )) + + def test_failures_remove_the_container(self): + completed = subprocess.CompletedProcess([], 0, "container-id\n", "") + for start, error, needs_profile in ( + (subprocess.CompletedProcess([], 1, "", "fixture failed"), subprocess.CalledProcessError, False), + (subprocess.TimeoutExpired("docker start", 60), subprocess.TimeoutExpired, False), + (completed, AssertionError, True), + ): + with self.subTest(error=error.__name__), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + docker = Mock(side_effect=[completed, start, completed]) + profile = root / "agent-%p-%m.profraw" if needs_profile else None + with self.assertRaises(error) as failure: + self.run_fixture(root, docker, profile) + if needs_profile: + self.assertIn("no coverage profile", str(failure.exception)) + self.assertEqual( + [call.args[0][1] for call in docker.call_args_list], + ["create", "start", "rm"], + ) if __name__ == "__main__": From 152cb3cdf81263832e9ea96246d9e3f9774867b2 Mon Sep 17 00:00:00 2001 From: peytonr18 Date: Tue, 10 Nov 2026 12:56:33 -0800 Subject: [PATCH 7/7] test(cli): restore production paths and adapt coverage tests Restore OVF, version, and directory-cleanup implementations using container fixtures and build variants for coverage. Retain only exit-code classification for the otherwise-unreachable NonEmptyPassword case. --- .github/workflows/coverage-report.yml | 5 +- src/main.rs | 157 +----------------- testinit/Dockerfile.isolated | 2 +- .../testing-server/container_provisioning.py | 155 ++++++++++++----- testinit/testing-server/fs_faults.c | 65 ++++++++ .../test_container_provisioning.py | 13 ++ testinit/testing-server/version_coverage.py | 84 ++++++++++ testinit/tests/provisioning.rs | 20 ++- 8 files changed, 303 insertions(+), 198 deletions(-) create mode 100644 testinit/testing-server/fs_faults.c create mode 100644 testinit/testing-server/version_coverage.py diff --git a/.github/workflows/coverage-report.yml b/.github/workflows/coverage-report.yml index d61835d2..16e7e2f0 100644 --- a/.github/workflows/coverage-report.yml +++ b/.github/workflows/coverage-report.yml @@ -64,9 +64,12 @@ jobs: - 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 diff --git a/src/main.rs b/src/main.rs index 08bfb767..11509a90 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,10 +3,7 @@ mod logging; mod tracing_layer; -use std::{ - future::Future, - path::{Path, PathBuf}, -}; +use std::{future::Future, path::PathBuf}; use anyhow::Context; use clap::{Parser, Subcommand}; @@ -33,26 +30,14 @@ const PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); const REPORT_AGENT: &str = concat!("Azure-Init/", env!("CARGO_PKG_VERSION")); fn version_string() -> String { - format_version( - option_env!("AZURE_INIT_VERSION"), - option_env!("AZURE_INIT_BUILD_VERSION"), - option_env!("AZURE_INIT_BUILD_SHA"), - ) -} - -fn format_version( - version: Option<&str>, - build: Option<&str>, - sha: Option<&str>, -) -> String { - if let Some(v) = version { + if let Some(v) = option_env!("AZURE_INIT_VERSION") { return v.to_string(); } - if let Some(build) = build { + if let Some(build) = option_env!("AZURE_INIT_BUILD_VERSION") { return format!("{PKG_VERSION}-{build}"); } - if let Some(sha) = sha { + if let Some(sha) = option_env!("AZURE_INIT_BUILD_SHA") { let short = &sha[..std::cmp::min(7, sha.len())]; return format!("{PKG_VERSION}-{short}"); } @@ -120,18 +105,12 @@ enum Command { #[instrument] fn get_environment() -> Result { tracing::debug!("Searching for OVF environment on local block devices."); - get_environment_from_devices(get_mount_device(None)?, mount_parse_ovf_env) -} - -fn get_environment_from_devices( - ovf_devices: impl IntoIterator, - mut load: impl FnMut(String) -> Result, -) -> Result { + let ovf_devices = get_mount_device(None)?; let mut environment: Option = None; // loop until it finds a correct device. for dev in ovf_devices { - environment = match load(dev) { + environment = match mount_parse_ovf_env(dev) { Ok(env) => { tracing::info!( target = "libazureinit::media::success", @@ -187,6 +166,7 @@ fn get_username( #[instrument] fn clean_provisioning_status(config: &Config) -> Result<(), std::io::Error> { let data_dir = &config.azure_init_data_dir.path; + let mut found = false; let entries = match std::fs::read_dir(data_dir) { Ok(entries) => entries, @@ -207,21 +187,9 @@ fn clean_provisioning_status(config: &Config) -> Result<(), std::io::Error> { } }; - clean_provisioning_entries( - data_dir, - entries.map(|entry| entry.map(|entry| entry.path())), - ) -} - -fn clean_provisioning_entries( - data_dir: &Path, - entries: impl IntoIterator>, -) -> Result<(), std::io::Error> { - let mut found = false; - for entry in entries { let path = match entry { - Ok(path) => path, + Ok(e) => e.path(), Err(e) => { tracing::error!( "Failed to read directory entry in {:?}: {:?}", @@ -590,72 +558,6 @@ mod tests { const VM_ID: &str = "00000000-0000-0000-0000-000000000001"; - #[rstest] - #[case::override_version( - Some("custom"), Some("build"), Some("123456789"), "custom".into() - )] - #[case::build_version( - None, Some("build"), Some("123456789"), format!("{PKG_VERSION}-build") - )] - #[case::long_sha( - None, None, Some("123456789"), format!("{PKG_VERSION}-1234567") - )] - #[case::short_sha( - None, None, Some("abc"), format!("{PKG_VERSION}-abc") - )] - #[case::package_version(None, None, None, PKG_VERSION.into())] - fn version_metadata_precedence( - #[case] version: Option<&str>, - #[case] build: Option<&str>, - #[case] sha: Option<&str>, - #[case] expected: String, - ) { - assert_eq!(format_version(version, build, sha), expected); - } - - #[rstest] - #[case::no_devices(vec![], None)] - #[case::unreadable_devices(vec!["failed"], None)] - #[case::last_valid_environment( - vec!["failed", "first", "failed", "last", "failed"], Some("last") - )] - fn environment_selection_preserves_success_after_failed_devices( - #[case] devices: Vec<&str>, - #[case] expected: Option<&str>, - ) { - let mut attempted = Vec::new(); - let result = get_environment_from_devices( - devices.iter().map(|device| device.to_string()), - |device| { - attempted.push(device.clone()); - if device == "failed" { - return Err(io::Error::other("unreadable media").into()); - } - let mut environment = Environment::default(); - environment - .provisioning_section - .linux_prov_conf_set - .username = device; - Ok(environment) - }, - ); - assert_eq!(attempted, devices); - match expected { - Some(username) => assert_eq!( - result - .unwrap() - .provisioning_section - .linux_prov_conf_set - .username, - username - ), - None => assert_eq!( - result.unwrap_err().to_string(), - "Unable to get list of block devices" - ), - } - } - #[rstest] #[case::imds_preferred(true, true, Some("imds-user"))] #[case::imds_only(true, false, Some("imds-user"))] @@ -695,49 +597,6 @@ mod tests { } } - #[rstest] - #[case::success(None)] - #[case::entry_failure(Some(io::ErrorKind::PermissionDenied))] - #[case::removal_failure(Some(io::ErrorKind::IsADirectory))] - fn cleanup_preserves_files_and_stops_on_errors( - #[case] expected: Option, - ) { - let dir = TempDir::new().unwrap(); - let marker = dir.path().join("existing.provisioned"); - let later = dir.path().join("later.provisioned"); - let unrelated = dir.path().join("keep.txt"); - if expected == Some(io::ErrorKind::IsADirectory) { - std::fs::create_dir(&marker).unwrap(); - } else { - std::fs::write(&marker, "complete").unwrap(); - } - for path in [&later, &unrelated] { - std::fs::write(path, "keep").unwrap(); - } - let entry = if expected == Some(io::ErrorKind::PermissionDenied) { - Err(io::Error::from(io::ErrorKind::PermissionDenied)) - } else { - Ok(marker.clone()) - }; - let result = clean_provisioning_entries( - dir.path(), - [ - entry, - Ok(dir.path().join("removed.provisioned")), - Ok(later.clone()), - Ok(unrelated.clone()), - ], - ); - assert_eq!( - result.map_err(|error| error.kind()), - expected.map_or(Ok(()), Err) - ); - for path in [marker, later] { - assert_eq!(path.exists(), expected.is_some(), "{path:?}"); - } - assert_eq!(std::fs::read_to_string(unrelated).unwrap(), "keep"); - } - #[rstest] #[case::existing("file", Ok(()))] #[case::missing("missing", Ok(()))] diff --git a/testinit/Dockerfile.isolated b/testinit/Dockerfile.isolated index c56c0333..bb132ca0 100644 --- a/testinit/Dockerfile.isolated +++ b/testinit/Dockerfile.isolated @@ -4,7 +4,7 @@ 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 \ + && 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 \ diff --git a/testinit/testing-server/container_provisioning.py b/testinit/testing-server/container_provisioning.py index 51c2c59f..04c3ef36 100644 --- a/testinit/testing-server/container_provisioning.py +++ b/testinit/testing-server/container_provisioning.py @@ -27,7 +27,25 @@ "failure": (1, "failed to provision hostname"), "config-error": (78, "user not found"), "marker-error": (1, "I/O error"), + "ovf-fallback": (1, "failed to retrieve instance metadata"), + "ovf-missing": (1, "failed to determine username"), + "clean-read-error": (1, "Failed to read directory entry"), + "clean-marker-error": (1, "Failed to clean provisioning marker"), + "clean-race": (0, "No provisioning marker found"), } +OVF_ENV = """ + + 1.0 + + ovf-usercoverage-host + + + + 1.0 + + +""" def check_mount_table(mtab): @@ -56,7 +74,7 @@ def handle(self): class MetadataHandler(CheckedHandler, IMDSHandler): _response_position = 0 _responses = [{ - "status_code": 200, + "status_code": 403 if scenario.startswith("ovf-") else 200, "headers": {"Content-Type": "application/json"}, "response": { "compute": { @@ -104,7 +122,10 @@ def inside_container(scenario): raise RuntimeError("fixture requires an isolated, empty /sys") if any(user.pw_name == USERNAME for user in pwd.getpwall()): raise RuntimeError(f"fixture requires a nonexistent account: {USERNAME}") - check_mount_table(Path("/etc/mtab")) + check_mount_table(Path("/proc/mounts")) + mtab = Path("/etc/mtab") + mtab.unlink(missing_ok=True) + mtab.write_text(Path("/test/mtab").read_text()) requests = [] service_errors = [] @@ -112,28 +133,42 @@ def inside_container(scenario): artifacts = Path("/artifacts") work = Path("/work") + cleaning = scenario.startswith("clean-") if scenario == "marker-error": (work / "state").write_text("not a directory") + if cleaning: + (work / "state").mkdir() + marker = work / "state" / "vanished.provisioned" + if scenario == "clean-marker-error": + marker.mkdir() + else: + marker.write_text("complete") + (work / "state" / "keep.txt").write_text("keep") + (work / "ovf-env.xml").write_text(OVF_ENV) commands = work / "commands.jsonl" stubs = work / "bin" stubs.mkdir() stub = stubs / "command-stub" stub.write_text( "#!/usr/bin/python3\n" - "import json, os, sys\n" + "import json, os, shutil, sys\n" "name = os.path.basename(sys.argv[0])\n" "with open('/work/commands.jsonl', 'a') as output:\n" " output.write(json.dumps([name, *sys.argv[1:]]) + '\\n')\n" "if name == 'getent':\n" " sys.exit(1)\n" + "if name == 'mount':\n" + " if sys.argv[-2] != '/test/ovf-good':\n" + " sys.exit(17)\n" + " shutil.copyfile('/work/ovf-env.xml', os.path.join(sys.argv[-1], 'ovf-env.xml'))\n" "if name == 'hostnamectl' and os.environ['TEST_SCENARIO'] == 'failure':\n" " print('expected hostname failure', file=sys.stderr)\n" " sys.exit(17)\n" - "if name not in ('hostnamectl', 'useradd', 'usermod', 'passwd'):\n" + "if name not in ('hostnamectl', 'useradd', 'usermod', 'passwd', 'mount', 'umount', 'eject'):\n" " raise RuntimeError('unexpected command: ' + name)\n" ) stub.chmod(0o755) - for name in ("hostnamectl", "getent", "useradd", "usermod", "passwd"): + for name in ("hostnamectl", "getent", "useradd", "usermod", "passwd", "mount", "umount", "eject"): (stubs / name).symlink_to(stub) with ( @@ -165,6 +200,8 @@ def inside_container(scenario): no_proxy="127.0.0.1,localhost", AZURE_INIT_LOG="info", ) + if scenario in ("clean-read-error", "clean-race"): + environment["LD_PRELOAD"] = "/test/fs_faults.so" servers = (imds_server, health_server) threads = [ threading.Thread(target=server.serve_forever, daemon=True) @@ -174,7 +211,7 @@ def inside_container(scenario): thread.start() try: result = subprocess.run( - ["/test/azure-init", "--config", str(config)], + ["/test/azure-init", "--config", str(config), *(["clean"] if cleaning else [])], env=environment, capture_output=True, text=True, @@ -185,40 +222,53 @@ def inside_container(scenario): assert not service_errors, service_errors expected_status, reason = SCENARIOS[scenario] assert result.returncode == expected_status, result - assert [kind for kind, _ in requests] == ["imds", "report"], requests - report = requests[1][1] - (artifacts / "http-report.json").write_text(json.dumps(report)) - marker = work / "state" / f"{VM_ID}.provisioned" - calls = [json.loads(line) for line in commands.read_text().splitlines()] - expected_commands = ( - ["hostnamectl"] - if scenario == "failure" - else ["hostnamectl", "getent", "useradd", "passwd"] - ) - assert [call[0] for call in calls] == expected_commands, calls - if scenario == "success": - assert report == {"state": "Ready"}, report - assert marker.is_file() - assert Path("/etc/sudoers.d/azure-init-user").read_text() == ( - f"{USERNAME} ALL=(ALL) NOPASSWD: ALL\n" - ) - else: - assert report["state"] == "NotReady", report - assert report["details"]["subStatus"] == "ProvisioningFailed", report - assert f"reason={reason}" in report["details"]["description"], report - assert not marker.exists() log = (work / "azure-init.log").read_text() - if scenario == "marker-error": - assert (work / "state").read_text() == "not a directory" - assert "Failed to mark provisioning complete." in log, log - expected_log = ( - "Provisioning completed successfully" - if scenario == "success" - else "Provisioning failed with error:" - ) - assert expected_log in log, log + if cleaning: + assert requests == [], requests + assert not commands.exists() + assert marker.exists() == (scenario != "clean-race") + assert (work / "state" / "keep.txt").read_text() == "keep" + assert reason in log, log + else: + assert [kind for kind, _ in requests] == ["imds", "report"], requests + report = requests[1][1] + (artifacts / "http-report.json").write_text(json.dumps(report)) + marker = work / "state" / f"{VM_ID}.provisioned" + calls = [json.loads(line) for line in commands.read_text().splitlines()] + if scenario == "ovf-fallback": + expected_commands = ["mount", "mount", "umount", "eject", "mount"] + assert log.count("Successfully parsed OVF environment.") == 1, log + elif scenario == "ovf-missing": + expected_commands = ["mount", "mount"] + else: + expected_commands = ( + ["hostnamectl"] + if scenario == "failure" + else ["hostnamectl", "getent", "useradd", "passwd"] + ) + assert [call[0] for call in calls] == expected_commands, calls + if scenario == "success": + assert report == {"state": "Ready"}, report + assert marker.is_file() + assert Path("/etc/sudoers.d/azure-init-user").read_text() == ( + f"{USERNAME} ALL=(ALL) NOPASSWD: ALL\n" + ) + else: + assert report["state"] == "NotReady", report + assert report["details"]["subStatus"] == "ProvisioningFailed", report + assert f"reason={reason}" in report["details"]["description"], report + assert not marker.exists() + if scenario == "marker-error": + assert (work / "state").read_text() == "not a directory" + assert "Failed to mark provisioning complete." in log, log + expected_log = ( + "Provisioning completed successfully" + if scenario == "success" + else "Provisioning failed with error:" + ) + assert expected_log in log, log shutil.copyfile("/var/lib/hyperv/.kvp_pool_1", artifacts / ".kvp_pool_1") - print(f"Verified {scenario}: actual agent exit {result.returncode} and health report") + print(f"Verified {scenario}: actual agent exit {result.returncode}") finally: for server in servers: server.shutdown() @@ -239,17 +289,30 @@ def run_containers(agent, artifacts): output.mkdir() # Container root lacks DAC_OVERRIDE; only this disposable output is writable. output.chmod(0o777) - with tempfile.TemporaryDirectory(prefix="azure-init-empty-sys-") as empty_sys: - Path(empty_sys).chmod(0o755) + with tempfile.TemporaryDirectory(prefix="azure-init-fixture-") as temporary: + empty_sys = Path(temporary) / "sys" + empty_sys.mkdir(mode=0o755) + mtab = Path(temporary) / "mtab" + devices = [] + if scenario.startswith("ovf-"): + devices = ["/test/missing-before", "/test/missing-after"] + if scenario == "ovf-fallback": + devices.insert(1, "/test/ovf-good") + mtab.write_text("".join(f"{device} /media iso9660 ro 0 0\n" for device in devices)) + mtab.chmod(0o644) command = [ "docker", "create", "--name", f"azure-init-test-{uuid.uuid4().hex}", "--network", "none", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--read-only", + "--mount", "type=volume,dst=/etc", "--tmpfs", "/work:exec,mode=0700", "--tmpfs", "/var/lib/hyperv:mode=0700", "--tmpfs", "/etc/sudoers.d:mode=0700", + "--tmpfs", "/run/azure-init/media:mode=0700", "--mount", f"type=bind,src={agent},dst=/test/azure-init,readonly", "--mount", f"type=bind,src={empty_sys},dst=/sys,readonly", + "--mount", f"type=bind,src={mtab},dst=/test/mtab,readonly", + "--mount", f"type=bind,src={artifacts / 'fs_faults.so'},dst=/test/fs_faults.so,readonly", "--mount", f"type=bind,src={output},dst=/artifacts", ] if profile_pattern: @@ -281,7 +344,7 @@ def run_containers(agent, artifacts): ) finally: subprocess.run( - ["docker", "rm", "--force", container], + ["docker", "rm", "--force", "--volumes", container], check=True, capture_output=True, text=True, timeout=15, ) @@ -295,6 +358,14 @@ def run_containers(agent, artifacts): if args.inside: inside_container(args.inside) elif args.agent and args.artifacts: - run_containers(args.agent.resolve(strict=True), args.artifacts.resolve(strict=True)) + artifacts = args.artifacts.resolve(strict=True) + faults = artifacts / "fs_faults.so" + subprocess.run( + ["cc", "-shared", "-fPIC", "-Wall", "-Wextra", "-Werror", + str(Path(__file__).with_name("fs_faults.c")), "-o", str(faults), "-ldl"], + check=True, timeout=30, + ) + faults.chmod(0o644) + run_containers(args.agent.resolve(strict=True), artifacts) else: parser.error("supply --inside or both --agent and --artifacts") diff --git a/testinit/testing-server/fs_faults.c b/testinit/testing-server/fs_faults.c new file mode 100644 index 00000000..e0b31795 --- /dev/null +++ b/testinit/testing-server/fs_faults.c @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include + +static void *next_symbol(const char *name) { + int saved_errno = errno; + void *symbol = dlsym(RTLD_NEXT, name); + if (!symbol) { + fprintf(stderr, "Cannot resolve %s: %s\n", name, dlerror()); + _Exit(127); + } + errno = saved_errno; + return symbol; +} + +static bool enabled(const char *scenario) { + int saved_errno = errno; + const char *current = getenv("TEST_SCENARIO"); + bool result = current && strcmp(current, scenario) == 0 && + access("/test/isolated-provisioning", F_OK) == 0; + errno = saved_errno; + return result; +} + +static bool is_state_directory(DIR *directory) { + int saved_errno = errno; + char descriptor[64], path[sizeof("/work/state")]; + snprintf(descriptor, sizeof(descriptor), "/proc/self/fd/%d", dirfd(directory)); + ssize_t length = readlink(descriptor, path, sizeof(path)); + errno = saved_errno; + return length >= 0 && (size_t)length == sizeof("/work/state") - 1 && + memcmp(path, "/work/state", (size_t)length) == 0; +} + +// Inject faults only at the disposable fixture's cleanup paths. +struct dirent64 *readdir64(DIR *directory) { + if (enabled("clean-read-error") && is_state_directory(directory)) { + errno = EIO; + return NULL; + } + struct dirent64 *(*read_next)(DIR *) = + (struct dirent64 *(*)(DIR *))next_symbol("readdir64"); + return read_next(directory); +} + +int unlink(const char *path) { + int (*remove_file)(const char *) = + (int (*)(const char *))next_symbol("unlink"); + int result = remove_file(path); + if (result == 0 && enabled("clean-race") && + strcmp(path, "/work/state/vanished.provisioned") == 0) { + errno = ENOENT; + return -1; + } + return result; +} diff --git a/testinit/testing-server/test_container_provisioning.py b/testinit/testing-server/test_container_provisioning.py index 6f7de3be..bf9e58f1 100644 --- a/testinit/testing-server/test_container_provisioning.py +++ b/testinit/testing-server/test_container_provisioning.py @@ -132,10 +132,23 @@ def docker(command, **_options): self.assertEqual(command[command.index("--cap-drop") + 1], "ALL") self.assertIn("no-new-privileges", command) self.assertIn("--read-only", command) + self.assertIn("type=volume,dst=/etc", command) self.assertIn("/work:exec,mode=0700", command) + self.assertIn("/run/azure-init/media:mode=0700", command) + for mount in command: + if mount.endswith("dst=/sys,readonly"): + source = Path(mount.split("src=", 1)[1].split(",dst=", 1)[0]) + self.assertEqual(list(source.iterdir()), []) + if mount.endswith("dst=/test/mtab,readonly"): + source = Path(mount.split("src=", 1)[1].split(",dst=", 1)[0]) + contents = source.read_text() + self.assertEqual("iso9660" in contents, command[-1].startswith("ovf-")) + self.assertEqual("/test/ovf-good" in contents, command[-1] == "ovf-fallback") return subprocess.CompletedProcess(command, 0, "container-id\n", "") if command[1] == "start": (output / "agent-1-123.profraw").write_bytes(b"synthetic profile") + if command[1] == "rm": + self.assertIn("--volumes", command) return subprocess.CompletedProcess(command, 0, "", "") self.run_fixture(root, docker, profiles / "agent-%p-%m.profraw") diff --git a/testinit/testing-server/version_coverage.py b/testinit/testing-server/version_coverage.py new file mode 100644 index 00000000..811927f5 --- /dev/null +++ b/testinit/testing-server/version_coverage.py @@ -0,0 +1,84 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Test compile-time version choices without changing the production function.""" + +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile + + +ROOT = Path(__file__).resolve().parents[2] +SHA = "0123456789abcdef0123456789abcdef01234567" +GIT_SHIM = """#!/usr/bin/python3 +import os, sys +if sys.argv[1:] == ["describe", "--dirty", "--tags"]: + sys.exit(1) +if sys.argv[1:] == ["rev-parse", "HEAD"]: + sha = os.environ.get("TEST_BUILD_SHA") + if sha: + print(sha) + sys.exit(0) + sys.exit(1) +os.execv(os.environ["TEST_REAL_GIT"], ["git", *sys.argv[1:]]) +""" + + +def main(): + metadata = json.loads(subprocess.check_output( + ["cargo", "metadata", "--locked", "--no-deps", "--format-version=1"], + cwd=ROOT, text=True, + )) + version = next(p["version"] for p in metadata["packages"] if p["name"] == "azure-init") + real_git = shutil.which("git") + if real_git is None: + raise RuntimeError("git is required for version coverage tests") + + for variant, expected in ( + ("override", "coverage-override"), + ("sha", f"{version}-{SHA[:7]}"), + ("package", version), + ): + with tempfile.TemporaryDirectory(prefix=f"azure-init-version-{variant}-") as temporary: + directory = Path(temporary) + environment = dict(os.environ) + for variable in ( + "AZURE_INIT_VERSION", "AZURE_INIT_BUILD_VERSION", + "AZURE_INIT_BUILD_SHA", "LLVM_PROFILE_FILE", + ): + environment.pop(variable, None) + environment["CARGO_LLVM_COV_TARGET_DIR"] = str(directory / "target") + if variant == "override": + environment["AZURE_INIT_VERSION"] = expected + else: + git = directory / "git" + git.write_text(GIT_SHIM) + git.chmod(0o755) + environment.update( + PATH=f"{directory}{os.pathsep}{environment['PATH']}", + TEST_REAL_GIT=real_git, + TEST_BUILD_SHA=SHA if variant == "sha" else "", + ) + result = subprocess.run( + ["cargo", "llvm-cov", "run", "--locked", "--all-features", + "--package", "azure-init", "--bin", "azure-init", "--no-report", + "--", "--version"], + cwd=ROOT, env=environment, check=True, text=True, + stdout=subprocess.PIPE, timeout=600, + ) + assert result.stdout == f"azure-init {expected}\n", result.stdout + report = ROOT / "target" / f"version-{variant}.lcov" + report.parent.mkdir(exist_ok=True) + subprocess.run( + ["cargo", "llvm-cov", "report", "--package", "azure-init", + "--lcov", "--output-path", str(report)], + cwd=ROOT, env=environment, check=True, timeout=60, + ) + print(f"Verified {variant}: {expected}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/testinit/tests/provisioning.rs b/testinit/tests/provisioning.rs index 74e8cc88..fd34b45a 100644 --- a/testinit/tests/provisioning.rs +++ b/testinit/tests/provisioning.rs @@ -26,16 +26,26 @@ fn agent_provisioning_in_container() { .success(); for (scenario, result) in [ - ("success", "success"), - ("failure", "error"), - ("config-error", "error"), - ("marker-error", "error"), + ("success", Some("success")), + ("failure", Some("error")), + ("config-error", Some("error")), + ("marker-error", Some("error")), + ("ovf-fallback", Some("error")), + ("ovf-missing", Some("error")), + ("clean-read-error", None), + ("clean-marker-error", None), + ("clean-race", None), ] { let directory = artifacts.path().join(scenario); let store = KvpPoolStore::new_in(KvpPool::Guest, &directory, PoolMode::Safe) .unwrap(); - let encoded = store.read(PROVISIONING_REPORT_KEY).unwrap().unwrap(); + let encoded = store.read(PROVISIONING_REPORT_KEY).unwrap(); + let Some(result) = result else { + assert_eq!(encoded, None, "cleanup must not report provisioning"); + continue; + }; + let encoded = encoded.unwrap(); let report: ProvisioningReport = encoded.parse().unwrap(); let report = serde_json::to_value(report).unwrap(); assert_eq!(report["result"], result);