← Back to team overview

sts-sponsors team mailing list archive

[Merge] ~adam-collard/maas-ci/+git/system-tests:lxd-instance-facade into ~maas-committers/maas-ci/+git/system-tests:master

 

Adam Collard has proposed merging ~adam-collard/maas-ci/+git/system-tests:lxd-instance-facade into ~maas-committers/maas-ci/+git/system-tests:master.

Commit message:
Add lxd.Instance facade for interacting with a particular LXD instance



Requested reviews:
  MAAS Committers (maas-committers)

For more details, see:
https://code.launchpad.net/~adam-collard/maas-ci/+git/system-tests/+merge/435762
-- 
Your team MAAS Committers is requested to review the proposed merge of ~adam-collard/maas-ci/+git/system-tests:lxd-instance-facade into ~maas-committers/maas-ci/+git/system-tests:master.
diff --git a/systemtests/api.py b/systemtests/api.py
index 568d6fa..20cc361 100644
--- a/systemtests/api.py
+++ b/systemtests/api.py
@@ -1,7 +1,6 @@
 from __future__ import annotations
 
 import json
-from functools import partial
 from logging import getLogger
 from subprocess import CalledProcessError
 from typing import TYPE_CHECKING, Any, Dict, Iterable, Optional, TypedDict, Union
@@ -12,7 +11,7 @@ from .utils import wait_for_machine
 if TYPE_CHECKING:
     from logging import Logger
 
-    from . import lxd
+    from .lxd import Instance
 
 LOG = getLogger("systemtests.api")
 
@@ -84,34 +83,31 @@ class UnauthenticatedMAASAPIClient:
 
     MAAS_CMD = ["sudo", "-u", "ubuntu", "maas"]
 
-    def __init__(self, url: str, maas_container: str, lxd: lxd.CLILXD):
+    def __init__(self, url: str, maas_container: Instance):
         self.url = url
         self.maas_container = maas_container
-        self.lxd = lxd
-        self.pull_file = partial(lxd.pull_file, maas_container)
-        self.push_file = partial(lxd.push_file, maas_container)
 
     def __repr__(self) -> str:
         return f"<UnauthenticatedMAASAPIClient for {self.url!r}>"
 
     @property
     def logger(self) -> Logger:
-        return self.lxd.logger
+        return self.maas_container.logger
 
     @logger.setter
     def logger(self, logger: Logger) -> None:
-        self.lxd.logger = logger
+        self.maas_container.logger = logger
 
     def execute(self, cmd: list[str], base_cmd: Optional[list[str]] = None) -> str:
         __tracebackhide__ = True
         if base_cmd is None:
             base_cmd = self.MAAS_CMD
-        result = self.lxd.execute(self.maas_container, base_cmd + cmd)
+        result = self.maas_container.execute(base_cmd + cmd)
         return result.stdout
 
     def quietly_execute(self, cmd: list[str]) -> str:
         __tracebackhide__ = True
-        result = self.lxd.quietly_execute(self.maas_container, self.MAAS_CMD + cmd)
+        result = self.maas_container.quietly_execute(self.MAAS_CMD + cmd)
         return result.stdout
 
     def log_in(self, session: str, token: str) -> tuple[str, AuthenticatedAPIClient]:
@@ -133,10 +129,6 @@ class AuthenticatedAPIClient:
         return f"<AuthenticatedAPIClient for {self.api_client.url!r}>"
 
     @property
-    def lxd(self) -> lxd.CLILXD:
-        return self.api_client.lxd
-
-    @property
     def logger(self) -> Logger:
         return self.api_client.logger
 
diff --git a/systemtests/collect_sos_report/test_collect.py b/systemtests/collect_sos_report/test_collect.py
index ff625da..a5c834b 100644
--- a/systemtests/collect_sos_report/test_collect.py
+++ b/systemtests/collect_sos_report/test_collect.py
@@ -1,12 +1,35 @@
 import contextlib
 import subprocess
-from logging import getLogger
+import tempfile
+from pathlib import Path
+from typing import TYPE_CHECKING
 
-from ..lxd import get_lxd
+if TYPE_CHECKING:
+    from ..lxd import Instance
 
 
-def test_collect_sos_report(maas_container: str) -> None:
-    lxd = get_lxd(getLogger("collect_sos_report"))
-    assert lxd.container_exists(maas_container)
+def collect_sos_report(instance: Instance, output: Path) -> None:
+    container_tmp = "/tmp/sosreport"
+    output_dir = output / "sosreport"
+    instance.execute(["apt", "install", "--yes", "sosreport"])
+    instance.execute(["rm", "-rf", container_tmp])
+    instance.execute(["mkdir", "-p", container_tmp])
+    instance.execute(
+        ["sos", "report", "--batch", "-o", "maas", "--tmp-dir", container_tmp]
+    )
+    output_dir.mkdir(parents=True, exist_ok=True)
+    journalctl = instance.execute(
+        ["journalctl", "--unit=vault", "--no-pager", "--output=cat"]
+    )
+    if journalctl.stdout:
+        (output_dir / "vault-journal").write_text(journalctl.stdout)
+    with tempfile.TemporaryDirectory(prefix="sosreport") as tempdir:
+        instance.files[container_tmp].pull(tempdir)
+        for f in (Path(tempdir) / "sosreport").iterdir():
+            f.rename(output_dir / f.name)
+
+
+def test_collect_sos_report(maas_container: Instance) -> None:
+    assert maas_container.exists()
     with contextlib.suppress(subprocess.CalledProcessError):
-        lxd.collect_sos_report(maas_container, ".")
+        collect_sos_report(maas_container, Path("."))
diff --git a/systemtests/conftest.py b/systemtests/conftest.py
index 39ef76e..39c59a0 100644
--- a/systemtests/conftest.py
+++ b/systemtests/conftest.py
@@ -30,7 +30,7 @@ from .fixtures import (
     vault,
     zone,
 )
-from .lxd import get_lxd
+from .lxd import Instance, get_lxd
 from .machine_config import MachineConfig
 from .state import (
     authenticated_admin,
@@ -175,20 +175,19 @@ def hardware_sync_machine(
     machine = authenticated_admin.list_machines(mac_address=machine_config.mac_address)[
         0
     ]
+    instance = Instance(get_lxd(LOG), machine_config.name)
     assert machine["status"] == STATUS_READY
     yield HardwareSyncMachine(
         name=machine_config.name,
         machine=machine,
         devices_config=machine_config.devices_config,
+        instance=instance,
     )
     if machine_config.power_type == "lxd":
-        lxd = get_lxd(LOG)
-        current_devices = lxd.list_instance_devices(machine_config.name)
+        current_devices = instance.list_devices()
         for additional_device in machine_config.devices_config:
             if additional_device["device_name"] in current_devices:
-                lxd.remove_instance_device(
-                    machine_config.name, additional_device["device_name"]
-                )
+                instance.remove_device(additional_device["device_name"])
     authenticated_admin.release_machine(machine)
     wait_for_machine(
         authenticated_admin,
diff --git a/systemtests/device_config.py b/systemtests/device_config.py
index 41d883b..342f8aa 100644
--- a/systemtests/device_config.py
+++ b/systemtests/device_config.py
@@ -4,6 +4,7 @@ from dataclasses import dataclass
 from typing import Dict
 
 from .api import Machine
+from .lxd import Instance
 
 DeviceConfig = Dict[str, str]
 
@@ -13,3 +14,4 @@ class HardwareSyncMachine:
     name: str
     machine: Machine
     devices_config: tuple[DeviceConfig, ...]
+    instance: Instance
diff --git a/systemtests/env_builder/test_basic.py b/systemtests/env_builder/test_basic.py
index 5f53438..265fa06 100644
--- a/systemtests/env_builder/test_basic.py
+++ b/systemtests/env_builder/test_basic.py
@@ -9,8 +9,10 @@ from urllib.request import urlopen
 import pytest
 from retry import retry
 
-from systemtests.lxd import get_lxd
+from systemtests.lxd import Instance, get_lxd
 from systemtests.utils import (
+    UnexpectedMachineStatus,
+    debug_lxd_vm,
     randomstring,
     retries,
     wait_for_machine,
@@ -118,13 +120,10 @@ class TestSetup:
         """Ensure that we have a Ready VM at the end."""
         lxd = get_lxd(logger=testlog)
         vm_name = instance_config.name
-        try:
-            lxd.instance_status(vm_name)
-        except ValueError:
-            pass
-        else:
+        instance = Instance(lxd, vm_name)
+        if instance.exists():
             # Force delete the VM so we know we're starting clean
-            lxd.delete(vm_name)
+            instance.delete()
 
         # Need to create a network device with a hwaddr
         config: dict[str, str] = {"security.secureboot": "false"}
@@ -133,7 +132,7 @@ class TestSetup:
         if instance_config.mac_address:
             config["volatile.eth0.hwaddr"] = instance_config.mac_address
 
-        lxd.create_vm(vm_name, config)
+        instance = lxd.create_vm(vm_name, config)
 
         mac_address = instance_config.mac_address
 
@@ -145,29 +144,33 @@ class TestSetup:
         else:
             # Machine not registered, let's boot it up
             @retry(tries=5, delay=5, backoff=1.2, logger=testlog)
-            def _boot_vm(vm_name: str) -> None:
-                status = lxd.instance_status(vm_name)
+            def _boot_vm(vm: Instance) -> None:
+                status = instance.status()
                 if status == "RUNNING":
-                    testlog.debug(f"{vm_name} is already running, restarting")
-                    lxd.restart(vm_name)
+                    testlog.debug(f"{instance.name} is already running, restarting")
+                    instance.restart()
                 elif status == "STOPPED":
-                    testlog.debug(f"{vm_name} is stopped, starting")
+                    testlog.debug(f"{instance.name} is stopped, starting")
                     try:
-                        lxd.start(vm_name)
+                        instance.start()
                     except CalledProcessError:
-                        lxd._run(["lxc", "info", "--show-log", vm_name])
+                        debug_lxd_vm(vm_name, testlog)
                         raise
                 else:
                     assert False, f"Don't know how to handle lxd_vm status: {status}"
 
-            _boot_vm(vm_name)
+            _boot_vm(instance)
             try:
-                vm_status = lxd.instance_status(vm_name)
+                vm_status = instance.status()
             except ValueError:
                 vm_status = "not available"
             testlog.debug(f"{vm_name} is {vm_status}")
 
-            machine = wait_for_new_machine(maas_api_client, mac_address, vm_name)
+            try:
+                machine = wait_for_new_machine(maas_api_client, mac_address, vm_name)
+            except UnexpectedMachineStatus:
+                debug_lxd_vm(vm_name, testlog)
+                raise
 
         # Make sure we have power parameters set
         if not machine["power_type"]:
diff --git a/systemtests/fixtures.py b/systemtests/fixtures.py
index eff8651..83a4843 100644
--- a/systemtests/fixtures.py
+++ b/systemtests/fixtures.py
@@ -2,10 +2,9 @@ from __future__ import annotations
 
 import io
 import os
-from functools import partial
 from logging import StreamHandler, getLogger
 from textwrap import dedent
-from typing import TYPE_CHECKING, Any, Callable, Iterator, Optional, TextIO
+from typing import TYPE_CHECKING, Any, Iterator, Optional, TextIO
 
 import paramiko
 import pytest
@@ -14,7 +13,7 @@ from pytest_steps import one_fixture_per_step
 
 from .api import UnauthenticatedMAASAPIClient
 from .config import ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_USER
-from .lxd import CLILXD, get_lxd
+from .lxd import get_lxd
 from .o11y import setup_o11y
 from .region import MAASRegion
 from .tls import MAAS_CONTAINER_CERTS_PATH, setup_tls
@@ -24,32 +23,35 @@ if TYPE_CHECKING:
     from logging import Logger
 
     from .api import AuthenticatedAPIClient
+    from .lxd import Instance
+
 LOG_NAME = "systemtests.fixtures"
 
 LXD_PROFILE = os.environ.get("MAAS_SYSTEMTESTS_LXD_PROFILE", "prof-maas-lab")
 
 
 def _add_maas_ppa(
-    exec_on_container: Callable[..., Any],
+    instance: Instance,
     maas_ppas: list[str],
 ) -> None:
     """Add MAAS PPA to the given container."""
     for ppa in maas_ppas:
-        exec_on_container(
+        instance.execute(
             ["add-apt-repository", "-y", ppa],
             environment={"DEBIAN_FRONTEND": "noninteractive"},
         )
 
 
 @pytest.fixture(scope="session")
-def build_container(config: dict[str, Any]) -> Iterator[str]:
+def build_container(config: dict[str, Any]) -> Iterator[Instance]:
     """Create a container for building MAAS package in."""
     log = getLogger(f"{LOG_NAME}.build_container")
     lxd = get_lxd(log)
     container_name = os.environ.get(
         "MAAS_SYSTEMTESTS_BUILD_CONTAINER", "maas-system-build"
     )
-    if not lxd.container_exists(container_name):
+    instance = Instance(lxd, container_name)
+    if not instance.exists():
         cloud_config = {}
 
         http_proxy = config.get("proxy", {}).get("http", "")
@@ -68,19 +70,19 @@ def build_container(config: dict[str, Any]) -> Iterator[str]:
             profile=LXD_PROFILE,
         )
 
-    yield container_name
+    yield instance
 
 
 @pytest.fixture(scope="session")
 def maas_deb_repo(
-    build_container: str, config: dict[str, Any]
+    build_container: Instance, config: dict[str, Any]
 ) -> Iterator[Optional[str]]:
     """Build maas deb, and setup APT repo."""
     if "snap" in config:
         yield None
     else:
-        lxd = get_lxd(getLogger(f"{LOG_NAME}.maas_deb_repo"))
-        build_ip = lxd.get_ip_address(build_container)
+        build_container.logger = getLogger(f"{LOG_NAME}.maas_deb_repo")
+        build_ip = build_container.get_ip_address()
         http_proxy = config.get("proxy", {}).get("http", "")
         proxy_env: Optional[dict[str, str]]
         if http_proxy:
@@ -92,14 +94,12 @@ def maas_deb_repo(
         else:
             proxy_env = None
 
-        if not lxd.file_exists(build_container, "/var/www/html/repo/Packages.gz"):
+        if not build_container.files["/var/www/html/repo/Packages.gz"].exists():
             maas_ppas = config.get("deb", {}).get(
                 "ppa", ["ppa:maas-committers/latest-deps"]
             )
-            exec_on_container = partial(lxd.execute, build_container)
-            _add_maas_ppa(exec_on_container, maas_ppas)
-            lxd.execute(
-                build_container,
+            _add_maas_ppa(build_container, maas_ppas)
+            build_container.execute(
                 [
                     "apt",
                     "install",
@@ -116,8 +116,7 @@ def maas_deb_repo(
                 "git_repo", "https://git.launchpad.net/maas";
             )
             maas_git_branch = str(config.get("deb", {}).get("git_branch", "master"))
-            lxd.execute(
-                build_container,
+            build_container.execute(
                 [
                     "git",
                     "clone",
@@ -134,8 +133,7 @@ def maas_deb_repo(
                 ],
                 environment=proxy_env,
             )
-            lxd.execute(
-                build_container,
+            build_container.execute(
                 [
                     "mk-build-deps",
                     "--install",
@@ -147,20 +145,18 @@ def maas_deb_repo(
                 environment={"DEBIAN_FRONTEND": "noninteractive"},
             )
 
-            lxd.execute(
-                build_container,
+            build_container.execute(
                 ["make", "-C", "maas", "package"],
                 environment=proxy_env,
             )
-            lxd.execute(
-                build_container,
+            build_container.execute(
                 [
                     "sh",
                     "-c",
                     "cd build-area && (dpkg-scanpackages . | gzip -c > Packages.gz)",
                 ],
             )
-            lxd.execute(build_container, ["mv", "build-area", "/var/www/html/repo"])
+            build_container.execute(["mv", "build-area", "/var/www/html/repo"])
         yield f"http://{build_ip}/repo";
 
 
@@ -191,29 +187,32 @@ def get_user_data(
 
 
 @pytest.fixture(scope="session")
-def maas_container(config: dict[str, Any], build_container: str) -> str:
+def maas_container(config: dict[str, Any], build_container: Instance) -> Instance:
     """Build a container for running MAAS in."""
     lxd = get_lxd(getLogger(f"{LOG_NAME}.maas_container"))
-    container_name = os.environ.get(
+    profile_name = container_name = os.environ.get(
         "MAAS_SYSTEMTESTS_MAAS_CONTAINER", "maas-system-maas"
     )
-    if not lxd.container_exists(container_name):
-        if not lxd.profile_exists(container_name):
-            lxd.copy_profile(LXD_PROFILE, container_name)
+    instance = Instance(lxd, container_name)
+    # FIXME: loggers could be a stack?
+    build_container.logger = instance.logger = lxd.logger
+    if not instance.exists():
+        if not lxd.profile_exists(profile_name):
+            lxd.copy_profile(LXD_PROFILE, profile_name)
         existing_maas_nics = [
             device_name
-            for device_name in lxd.list_profile_devices(container_name)
+            for device_name in lxd.list_profile_devices(profile_name)
             if device_name.startswith("maas-ss-")
         ]
         for device_name in existing_maas_nics:
-            lxd.remove_profile_device(container_name, device_name)
+            lxd.remove_profile_device(profile_name, device_name)
         maas_networks = config["maas"]["networks"]
         devices = {}
         for network_name, ip in maas_networks.items():
             network = config["networks"][network_name]
             device_name = "maas-ss-" + network_name
             lxd.add_profile_device(
-                container_name,
+                profile_name,
                 device_name,
                 "nic",
                 "name=" + device_name,
@@ -257,7 +256,7 @@ def maas_container(config: dict[str, Any], build_container: str) -> str:
         )
 
         if "snap" not in config:
-            build_container_ip = lxd.get_ip_address(build_container)
+            build_container_ip = build_container.get_ip_address()
             contents = dedent(
                 f"""\
                 Package: *
@@ -265,9 +264,7 @@ def maas_container(config: dict[str, Any], build_container: str) -> str:
                 Pin-Priority: 999
                 """
             )
-            lxd.push_text_file(
-                container_name, contents, "/etc/apt/preferences.d/maas-build-pin-999"
-            )
+            instance.files["/etc/apt/preferences.d/maas-build-pin-999"].write(contents)
 
             http_proxy = config.get("proxy", {}).get("http", "")
             if http_proxy:
@@ -279,27 +276,21 @@ def maas_container(config: dict[str, Any], build_container: str) -> str:
                     Acquire::https::Proxy::{build_container_ip} "DIRECT";
                     """
                 )
-                lxd.push_text_file(
-                    container_name, contents, "/etc/apt/apt.conf.d/80maas-system-test"
-                )
+                instance.files["/etc/apt/apt.conf.d/80maas-system-test"].write(contents)
 
-    return container_name
+    return instance
 
 
 @pytest.fixture(scope="session")
-def vault(maas_container: str, config: dict[str, Any]) -> Optional[Vault]:
+def vault(maas_container: Instance, config: dict[str, Any]) -> Optional[Vault]:
     snap_channel = config.get("vault", {}).get("snap-channel")
     if not snap_channel:
         return None
 
-    vault_logger = getLogger(f"{LOG_NAME}.vault")
-    lxd = get_lxd(vault_logger)
-    lxd.execute(maas_container, ["apt", "install", "--yes", "ssl-cert"])
-    lxd.execute(
-        maas_container, ["snap", "install", "vault", f"--channel={snap_channel}"]
-    )
-    lxd.execute(
-        maas_container,
+    vault_logger = maas_container.logger = getLogger(f"{LOG_NAME}.vault")
+    maas_container.execute(["apt", "install", "--yes", "ssl-cert"])
+    maas_container.execute(["snap", "install", "vault", f"--channel={snap_channel}"])
+    maas_container.execute(
         [
             "cp",
             "/etc/ssl/certs/ssl-cert-snakeoil.pem",
@@ -326,7 +317,7 @@ def vault(maas_container: str, config: dict[str, Any]) -> Optional[Vault]:
         """
     )
     vault_config_file = "/var/snap/vault/common/vault.hcl"
-    lxd.push_text_file(maas_container, vault_config, vault_config_file)
+    maas_container.files[vault_config_file].write(vault_config)
 
     systemd_unit = dedent(
         f"""\
@@ -345,23 +336,20 @@ def vault(maas_container: str, config: dict[str, Any]) -> Optional[Vault]:
         WantedBy=multi-user.target
         """
     )
-    lxd.push_text_file(
-        maas_container, systemd_unit, "/etc/systemd/system/vault.service"
-    )
-    lxd.execute(maas_container, ["systemctl", "enable", "--now", "vault"])
+    maas_container.files["/etc/systemd/system/vault.service"].write(systemd_unit)
+    maas_container.execute(["systemctl", "enable", "--now", "vault"])
 
     vault = Vault(
         container=maas_container,
         secrets_mount="maas-secrets",
         secrets_path="maas",
-        lxd=lxd,
         logger=vault_logger,
     )
     try:
         vault.ensure_initialized()
     except VaultNotReadyError as e:
-        journalctl = lxd.execute(
-            maas_container, ["journalctl", "--unit=vault", "--no-pager"]
+        journalctl = maas_container.execute(
+            ["journalctl", "--unit=vault", "--no-pager"]
         )
         vault_logger.exception(f"{e}\n{journalctl.stdout}")
         raise
@@ -370,21 +358,18 @@ def vault(maas_container: str, config: dict[str, Any]) -> Optional[Vault]:
 
 
 def install_deb(
-    lxd: CLILXD, maas_container: str, maas_deb_repo: str, config: dict[str, Any]
+    maas_container: Instance, maas_deb_repo: str, config: dict[str, Any]
 ) -> str:
-    on_maas_container = partial(lxd.execute, maas_container)
     maas_ppas = config.get("deb", {}).get("ppa", ["ppa:maas-committers/latest-deps"])
-    lxd.push_text_file(
-        maas_container,
-        f"deb [trusted=yes] {maas_deb_repo} ./\n",
-        "/etc/apt/sources.list.d/maas.list",
+    maas_container.files["/etc/apt/sources.list.d/maas.list"].write(
+        f"deb [trusted=yes] {maas_deb_repo} ./\n"
     )
-    _add_maas_ppa(on_maas_container, maas_ppas)
-    on_maas_container(
+    _add_maas_ppa(maas_container, maas_ppas)
+    maas_container.execute(
         ["apt", "install", "--yes", "maas"],
         environment={"DEBIAN_FRONTEND": "noninteractive"},
     )
-    policy = on_maas_container(
+    policy = maas_container.execute(
         ["apt-cache", "policy", "maas"],
         environment={"DEBIAN_FRONTEND": "noninteractive"},
     )  # just to record which version is running.
@@ -397,21 +382,20 @@ def install_deb(
 
 @pytest.fixture(scope="session")
 def maas_region(
-    maas_container: str,
+    maas_container: Instance,
     maas_deb_repo: Optional[str],
     vault: Optional[Vault],
     config: dict[str, Any],
 ) -> Iterator[MAASRegion]:
     """Install MAAS region controller in the container."""
     lxd = get_lxd(getLogger(f"{LOG_NAME}.maas_region"))
-
-    region_ip = lxd.get_ip_address(maas_container)
+    maas_container.logger = lxd.logger
+    region_ip = maas_container.get_ip_address()
     installed_from_snap = "snap" in config
 
     # setup self-signed certs, and add them to the trusted list
-    lxd.execute(maas_container, ["apt", "install", "--yes", "ssl-cert"])
-    lxd.execute(
-        maas_container,
+    maas_container.execute(["apt", "install", "--yes", "ssl-cert"])
+    maas_container.execute(
         [
             "cp",
             "-n",
@@ -419,26 +403,25 @@ def maas_region(
             "/usr/share/ca-certificates/ssl-cert-snakeoil.crt",
         ],
     )
-    certs_list = lxd.get_file_contents(maas_container, "/etc/ca-certificates.conf")
+    ca_certificates = maas_container.files["/etc/ca-certificates.conf"]
+    certs_list = ca_certificates.read()
     if "ssl-cert-snakeoil.crt" not in certs_list:
-        certs_list += "ssl-cert-snakeoil.crt\n"
-        lxd.push_text_file(maas_container, certs_list, "/etc/ca-certificates.conf")
-        lxd.execute(maas_container, ["update-ca-certificates"])
+        ca_certificates.write(certs_list + "ssl-cert-snakeoil.crt\n")
+        maas_container.execute(["update-ca-certificates"])
 
     if installed_from_snap:
-        maas_already_initialized = lxd.file_exists(
-            maas_container, "/var/snap/maas/common/snap_mode"
-        )
-        snap_list = lxd.execute(
-            maas_container, ["snap", "list", "maas"]
-        )  # just to record which version is running.
+
+        maas_already_initialized = maas_container.files[
+            "/var/snap/maas/common/snap_mode"
+        ].exists()
+        # just to record which version is running.
+        snap_list = maas_container.execute(["snap", "list", "maas"])
         try:
             version = snap_list.stdout.split("\n")[1].split()[1]
         except IndexError:
             version = ""
         if not maas_already_initialized:
-            lxd.execute(
-                maas_container,
+            maas_container.execute(
                 [
                     "maas",
                     "init",
@@ -450,9 +433,8 @@ def maas_region(
                 ],
             )
     else:
-        # TODO: bind the LXD to the maas_container
         assert maas_deb_repo is not None
-        version = install_deb(lxd, maas_container, maas_deb_repo, config)
+        version = install_deb(maas_container, maas_deb_repo, config)
     with open("version_under_test", "w") as fh:
         fh.write(f"{version}\n")
 
@@ -466,9 +448,9 @@ def maas_region(
     region_host = region_ip
 
     if vault:
-        setup_vault(vault, lxd, maas_container)
+        setup_vault(vault, maas_container)
     if "tls" in config:
-        region_host, url = setup_tls(lxd, maas_container)
+        region_host, url = setup_tls(maas_container)
     region = MAASRegion(
         url=url,
         http_url=http_url,
@@ -495,20 +477,18 @@ def maas_region(
         yaml.dump(credentials, fh)
 
     if o11y := config.get("o11y"):
-        setup_o11y(o11y, lxd, maas_container, installed_from_snap)
+        setup_o11y(o11y, maas_container, installed_from_snap)
     yield region
 
 
 @pytest.fixture(scope="session")
 def unauthenticated_maas_api_client(
     maas_credentials: dict[str, str],
-    maas_client_container: str,
+    maas_client_container: Instance,
 ) -> UnauthenticatedMAASAPIClient:
     """Get an UnauthenticatedMAASAPIClient for interacting with MAAS."""
     return UnauthenticatedMAASAPIClient(
-        maas_credentials["region_url"],
-        maas_client_container,
-        get_lxd(getLogger()),
+        maas_credentials["region_url"], maas_client_container
     )
 
 
@@ -616,40 +596,40 @@ def tag_all(authenticated_admin: AuthenticatedAPIClient) -> Iterator[str]:
 @pytest.fixture(scope="session")
 def maas_client_container(
     maas_credentials: dict[str, str], config: dict[str, Any]
-) -> Iterator[str]:
+) -> Iterator[Instance]:
     """Set up a new LXD container with maas installed (in order to use maas CLI)."""
 
     log = getLogger(f"{LOG_NAME}.client_container")
     lxd = get_lxd(log)
     container = os.environ.get("MAAS_SYSTEMTESTS_CLIENT_CONTAINER", "maas-client")
 
-    lxd.get_or_create(container, config["containers-image"], profile=LXD_PROFILE)
+    instance = lxd.get_or_create(
+        container, config["containers-image"], profile=LXD_PROFILE
+    )
     snap_channel = maas_credentials.get("snap_channel", "latest/edge")
-    lxd.execute(container, ["snap", "refresh", "snapd"])
-    lxd.execute(container, ["snap", "install", "maas", f"--channel={snap_channel}"])
-    lxd.execute(container, ["snap", "list", "maas"])
+    instance.execute(["snap", "refresh", "snapd"])
+    instance.execute(["snap", "install", "maas", f"--channel={snap_channel}"])
+    instance.execute(["snap", "list", "maas"])
     ensure_host_ip_mapping(
-        lxd, container, maas_credentials["region_host"], maas_credentials["region_ip"]
+        instance, maas_credentials["region_host"], maas_credentials["region_ip"]
     )
     if "tls" in config:
-        lxd.execute(container, ["mkdir", "-p", MAAS_CONTAINER_CERTS_PATH])
-        lxd.push_file(
-            container,
-            config["tls"]["cacerts"],
-            f"{MAAS_CONTAINER_CERTS_PATH}cacerts.pem",
+        instance.execute(["mkdir", "-p", MAAS_CONTAINER_CERTS_PATH])
+        instance.files[f"{MAAS_CONTAINER_CERTS_PATH}cacerts.pem"].push(
+            config["tls"]["cacerts"]
         )
 
-    yield container
+    yield instance
 
 
-def ensure_host_ip_mapping(lxd: CLILXD, container: str, hostname: str, ip: str) -> None:
+def ensure_host_ip_mapping(instance: Instance, hostname: str, ip: str) -> None:
     """Ensure the /etc/hosts file contains the specified host/ip mapping."""
     if hostname == ip:
         # no need to add the alias
         return
     line = f"{ip} {hostname}\n"
-    content = lxd.get_file_contents(container, "/etc/hosts")
+    hosts_file = instance.files["/etc/hosts"]
+    content = hosts_file.read()
     if line in content:
         return
-    content += line
-    lxd.push_text_file(container, content, "/etc/hosts")
+    hosts_file.write(content + line)
diff --git a/systemtests/lxd.py b/systemtests/lxd.py
index 72483f5..f660ffd 100644
--- a/systemtests/lxd.py
+++ b/systemtests/lxd.py
@@ -36,6 +36,130 @@ class BadWebSocketHandshakeError(Exception):
     """Raised when lxc execute gives a bad websocket handshake error."""
 
 
+class _FileWrapper:
+    def __init__(self, lxd: CLILXD, instance_name: str, path: str):
+        self._lxd = lxd
+        self._instance_name = instance_name
+        self._path = path
+
+    def __repr__(self) -> str:
+        return f"<FileWrapper for {self._path} on {self._instance_name}>"
+
+    def read(self) -> str:
+        return self._lxd.get_file_contents(self._instance_name, self._path)
+
+    def write(self, content: str, uid: int = 0, gid: int = 0) -> None:
+        return self._lxd.push_text_file(
+            self._instance_name, content, self._path, uid=uid, gid=gid
+        )
+
+    def exists(self) -> bool:
+        return self._lxd.file_exists(self._instance_name, self._path)
+
+    def push(
+        self,
+        local_path: Path,
+        uid: int = 0,
+        gid: int = 0,
+        mode: str = "",
+        create_dirs: bool = False,
+    ) -> None:
+        return self._lxd.push_file(
+            self._instance_name,
+            str(local_path),
+            self._path,
+            uid=uid,
+            gid=gid,
+            mode=mode,
+            create_dirs=create_dirs,
+        )
+
+    def pull(self, local_path: str) -> None:
+        self._lxd.pull_file(self._instance_name, self._path, local_path)
+
+
+class _FilesWrapper:
+    def __init__(self, lxd: CLILXD, instance_name: str):
+        super().__init__()
+        self._lxd = lxd
+        self._instance_name = instance_name
+
+    def __getitem__(self, path: str) -> _FileWrapper:
+        return _FileWrapper(self._lxd, self._instance_name, path)
+
+
+class Instance:
+    """A container or VM on a LXD."""
+
+    def __init__(self, lxd: CLILXD, instance_name: str):
+        self._lxd = lxd
+        self._instance_name = instance_name
+
+    def __repr__(self) -> str:
+        return f"<Instance {self.name}>"
+
+    @property
+    def name(self) -> str:
+        return self._instance_name
+
+    @property
+    def logger(self) -> logging.Logger:
+        return self._lxd.logger
+
+    @logger.setter
+    def logger(self, logger: logging.Logger) -> None:
+        self._lxd.logger = logger
+
+    def exists(self) -> bool:
+        return self._lxd.container_exists(self._instance_name)
+
+    def execute(
+        self, command: list[str], environment: Optional[dict[str, str]] = None
+    ) -> subprocess.CompletedProcess[str]:
+        return self._lxd.execute(self._instance_name, command, environment=environment)
+
+    def quietly_execute(
+        self, command: list[str], environment: Optional[dict[str, str]] = None
+    ) -> subprocess.CompletedProcess[str]:
+        return self._lxd.quietly_execute(
+            self._instance_name, command, environment=environment
+        )
+
+    @property
+    def files(self) -> _FilesWrapper:
+        return _FilesWrapper(self._lxd, self._instance_name)
+
+    def get_ip_address(self) -> str:
+        return self._lxd.get_ip_address(self._instance_name)
+
+    def list_devices(self) -> list[str]:
+        return self._lxd.list_instance_devices(self._instance_name)
+
+    def add_device(self, name: str, device_config: DeviceConfig) -> None:
+        return self._lxd.add_instance_device(self._instance_name, name, device_config)
+
+    def remove_device(self, name: str) -> None:
+        return self._lxd.remove_instance_device(self._instance_name, name)
+
+    def start(self) -> subprocess.CompletedProcess[str]:
+        return self._lxd.start(self._instance_name)
+
+    def stop(self, force: bool = False) -> subprocess.CompletedProcess[str]:
+        return self._lxd.stop(self._instance_name, force=force)
+
+    def restart(self) -> subprocess.CompletedProcess[str]:
+        return self._lxd.restart(self._instance_name)
+
+    def delete(self) -> None:
+        return self._lxd.delete(self._instance_name)
+
+    def is_running(self) -> bool:
+        return self._lxd.is_running(self._instance_name)
+
+    def status(self) -> str:
+        return self._lxd.instance_status(self._instance_name)
+
+
 class CLILXD:
     """Backend that uses the CLI to talk to LXD."""
 
@@ -68,7 +192,7 @@ class CLILXD:
         image: str,
         user_data: Optional[str] = None,
         profile: Optional[str] = None,
-    ) -> str:
+    ) -> Instance:
         logger = self.logger.getChild(name)
         if not self.container_exists(name):
             logger.info(f"Creating container {name} (from {image})...")
@@ -88,20 +212,22 @@ class CLILXD:
         logger.info(f"Container {name} created.")
         logger.info("Waiting for boot to finish...")
 
+        instance = Instance(self, name)
+
         @retry(exceptions=CloudInitDisabled, tries=120, delay=1, logger=logger)
         def _cloud_init_wait() -> None:
-            process = self.execute(
-                name, ["timeout", "2000", "cloud-init", "status", "--wait", "--long"]
+            process = instance.execute(
+                ["timeout", "2000", "cloud-init", "status", "--wait", "--long"]
             )
             if "Cloud-init disabled by cloud-init-generator" in process.stdout:
                 raise CloudInitDisabled("Cloud-init is disabled.")
-            process = self.execute(
-                name, ["timeout", "2000", "snap", "wait", "system", "seed.loaded"]
+            process = instance.execute(
+                ["timeout", "2000", "snap", "wait", "system", "seed.loaded"]
             )
 
         _cloud_init_wait()
         logger.info("Boot finished.")
-        return name
+        return instance
 
     def get_or_create(
         self,
@@ -109,10 +235,11 @@ class CLILXD:
         image: str,
         user_data: Optional[str] = None,
         profile: Optional[str] = None,
-    ) -> str:
-        if not self.container_exists(name):
+    ) -> Instance:
+        instance = Instance(self, name)
+        if not instance.exists():
             self.create_container(name, image, user_data=user_data, profile=profile)
-        return name
+        return instance
 
     def push_file(
         self,
@@ -325,27 +452,6 @@ class CLILXD:
             ["lxc", "profile", "device", "remove", profile_name, device_name],
         )
 
-    def collect_sos_report(self, instance_name: str, output: str) -> None:
-        container_tmp = "/tmp/sosreport"
-        output_dir = Path(f"{output}/sosreport")
-        self.execute(instance_name, ["apt", "install", "--yes", "sosreport"])
-        self.execute(instance_name, ["rm", "-rf", container_tmp])
-        self.execute(instance_name, ["mkdir", "-p", container_tmp])
-        self.execute(
-            instance_name,
-            ["sos", "report", "--batch", "-o", "maas", "--tmp-dir", container_tmp],
-        )
-        output_dir.mkdir(parents=True, exist_ok=True)
-        journalctl = self.execute(
-            instance_name, ["journalctl", "--unit=vault", "--no-pager", "--output=cat"]
-        )
-        if journalctl.stdout:
-            (output_dir / "vault-journal").write_text(journalctl.stdout)
-        with tempfile.TemporaryDirectory(prefix="sosreport") as tempdir:
-            self.pull_file(instance_name, container_tmp, f"{tempdir}/")
-            for f in os.listdir(f"{tempdir}/sosreport"):
-                os.rename(os.path.join(f"{tempdir}/sosreport", f), f"{output_dir}/{f}")
-
     def list_instance_devices(self, instance_name: str) -> list[str]:
         logger = self.logger.getChild(instance_name)
         result = self._run(
@@ -395,7 +501,7 @@ class CLILXD:
         instances = {instance["name"]: instance for instance in lxc_list}
         return instances
 
-    def create_vm(self, instance_name: str, config: dict[str, str]) -> None:
+    def create_vm(self, instance_name: str, config: dict[str, str]) -> Instance:
         logger = self.logger.getChild(instance_name)
         args: list[str] = []
         profile: Optional[str] = config.pop("profile", None)
@@ -417,6 +523,7 @@ class CLILXD:
             ],
             logger=logger,
         )
+        return Instance(self, instance_name)
 
     def start(self, instance_name: str) -> subprocess.CompletedProcess[str]:
         logger = self.logger.getChild(instance_name)
diff --git a/systemtests/o11y.py b/systemtests/o11y.py
index f26e1a6..feda475 100644
--- a/systemtests/o11y.py
+++ b/systemtests/o11y.py
@@ -1,29 +1,25 @@
+from pathlib import Path
 from textwrap import dedent
 from typing import TYPE_CHECKING, Any
 
 if TYPE_CHECKING:
-    from .lxd import CLILXD
+    from .lxd import Instance
 
 AGENT_PATH = "/opt/agent/agent-linux-amd64"
 
 
 def setup_o11y(
-    o11y: dict[str, Any], lxd: CLILXD, maas_container: str, installed_from_snap: bool
+    o11y: dict[str, Any], maas_container: Instance, installed_from_snap: bool
 ) -> None:
-    if not lxd.file_exists(maas_container, AGENT_PATH):
-        host_path_to_agent = o11y["grafana_agent_file_path"].strip()
-        lxd.execute(maas_container, ["mkdir", "-p", "/opt/agent"])
-        lxd.push_file(
-            maas_container,
-            host_path_to_agent,
-            AGENT_PATH,
-            mode="0755",
-        )
+    agent_on_container = maas_container.files[AGENT_PATH]
+    if not agent_on_container.exists():
+        host_path_to_agent = Path(o11y["grafana_agent_file_path"].strip())
+        maas_container.execute(["mkdir", "-p", "/opt/agent"])
+        agent_on_container.push(host_path_to_agent, mode="0755")
         agent_maas_sample = "/usr/share/maas/grafana_agent/agent.yaml.example"
         if installed_from_snap:
             agent_maas_sample = f"/snap/maas/current{agent_maas_sample}"
-        lxd.execute(
-            maas_container,
+        maas_container.execute(
             ["cp", agent_maas_sample, "/opt/agent/agent.yml"],
         )
         o11y_ip = o11y["o11y_ip"]
@@ -48,4 +44,4 @@ def setup_o11y(
     -server.http.address="0.0.0.0:3100" -server.grpc.address="0.0.0.0:9095"
         """
         )
-        lxd.execute(maas_container, ["sh", "-c", telemetry_run_cmd])
+        maas_container.execute(["sh", "-c", telemetry_run_cmd])
diff --git a/systemtests/region.py b/systemtests/region.py
index 014cbc8..4b979b6 100644
--- a/systemtests/region.py
+++ b/systemtests/region.py
@@ -4,11 +4,11 @@ import subprocess
 from logging import getLogger
 from typing import TYPE_CHECKING, Any, Union
 
-from .lxd import get_lxd
 from .utils import retries
 
 if TYPE_CHECKING:
     from .api import AuthenticatedAPIClient
+    from .lxd import Instance
 
 LOG = getLogger("systemtests.region")
 
@@ -19,15 +19,15 @@ class MAASRegion:
         url: str,
         http_url: str,
         host: str,
-        maas_container: str,
+        maas_container: Instance,
         installed_from_snap: bool,
     ):
         self.url = url
         self.http_url = http_url
         self.host = host
         self.maas_container = maas_container
+        self.maas_container.logger = LOG
         self.installed_from_snap = installed_from_snap
-        self.lxd = get_lxd(LOG)
 
     def __repr__(self) -> str:
         package = "snap" if self.installed_from_snap else "deb"
@@ -36,7 +36,7 @@ class MAASRegion:
         )
 
     def execute(self, command: list[str]) -> subprocess.CompletedProcess[str]:
-        return self.lxd.execute(self.maas_container, command)
+        return self.maas_container.execute(command)
 
     def get_api_token(self, user: str) -> str:
         result = self.execute(["maas", "apikey", "--username", user])
@@ -158,7 +158,7 @@ class MAASRegion:
         dhcpd_conf_path = "/var/lib/maas/dhcpd.conf"
         if self.installed_from_snap:
             dhcpd_conf_path = "/var/snap/maas/common/maas/dhcpd.conf"
-        return self.lxd.file_exists(self.maas_container, dhcpd_conf_path)
+        return self.maas_container.files[dhcpd_conf_path].exists()
 
     def set_config(self, key: str, value: str = "") -> None:
         if self.installed_from_snap:
diff --git a/systemtests/state.py b/systemtests/state.py
index b79b247..57f0f7c 100644
--- a/systemtests/state.py
+++ b/systemtests/state.py
@@ -3,7 +3,8 @@ from __future__ import annotations
 import json
 import time
 from logging import getLogger
-from typing import TYPE_CHECKING, Any, Iterator, Set, cast
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Iterator, Set
 
 import pytest
 from retry import retry
@@ -75,11 +76,8 @@ def import_images_and_wait_until_synced(
     if "windows" in osystems:
         windows_path = "/home/ubuntu/windows-win2012hvr2-amd64-root-dd"
         # 1000/1000 is default uid/gid of ubuntu user
-        authenticated_admin.api_client.push_file(
-            source_file=config["windows_image_file_path"],
-            target_file=windows_path,
-            uid=1000,
-            gid=1000,
+        authenticated_admin.api_client.maas_container.files[windows_path].push(
+            Path(config["windows_image_file_path"]), uid=1000, gid=1000
         )
 
     region_start_point = time.time()
@@ -94,7 +92,7 @@ def import_images_and_wait_until_synced(
     )
     if "windows" in osystems:
         windows_start_point = time.time()
-        windows_path = cast(str, windows_path)
+        assert windows_path is not None
         authenticated_admin.create_boot_resource(
             name="windows/win2012hvr2",
             title="Windows2012HVR2",
diff --git a/systemtests/tests_per_machine/test_hardware_sync.py b/systemtests/tests_per_machine/test_hardware_sync.py
index 05533c3..1bab2c4 100644
--- a/systemtests/tests_per_machine/test_hardware_sync.py
+++ b/systemtests/tests_per_machine/test_hardware_sync.py
@@ -11,7 +11,6 @@ from pytest_steps.steps_generator import optional_step
 from retry.api import retry, retry_call
 
 from ..device_config import DeviceConfig, HardwareSyncMachine
-from ..lxd import get_lxd
 from ..utils import (
     retries,
     ssh_execute_command,
@@ -25,15 +24,7 @@ if TYPE_CHECKING:
     from paramiko import PKey
 
     from ..api import AuthenticatedAPIClient, Machine
-    from ..lxd import CLILXD
-
-
-def assert_device_not_added_to_lxd_instance(
-    lxd: CLILXD,
-    machine_name: str,
-    device_config: DeviceConfig,
-) -> None:
-    assert device_config["device_name"] not in lxd.list_instance_devices(machine_name)
+    from ..lxd import Instance
 
 
 def _check_machine_for_device(
@@ -79,27 +70,27 @@ def check_machine_does_not_have_device(
 
 
 @contextmanager
-def powered_off_vm(lxd: CLILXD, instance_name: str) -> Iterator[None]:
+def powered_off_vm(instance: Instance) -> Iterator[None]:
     """Context-manager to do something with LXD instance off."""
-    strategy_iter: Iterator[Callable[[str], CompletedProcess[str]]] = chain(
-        repeat(lxd.stop, 3), repeat(partial(lxd.stop, force=True))
+    strategy_iter: Iterator[Callable[[], CompletedProcess[str]]] = chain(
+        repeat(instance.stop, 3), repeat(partial(instance.stop, force=True))
     )
 
-    @retry(tries=10, delay=5, logger=lxd.logger)
-    def power_off(instance_name: str) -> None:
-        if lxd.is_running(instance_name):
+    @retry(tries=10, delay=5, logger=instance.logger)
+    def power_off(instance: Instance) -> None:
+        if instance.is_running():
             stop_attempt = next(strategy_iter)
-            stop_attempt(instance_name)
-            if lxd.is_running(instance_name):
-                raise Exception(f"LXD {instance_name} still running.")
+            stop_attempt()
+            if instance.is_running():
+                raise Exception(f"{instance.name} still running.")
 
-    @retry(tries=10, delay=5, backoff=1.2, logger=lxd.logger)
-    def power_on(instance_name: str) -> None:
-        lxd.start(instance_name)
+    @retry(tries=10, delay=5, backoff=1.2, logger=instance.logger)
+    def power_on(instance: Instance) -> None:
+        instance.start()
 
-    power_off(instance_name)
+    power_off(instance)
     yield
-    power_on(instance_name)
+    power_on(instance)
 
 
 @test_steps(
@@ -117,8 +108,7 @@ def test_hardware_sync(
     ssh_key: PKey,
     testlog: Logger,
 ) -> Iterator[None]:
-    lxd = get_lxd(logger=testlog)
-
+    hardware_sync_machine.instance.logger = testlog
     maas_api_client.set_config("hardware_sync_interval", "5s")
 
     maas_api_client.deploy_machine(
@@ -144,21 +134,19 @@ def test_hardware_sync(
     testlog.info(f"{hardware_sync_machine.name}: {stdout}")
 
     yield
-
+    instance = hardware_sync_machine.instance
     with optional_step("add_device") as add_device:
         # we don't have a way of remotely adding physical hardware,
         # so this test only tests lxd instances
         assert hardware_sync_machine.machine["power_type"] == "lxd"
 
+        current_devices = instance.list_devices()
         for device_config in hardware_sync_machine.devices_config:
-            assert_device_not_added_to_lxd_instance(
-                lxd, hardware_sync_machine.name, device_config
-            )
+            assert device_config["device_name"] not in current_devices
 
-        with powered_off_vm(lxd, hardware_sync_machine.name):
+        with powered_off_vm(instance):
             for device_config in hardware_sync_machine.devices_config:
-                lxd.add_instance_device(
-                    hardware_sync_machine.name,
+                instance.add_device(
                     device_config["device_name"],
                     device_config,
                 )
@@ -220,11 +208,9 @@ def test_hardware_sync(
 
     with optional_step("remove_device", depends_on=add_device) as remove_device:
         if remove_device.should_run():
-            with powered_off_vm(lxd, hardware_sync_machine.name):
+            with powered_off_vm(instance):
                 for device_config in hardware_sync_machine.devices_config:
-                    lxd.remove_instance_device(
-                        hardware_sync_machine.name, device_config["device_name"]
-                    )
+                    instance.remove_device(device_config["device_name"])
 
             for device_config in hardware_sync_machine.devices_config:
                 retry_call(
@@ -248,5 +234,5 @@ def test_hardware_sync(
             break
         elif power_state == "on":
             with suppress(CalledProcessError):
-                lxd.stop(hardware_sync_machine.name, force=True)
+                instance.stop(force=True)
     yield
diff --git a/systemtests/tls.py b/systemtests/tls.py
index 47e7cb4..b068650 100644
--- a/systemtests/tls.py
+++ b/systemtests/tls.py
@@ -1,14 +1,16 @@
-from .lxd import CLILXD
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from .lxd import Instance
 
 # Certs must be accessible for MAAS installed by snap, but
 # this location is useful also when installed via deb package.
 MAAS_CONTAINER_CERTS_PATH = "/var/snap/maas/common/certs/"
 
 
-def setup_tls(lxd: CLILXD, maas_container: str) -> tuple[str, str]:
-    lxd.execute(maas_container, ["mkdir", "-p", MAAS_CONTAINER_CERTS_PATH])
-    lxd.execute(
-        maas_container,
+def setup_tls(maas_container: Instance) -> tuple[str, str]:
+    maas_container.execute(["mkdir", "-p", MAAS_CONTAINER_CERTS_PATH])
+    maas_container.execute(
         [
             "cp",
             "-n",
@@ -18,13 +20,10 @@ def setup_tls(lxd: CLILXD, maas_container: str) -> tuple[str, str]:
         ],
     )
     # We need the cert to add it as CA in client container.
-    lxd.pull_file(
-        maas_container,
-        "/etc/ssl/certs/ssl-cert-snakeoil.pem",
-        "ssl-cert-snakeoil.pem",
+    maas_container.files["/etc/ssl/certs/ssl-cert-snakeoil.pem"].pull(
+        "ssl-cert-snakeoil.pem"
     )
-    lxd.execute(
-        maas_container,
+    maas_container.execute(
         [
             "maas",
             "config-tls",
@@ -36,5 +35,5 @@ def setup_tls(lxd: CLILXD, maas_container: str) -> tuple[str, str]:
             "--yes",
         ],
     )
-    region_host = lxd.quietly_execute(maas_container, ["hostname", "-f"]).stdout.strip()
+    region_host = maas_container.quietly_execute(["hostname", "-f"]).stdout.strip()
     return region_host, f"https://{region_host}:5443/MAAS/";
diff --git a/systemtests/utils.py b/systemtests/utils.py
index d3858bd..a582c22 100644
--- a/systemtests/utils.py
+++ b/systemtests/utils.py
@@ -7,12 +7,16 @@ import re
 import string
 import time
 from dataclasses import dataclass
-from typing import Iterator, Optional, TypedDict, Union
+from typing import TYPE_CHECKING, Iterator, Optional, TypedDict, Union
 
 import paramiko
 from retry.api import retry_call
 
 from . import api
+from .lxd import get_lxd
+
+if TYPE_CHECKING:
+    from logging import Logger
 
 
 class UnexpectedMachineStatus(Exception):
@@ -184,6 +188,15 @@ def wait_for_machine(
     raise UnexpectedMachineStatus(machine_id, status, retry_info.elapsed, debug_outputs)
 
 
+def debug_lxd_vm(machine_name: str, logger: Logger) -> list[str]:
+    """Grab debug information for a VM failure."""
+    lxd = get_lxd(logger)
+    debug_info = []
+    debug_info.append(repr(lxd.list_instances().get(machine_name, "")))
+    debug_info.append(lxd._run(["lxc", "info", "--show-log", machine_name]).stdout)
+    return debug_info
+
+
 # XXX: Move to api.py
 def wait_for_new_machine(
     api_client: api.AuthenticatedAPIClient, mac_address: str, machine_name: str
@@ -200,8 +213,8 @@ def wait_for_new_machine(
     debug_outputs = []
     maybe_machines = quiet_client.list_machines(mac_address=mac_address)
     debug_outputs.append(repr(maybe_machines))
-    if "lxd" in [m["power_type"] for m in machines]:
-        debug_outputs.append(repr(api_client.lxd.list_instances().get(machine_name)))
+    if "lxd" in [m["power_type"] for m in maybe_machines]:
+        debug_outputs.extend(debug_lxd_vm(machine_name, api_client.logger))
 
     raise UnexpectedMachineStatus(
         machine_name, "New", retry_info.elapsed, debug_outputs
@@ -226,7 +239,7 @@ def wait_for_machine_to_power_off(
 
     debug_outputs = [repr(machine)]
     if machine["power_type"] == "lxd":
-        debug_outputs.append(repr(api_client.lxd.list_instances()[machine_name]))
+        debug_outputs.extend(debug_lxd_vm(machine_name, api_client.logger))
     raise UnexpectedMachineStatus(
         machine_name, "power_off", retry_info.elapsed, debug_outputs
     )
diff --git a/systemtests/vault.py b/systemtests/vault.py
index 85f86ca..178ea46 100644
--- a/systemtests/vault.py
+++ b/systemtests/vault.py
@@ -10,7 +10,7 @@ from typing import Any, cast
 import yaml
 from retry.api import retry_call
 
-from .lxd import CLILXD
+from .lxd import Instance
 
 
 class VaultNotReadyError(Exception):
@@ -21,10 +21,9 @@ class VaultNotReadyError(Exception):
 class Vault:
     """Vault CLI wrapper to be run inside a container."""
 
-    container: str
+    container: Instance
     secrets_path: str
     secrets_mount: str
-    lxd: CLILXD
     logger: Logger
     root_token: str = ""
 
@@ -34,9 +33,7 @@ class Vault:
     @cached_property
     def addr(self) -> str:
         """The Vault address."""
-        fqdn = self.lxd.quietly_execute(
-            self.container, ["hostname", "-f"]
-        ).stdout.strip()
+        fqdn = self.container.quietly_execute(["hostname", "-f"]).stdout.strip()
         return f"https://{fqdn}:8200";
 
     def wait_ready(self) -> dict[str, Any]:
@@ -61,8 +58,8 @@ class Vault:
         return cast(
             dict[str, Any],
             json.loads(
-                self.lxd.quietly_execute(
-                    self.container, ["curl", f"{self.addr}/v1/sys/seal-status"]
+                self.container.quietly_execute(
+                    ["curl", f"{self.addr}/v1/sys/seal-status"]
                 ).stdout
             ),
         )
@@ -79,8 +76,7 @@ class Vault:
         environment = {"VAULT_ADDR": self.addr}
         if self.root_token:
             environment["VAULT_TOKEN"] = self.root_token
-        return self.lxd.quietly_execute(
-            self.container,
+        return self.container.quietly_execute(
             ["vault"] + list(command),
             environment=environment,
         )
@@ -95,17 +91,13 @@ class Vault:
         """Ensure Vault is initialized and unlocked."""
         status = self.wait_ready()
 
-        vault_init_file = "/var/snap/vault/common/init.json"
+        vault_init_file = self.container.files["/var/snap/vault/common/init.json"]
         if status["initialized"]:
-            init_result = json.loads(
-                self.lxd.get_file_contents(self.container, vault_init_file)
-            )
+            init_result = json.loads(vault_init_file.read())
             self.root_token = init_result["root_token"]
         else:
             init_result = self.init()
-            self.lxd.push_text_file(
-                self.container, json.dumps(init_result), vault_init_file
-            )
+            vault_init_file.write(json.dumps(init_result))
 
         while (status := self.status())["sealed"]:
             index = status["progress"]
@@ -141,9 +133,9 @@ class Vault:
             """
         )
         tmpfile = f"/root/vault-policy-{uuid.uuid4()}"
-        self.lxd.push_text_file(self.container, policy, tmpfile)
+        self.container.files[tmpfile].write(policy)
         self.execute("policy", "write", self.MAAS_POLICY_NAME, tmpfile)
-        self.lxd.quietly_execute(self.container, ["rm", tmpfile])
+        self.container.quietly_execute(["rm", tmpfile])
 
     def create_approle(self, role_name: str) -> tuple[str, str]:
         """Create an approle with secret and return its ID and wrapped token."""
@@ -165,17 +157,16 @@ class Vault:
         return role_id, wrapped_token
 
 
-def setup_vault(vault: Vault, lxd: CLILXD, maas_container: str) -> None:
+def setup_vault(vault: Vault, maas_container: Instance) -> None:
     """Configures MAAS to talk to Vault."""
     maas_vault_status = yaml.safe_load(
-        lxd.quietly_execute(
-            maas_container, ["maas", "config-vault", "status"]
+        maas_container.quietly_execute(
+            ["maas", "config-vault", "status"]
         ).stdout.strip()
     )
     if maas_vault_status["status"] == "disabled":
-        role_id, wrapped_token = vault.create_approle(maas_container)
-        lxd.execute(
-            maas_container,
+        role_id, wrapped_token = vault.create_approle(maas_container.name)
+        maas_container.execute(
             [
                 "maas",
                 "config-vault",
@@ -188,8 +179,7 @@ def setup_vault(vault: Vault, lxd: CLILXD, maas_container: str) -> None:
                 vault.secrets_mount,
             ],
         )
-        lxd.execute(
-            maas_container,
+        maas_container.execute(
             [
                 "maas",
                 "config-vault",

Follow ups