# Copyright 2024 The Debusine Developers
# See the AUTHORS file at the top-level directory of this distribution
#
# This file is part of Debusine. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution. No part of Debusine, including this file, may be copied,
# modified, propagated, or distributed except according to the terms
# contained in the LICENSE file.

"""Unit tests for the Incus executor."""

import tarfile
from io import BytesIO
from pathlib import Path
from shutil import rmtree
from subprocess import CalledProcessError, CompletedProcess, DEVNULL, PIPE
from tempfile import NamedTemporaryFile, mkdtemp
from typing import cast
from unittest import TestCase
from unittest.mock import ANY, MagicMock, call, create_autospec, patch

import yaml

from debusine.artifacts.models import ArtifactCategory
from debusine.client.debusine import Debusine
from debusine.tasks import Noop
from debusine.tasks.executors.base import (
    ExecutorInterface,
    ImageImportError,
    InstanceNotRunning,
    executor_class,
)
from debusine.tasks.executors.incus import (
    IncusImageCache,
    IncusInstance,
    IncusLXCExecutor,
    IncusLXCImageCache,
    IncusVMExecutor,
    IncusVMImageCache,
    run_incus_cmd,
)
from debusine.tasks.tests.helper_mixin import ExternalTaskHelperMixin
from debusine.test import TestHelpersMixin


class RunIncusCmdTest(TestCase):
    """Unit tests for run_incus_cmd."""

    @patch("debusine.tasks.executors.incus.check_output")
    def test_run_incus_cmd(self, check_output):
        """Test that run_incus_cmd is a thin wrapper around check_output."""
        check_output.return_value = "output"
        output = run_incus_cmd("testing", "123")
        self.assertEqual(output, "output")
        check_output.assert_called_with(
            ["incus", "testing", "123"], encoding="utf-8"
        )

    @patch("debusine.tasks.executors.incus.check_output")
    def test_run_incus_cmd_suppress_stderr(self, check_output):
        """Test that run_incus_cmd can suppress stderr."""
        check_output.return_value = "output"
        output = run_incus_cmd("testing", "123", suppress_stderr=True)
        self.assertEqual(output, "output")
        check_output.assert_called_with(
            ["incus", "testing", "123"],
            encoding="utf-8",
            stderr=DEVNULL,
        )


class IncusImageCacheTestMixin(ExternalTaskHelperMixin[Noop]):
    """Common setup for all the Incus*ImageCacheTests."""

    def create_tar(
        self,
        path: Path | None = None,
        dirs: tuple[str, ...] = ("./", "./sbin"),
        files: tuple[str, ...] = ("./test-file", "./sbin/init"),
        special_files: bool = False,
    ) -> Path:
        """
        Create a test tar file, creating a temporary file if needed.

        The named dirs will be created at the beginning of the file.
        The named files will be created with dummy content.
        If special_files is set, a full range of file types will be included.
        """
        if path is None:
            path = Path(
                NamedTemporaryFile(
                    prefix="debusine-test-", suffix=".tar", delete=False
                ).name
            )
            cast(TestCase, self).addCleanup(path.unlink)

        mode = "w"
        if path.suffix == ".xz":
            mode += ":xz"

        with tarfile.open(name=path, mode=mode) as tar:
            for dir_ in dirs:
                tarinfo = tarfile.TarInfo(name=dir_)
                tarinfo.type = tarfile.DIRTYPE
                tarinfo.mode = 0o755
                tar.addfile(tarinfo)
            for file in files:
                tarinfo = tarfile.TarInfo(name=file)
                tarinfo.size = 3
                tar.addfile(tarinfo, BytesIO(b"foo"))
            if special_files:
                tarinfo = tarfile.TarInfo(name="./special")
                tarinfo.type = tarfile.DIRTYPE
                tar.addfile(tarinfo)
                tarinfo = tarfile.TarInfo(name="./special/reg-file")
                tar.addfile(tarinfo, BytesIO(b"foo"))
                tarinfo = tarfile.TarInfo(name="./special/hard-link")
                tarinfo.type = tarfile.LNKTYPE
                tarinfo.linkname = "./special/reg-file"
                tar.addfile(tarinfo)
                tarinfo = tarfile.TarInfo(name="./special/sym-link")
                tarinfo.type = tarfile.SYMTYPE
                tarinfo.linkname = "/dev/null"
                tar.addfile(tarinfo)
                tarinfo = tarfile.TarInfo(name="./special/chr-dev")
                tarinfo.type = tarfile.CHRTYPE
                tarinfo.devmajor = 10
                tarinfo.devminor = 42
                tar.addfile(tarinfo)
                tarinfo = tarfile.TarInfo(name="./special/blk-dev")
                tarinfo.type = tarfile.BLKTYPE
                tarinfo.devmajor = 11
                tarinfo.devminor = 42
                tar.addfile(tarinfo)
                tarinfo = tarfile.TarInfo(name="./special/fifo-dev")
                tarinfo.type = tarfile.FIFOTYPE
                tar.addfile(tarinfo)

        return path


class IncusImageCacheTests(IncusImageCacheTestMixin, TestCase):
    """Unit tests for IncusImageCache."""

    def setUp(self):
        """Mock the image handling for tests."""
        super().setUp()
        self.debusine_api = create_autospec(Debusine)
        self.image_cache = IncusImageCache(
            self.debusine_api, ArtifactCategory.SYSTEM_TARBALL
        )
        self.artifact = self.fake_system_tarball_artifact()
        self.image_cache.image_cache_path = Path("/tmp/not-used")

    @patch("debusine.tasks.executors.incus.run_incus_cmd")
    def test_image_in_cache_uncached(self, run_incus_cmd):
        """Test that image_in_cache fails to find a cached image."""
        run_incus_cmd.side_effect = CalledProcessError(
            cmd=["incus"], returncode=1
        )
        self.assertFalse(self.image_cache.image_in_cache(self.artifact))
        run_incus_cmd.assert_called_with(
            "image", "info", "artifact/42", suppress_stderr=True
        )

    @patch("debusine.tasks.executors.incus.run_incus_cmd")
    def test_image_in_cache_cached(self, run_incus_cmd):
        """Test that image_in_cache finds cached image."""
        run_incus_cmd.return_value = "Fingerprint: abc123\n"
        self.assertTrue(self.image_cache.image_in_cache(self.artifact))
        run_incus_cmd.assert_called_with(
            "image", "info", "artifact/42", suppress_stderr=True
        )

    @patch("debusine.tasks.executors.incus.run_incus_cmd")
    def test_process_downloaded_image(self, run_incus_cmd):
        """
        Test that process_downloaded_image calls build_incus_image and uploads.

        The real work is in build_incus_image(), so we mock everything and
        ensure that the expected calls are made.
        """
        built_path = Path("incus.tar.xz")
        with patch.object(
            self.image_cache,
            "build_incus_image",
            return_value=built_path,
        ) as build_incus_image:
            self.image_cache.process_downloaded_image(self.artifact)

        downloaded_path = self.image_cache.cache_artifact_image_path(
            42, "system.tar.xz"
        )
        build_incus_image.assert_called_with(
            downloaded_path, ANY, self.artifact
        )
        run_incus_cmd.assert_called_with(
            "image", "import", "incus.tar.xz", "--alias", "artifact/42"
        )

    def test_image_metadata(self):
        """Test that _image_metadata returns the correct metadata."""
        metadata = self.image_cache._image_metadata(self.artifact)

        expected = {
            "architecture": "x86_64",
            "creation_date": 1704067200,
            "profiles": ["debusine"],
            "properties": {
                "debian_architecture": "amd64",
                "debusine_artifact_id": 42,
                "description": (
                    "Debusine Artifact #42: "
                    "debian:system-tarball for debian bookworm amd64"
                ),
                "os": "debian",
                "release": "bookworm",
            },
            "templates": {
                "/etc/hostname": {
                    "create_only": False,
                    "properties": {},
                    "template": "hostname.tpl",
                    "when": ["create", "copy"],
                },
                "/etc/hosts": {
                    "create_only": False,
                    "properties": {},
                    "template": "hosts.tpl",
                    "when": ["create", "copy"],
                },
            },
        }
        self.assertEqual(metadata, expected)

    def test_add_tar_directory(self):
        """Test that the add_tar_directory helper adds a directory."""
        buffer = BytesIO()
        with tarfile.TarFile(fileobj=buffer, mode="w") as tar:
            self.image_cache.add_tar_directory(tar, "foo")

        buffer.seek(0)

        with tarfile.TarFile(fileobj=buffer, mode="r") as tar:
            members = tar.getmembers()
            self.assertEqual(len(members), 1)
            member = members[0]
            self.assertEqual(member.name, "foo")
            self.assertTrue(member.isdir())
            self.assertEqual(member.mode, 0o755)
            self.assertEqual(member.uid, 0)
            self.assertEqual(member.gid, 0)

    def test_add_tar_file(self):
        """Test that the add_tar_file helper adds a file."""
        buffer = BytesIO()
        with tarfile.TarFile(fileobj=buffer, mode="w") as tar:
            self.image_cache.add_tar_file(tar, "foo", "bar")

        buffer.seek(0)

        with tarfile.TarFile(fileobj=buffer, mode="r") as tar:
            members = tar.getmembers()
            self.assertEqual(len(members), 1)
            member = members[0]
            self.assertEqual(member.name, "foo")
            self.assertTrue(member.isreg())
            self.assertEqual(member.mode, 0o644)
            self.assertEqual(member.uid, 0)
            self.assertEqual(member.gid, 0)
            self.assertEqual(tar.extractfile(member).read(), b"bar")

    def test_add_tar_symlink(self):
        """Test that the add_tar_symlink helper adds a symlink."""
        buffer = BytesIO()
        with tarfile.TarFile(fileobj=buffer, mode="w") as tar:
            self.image_cache.add_tar_symlink(tar, "foo", "bar")

        buffer.seek(0)

        with tarfile.TarFile(fileobj=buffer, mode="r") as tar:
            members = tar.getmembers()
            self.assertEqual(len(members), 1)
            member = members[0]
            self.assertEqual(member.name, "foo")
            self.assertTrue(member.issym())
            self.assertEqual(member.uid, 0)
            self.assertEqual(member.gid, 0)
            self.assertEqual(member.linkname, "bar")


class IncusLXCImageCacheTests(
    IncusImageCacheTestMixin, TestHelpersMixin, TestCase
):
    """Unit tests for IncusLXCImageCache."""

    def setUp(self):
        """Mock the image handling for tests."""
        super().setUp()
        self.debusine_api = create_autospec(Debusine)
        self.image_cache = IncusLXCImageCache(
            self.debusine_api, ArtifactCategory.SYSTEM_TARBALL
        )
        self.artifact = self.fake_system_tarball_artifact()
        self.image_cache.image_cache_path = Path("/tmp/not-used")

    def test_prepare_incus_image(self):
        """Test that prepare copies over the image and metadata."""
        source = self.create_tar(
            dirs=("./", "./etc", "./sbin"), special_files=True
        )
        dest_buf = BytesIO()
        with tarfile.TarFile(fileobj=dest_buf, mode="w") as dest_tar:
            self.image_cache.prepare_incus_image(
                source, dest_tar, self.artifact
            )

        dest_buf.seek(0)

        with tarfile.open(fileobj=dest_buf) as tar:
            names = tar.getnames()
            self.assertEqual(
                names,
                [
                    "metadata.yaml",
                    "templates",
                    "templates/hosts.tpl",
                    "templates/hostname.tpl",
                    "rootfs",
                    "rootfs/etc",
                    "rootfs/sbin",
                    "rootfs/test-file",
                    "rootfs/sbin/init",
                    "rootfs/special",
                    "rootfs/special/reg-file",
                    "rootfs/special/hard-link",
                    "rootfs/special/sym-link",
                    "rootfs/special/chr-dev",
                    "rootfs/special/blk-dev",
                    "rootfs/special/fifo-dev",
                    "rootfs/etc/systemd",
                    "rootfs/etc/systemd/system",
                    "rootfs/etc/systemd/system-generators",
                    "rootfs/etc/systemd/system/multi-user.target.wants",
                    (
                        "rootfs/etc/systemd/system/multi-user.target.wants/"
                        "systemd-networkd.service"
                    ),
                    "rootfs/etc/systemd/system-generators/lxc",
                ],
            )

            tarinfo = tar.getmember("rootfs/special/hard-link")
            self.assertEqual(tarinfo.linkname, "rootfs/special/reg-file")
            tarinfo = tar.getmember("rootfs/special/sym-link")
            self.assertEqual(tarinfo.linkname, "/dev/null")

    def test_build_incus_image(self):
        """Test that build_incus_image injects basic metadata into a new tar."""
        directory = self.create_temporary_directory()
        source_path = directory / "system.tar.xz"
        self.create_tar(source_path)
        workdir = directory / "workdir"
        workdir.mkdir()

        image = self.image_cache.build_incus_image(
            source_path, workdir, self.artifact
        )

        self.assertTrue(image.exists())
        with tarfile.open(image) as tar:
            names = set(tar.getnames())
            self.assertIn("metadata.yaml", names)
            metadata = yaml.safe_load(tar.extractfile("metadata.yaml"))

        self.assertNotIn("test-file", names)
        self.assertEqual(
            metadata, self.image_cache._image_metadata(self.artifact)
        )

    def test_prepare_incus_image_requires_init(self):
        """Test that prepare only accepts bootable images."""
        source = self.create_tar(files=())
        dest_buf = BytesIO()
        with tarfile.TarFile(fileobj=dest_buf, mode="w") as dest_tar:
            with self.assertRaises(ImageImportError):
                self.image_cache.prepare_incus_image(
                    source, dest_tar, self.artifact
                )

    def test_prepare_incus_image_accepts_usrmerged_init(self):
        """Test that prepare accepts a /usr/sbin/init."""
        source = self.create_tar(
            dirs=("./", "./usr", "./usr/sbin"),
            files=("./usr/sbin/init",),
        )
        dest_buf = BytesIO()
        with tarfile.TarFile(fileobj=dest_buf, mode="w") as dest_tar:
            self.image_cache.prepare_incus_image(
                source, dest_tar, self.artifact
            )


class IncusVMImageCacheTests(IncusImageCacheTestMixin, TestCase):
    """Unit tests for IncusVMImageCache."""

    def setUp(self):
        """Mock the image handling for tests."""
        super().setUp()
        self.debusine_api = create_autospec(Debusine)
        self.image_cache = IncusVMImageCache(
            self.debusine_api, ArtifactCategory.SYSTEM_TARBALL
        )
        self.artifact = self.fake_system_image_artifact()
        self.image_cache.image_cache_path = Path("/tmp/not-used")

    def test_prepare_qcow2_incus_image(self):
        """Test that prepare converts qcow2 images."""
        with NamedTemporaryFile(
            prefix="debusine-test-", suffix=".qcow2", delete=False
        ) as source:
            source.write(b"qcow!")
            source_path = Path(source.name)
            self.addCleanup(source_path.unlink)
        dest_buf = BytesIO()
        with tarfile.TarFile(fileobj=dest_buf, mode="w") as dest_tar:
            self.image_cache.prepare_incus_image(
                source_path, dest_tar, self.artifact
            )

        dest_buf.seek(0)

        with tarfile.open(fileobj=dest_buf) as tar:
            names = tar.getnames()
            image = tar.extractfile("rootfs.img").read()

        self.assertEqual(
            names,
            [
                "metadata.yaml",
                "templates",
                "templates/hosts.tpl",
                "templates/hostname.tpl",
                "rootfs.img",
            ],
        )
        self.assertEqual(image, b"qcow!")

    @patch("debusine.tasks.executors.incus.check_call")
    def test_prepare_raw_incus_image(self, check_call):
        """Test that prepare converts raw images."""
        self.artifact.data["image_format"] = "raw"
        self.artifact.data["filename"] = "image.tar.xz"

        directory = Path(mkdtemp(prefix="debusine-tests-"))
        self.addCleanup(rmtree, directory)
        source = self.create_tar(
            path=directory / "image.tar.xz",
            files=("img.raw",),
            dirs=(),
        )
        dest_buf = BytesIO()

        def fake_convert(args: list[str]):
            """Fake for subprocess.check_call."""
            assert args[:2] == ["qemu-img", "convert"]
            with Path(args[-1]).open("wb") as f:
                f.write(b"qcow!")

        check_call.side_effect = fake_convert

        with tarfile.TarFile(fileobj=dest_buf, mode="w") as dest_tar:
            self.image_cache.prepare_incus_image(
                source, dest_tar, self.artifact
            )

        extracted = directory / "img.raw"
        self.assertFalse(extracted.exists())

        check_call.assert_called_with(
            [
                "qemu-img",
                "convert",
                "-f",
                "raw",
                "-O",
                "qcow2",
                str(extracted),
                str(directory / "image.qcow2"),
            ]
        )

        dest_buf.seek(0)

        with tarfile.open(fileobj=dest_buf) as tar:
            names = tar.getnames()
            image = tar.extractfile("rootfs.img").read()
            metadata = yaml.safe_load(tar.extractfile("metadata.yaml"))

        self.assertEqual(
            names,
            [
                "metadata.yaml",
                "templates",
                "templates/hosts.tpl",
                "templates/hostname.tpl",
                "rootfs.img",
            ],
        )
        self.assertEqual(image, b"qcow!")
        self.assertEqual(
            metadata, self.image_cache._image_metadata(self.artifact)
        )


class IncusExecutorCommon(TestHelpersMixin, ExternalTaskHelperMixin[Noop]):
    """Unit tests that apply to both Incus Executors."""

    executor_cls: type[ExecutorInterface]
    uses_images: bool

    def setUp(self):
        """Mock the Debusine API for tests."""
        super().setUp()
        self.debusine_api = MagicMock(spec=Debusine)
        self.image_artifact = self.mock_image_download(
            self.debusine_api, system_image=self.uses_images
        )

        run_incus_cmd_patcher = patch(
            "debusine.tasks.executors.incus.run_incus_cmd"
        )
        self.run_incus_cmd = run_incus_cmd_patcher.start()
        self.addCleanup(run_incus_cmd_patcher.stop)

        self.executor = self.executor_cls(self.debusine_api, 42)

    def test_available(self):
        """Test that available() returns True if incus is available."""
        self.mock_is_command_available(
            {
                "incus": True,
                "autopkgtest-virt-incus": True,
            }
        )
        self.run_incus_cmd.return_value = "environment:\n  driver: lxc | qemu\n"
        self.assertTrue(self.executor_cls.available())
        self.run_incus_cmd.assert_called_once_with("info")

    def test_available_no_incus(self):
        """Test that available() returns False if incus is not available."""
        self.mock_is_command_available(
            {
                "incus": False,
                "autopkgtest-virt-incus": True,
            }
        )
        self.run_incus_cmd.return_value = "environment:\n  driver: lxc | qemu\n"
        self.assertFalse(self.executor_cls.available())

    def test_available_no_autopkgtest_virt_incus(self):
        """Not available() if autopkgtest-virt-incus is not available."""
        self.mock_is_command_available(
            {
                "incus": True,
                "autopkgtest-virt-incus": False,
            }
        )
        self.run_incus_cmd.return_value = "environment:\n  driver: lxc | qemu\n"
        self.assertFalse(self.executor_cls.available())

    def test_available_no_incus_permission(self):
        """Test that available() returns False if the worker can't run incus."""
        self.mock_is_command_available(
            {
                "incus": True,
                "autopkgtest-virt-incus": True,
            }
        )
        self.run_incus_cmd.side_effect = CalledProcessError(
            cmd=("incus", "info"), returncode=1
        )
        self.assertFalse(self.executor_cls.available())

    def test_instantiation_fetches_artifact(self):
        """Test that instantiating the Executor fetches the artifact."""
        self.assertEqual(self.executor.system_image, self.image_artifact)

    def test_download_image(self):
        """Test that download_image calls the ImageCache.download_image."""
        self.image_artifact = self.mock_image_download(
            self.debusine_api, create_image=True, system_image=self.uses_images
        )

        def fake_run_incus_cmd(*args: str, **kwargs) -> str:
            """Fake for run_incus_cmd."""
            if args == ("image", "info", "artifact/42"):
                raise CalledProcessError(cmd=("incus",) + args, returncode=1)
            if args[:2] == ("image", "import"):
                return "Fingerprint: abc123\n"
            raise AssertionError(f"Unexpected args: {args}")

        self.run_incus_cmd.side_effect = fake_run_incus_cmd

        response = self.executor.download_image()

        self.run_incus_cmd.assert_any_call(
            "image", "info", "artifact/42", suppress_stderr=True
        )
        self.run_incus_cmd.assert_called_with(
            "image", "import", ANY, "--alias", "artifact/42"
        )
        expected_name = "artifact/42"
        self.assertEqual(response, expected_name)

    def test_image_name(self):
        """Test that image_name returns the expected image name."""
        response = self.executor.image_name()
        expected_name = "artifact/42"
        self.assertEqual(response, expected_name)

    def test_autopkgtest_virt_server(self):
        """Test that autopkgtest_virt_server returns incus."""
        self.assertEqual(self.executor.autopkgtest_virt_server(), "incus")

    def test_create(self):
        """Test create() return IncusInstance instance."""
        self.executor._local_path = self.create_temporary_file()
        incus_instance = self.executor.create()

        self.assertIsInstance(incus_instance, IncusInstance)

    @patch("debusine.tasks.executors.incus.run_incus_cmd")
    def test_clean_up_image(self, run_incus_cmd):
        """Test that IncusImageCache.clean_up_image removes incus images."""
        self.executor_cls.clean_up_image(42)
        run_incus_cmd.assert_called_once_with("image", "rm", "artifact/42")


class IncusLXCExecutorTests(IncusExecutorCommon, TestCase):
    """Unit tests for IncusLXCExecutor."""

    executor_cls = IncusLXCExecutor
    uses_images = False

    def test_backend_name(self):
        """Test that the backend_name attribute was set."""
        self.assertEqual(self.executor_cls.backend_name, "incus-lxc")

    def test_executor_class_finds_incus_lxc(self):
        """Test that executor_class() supports incus-lxc."""
        instance = executor_class("incus-lxc")
        self.assertEqual(instance, self.executor_cls)

    def test_available_no_driver(self):
        """Test available() returns False if the lxc driver isn't there."""
        self.mock_is_command_available(
            {
                "incus": True,
                "autopkgtest-virt-incus": True,
            }
        )
        self.run_incus_cmd.return_value = "environment:\n  driver: qemu\n"
        self.assertFalse(self.executor_cls.available())

    def test_available_has_driver(self):
        """Test available() returns True if the lxc driver is there."""
        self.mock_is_command_available(
            {
                "incus": True,
                "autopkgtest-virt-incus": True,
            }
        )
        self.run_incus_cmd.return_value = "environment:\n  driver: lxc\n"
        self.assertTrue(self.executor_cls.available())

    def test_autopkgtest_virt_args(self):
        """Test that autopkgtest_virt_args returns sane arguments."""
        self.assertEqual(
            self.executor.autopkgtest_virt_args(),
            ["artifact/42", "--", "--profile", "debusine"],
        )


class IncusVMExecutorTests(IncusExecutorCommon, TestCase):
    """Unit tests for IncusVMExecutor."""

    executor_cls = IncusVMExecutor
    uses_images = True

    def test_backend_name(self):
        """Test that the backend_name attribute was set."""
        self.assertEqual(self.executor_cls.backend_name, "incus-vm")

    def test_executor_class_finds_incus_vm(self):
        """Test that executor_class() supports incus-vm."""
        instance = executor_class("incus-vm")
        self.assertEqual(instance, self.executor_cls)

    def test_available_no_driver(self):
        """Test available() returns False if the qemu driver isn't there."""
        self.mock_is_command_available({"incus": True})
        self.run_incus_cmd.return_value = "environment:\n  driver: lxc\n"
        self.assertFalse(self.executor_cls.available())

    def test_available_has_driver(self):
        """Test available() returns True if the qemu driver is there."""
        self.mock_is_command_available(
            {
                "incus": True,
                "autopkgtest-virt-incus": True,
            }
        )
        self.run_incus_cmd.return_value = "environment:\n  driver: qemu\n"
        self.assertTrue(self.executor_cls.available())

    def test_autopkgtest_virt_args(self):
        """Test that autopkgtest_virt_args returns sane arguments."""
        self.assertEqual(
            self.executor.autopkgtest_virt_args(),
            ["--vm", "artifact/42", "--", "--profile", "debusine"],
        )


class IncusInstanceTests(TestHelpersMixin, TestCase):
    """Tests for IncusInstance class."""

    def setUp(self):
        """Initialize test."""
        self.instance = IncusInstance("image-name")

        run_incus_cmd_patcher = patch(
            "debusine.tasks.executors.incus.run_incus_cmd"
        )
        self.run_incus_cmd = run_incus_cmd_patcher.start()
        self.addCleanup(run_incus_cmd_patcher.stop)

        def fake_run_incus_cmd(*args: str) -> str:
            """
            Fake for run_incus_cmd.

            Pretend to operate normally.
            """
            if args[0] == "info":  # _generate_instance_name() probes
                raise CalledProcessError(cmd=("incus",) + args, returncode=1)
            elif args[0] == "launch":
                return "Launching\n"
            elif args[0] == "stop":
                return ""
            elif args[0] == "restart":
                return ""
            elif args[0] == "file":  # push/pull
                return ""
            raise AssertionError(f"Unexpected args: {args}")

        self.run_incus_cmd.side_effect = fake_run_incus_cmd

    def patch_spin_until_started(self):
        """Patch _spin_until_started() out for testing."""
        patcher = patch.object(self.instance, "_spin_until_started")
        self._spin_until_started_mock = patcher.start()
        self.addCleanup(patcher.stop)

    def test_generate_instance_name(self):
        """Test that _generate_instance_name() picks an unused name."""
        rejected = None

        def fake_run_incus_cmd(*args: str) -> str:
            """
            Fake for run_incus_cmd.

            Reject the first name tested, accept the second.
            """
            nonlocal rejected
            if args[0] != "info":
                raise AssertionError(f"Unexpected args: {args}")
            if rejected:
                raise CalledProcessError(cmd=("incus",) + args, returncode=1)
            else:
                rejected = args[1]
                return "Fingerprint: abc123\n"

        self.run_incus_cmd.side_effect = fake_run_incus_cmd

        instance_name = IncusInstance._generate_instance_name()

        self.assertIsNotNone(rejected)
        self.assertNotEqual(rejected, instance_name)
        calls = self.run_incus_cmd.call_args_list
        self.assertEqual(len(calls), 2)
        self.assertEqual(calls[0], call("info", rejected))
        self.assertEqual(calls[1], call("info", instance_name))

    def test_start(self):
        """Test that start() launches an instance."""
        self.patch_spin_until_started()
        self.instance.start()

        # First call would have been generating an instance name
        self.assertEqual(len(self.run_incus_cmd.call_args_list), 2)
        self.run_incus_cmd.assert_called_with(
            "launch", "--ephemeral", "--profile", "debusine", "image-name", ANY
        )
        self.assertIsNotNone(self.instance._instance)
        self._spin_until_started_mock.assert_called_once_with()

    @patch("subprocess.run")
    def test_spin_until_started(self, run):
        """Test that spin_until_started() run true until it succeeds."""
        count = 0

        def fake_run(args: list[str], **kwargs) -> CompletedProcess:
            """
            Fake for subprocess.run().

            Pretend to spin once.
            """
            nonlocal count
            if count == 2:
                returncode = 0
                stderr = ""
            else:
                count += 1
                returncode = 1
                stderr = "Error: VM agent isn't currently running\n"
            return CompletedProcess(
                args=args,
                returncode=returncode,
                stderr=stderr,
            )

        run.side_effect = fake_run

        self.instance._instance = "fake-instance"
        self.instance._spin_until_started(sleep_time=0, timeout=2)

        run.assert_called_with(
            ["incus", "exec", self.instance._instance, "true"],
            stderr=PIPE,
            text=True,
        )

    @patch("subprocess.run")
    def test_spin_until_started_unexpected_failure(self, run):
        """Test that spin_until_started() may raise InstanceNotRunning."""
        run.return_value = CompletedProcess(
            args=("...",), returncode=1, stderr="Something weird"
        )

        self.instance._instance = "fake-instance"

        with self.assertRaisesRegex(
            InstanceNotRunning,
            r"Unexpected response from exec true: 1: Something weird",
        ):
            self.instance._spin_until_started(timeout=5)

    def test_spin_until_started_times_out(self):
        """Test that spin_until_started() times out."""
        self.instance._instance = "fake-instance"
        with self.assertRaisesRegex(
            InstanceNotRunning,
            "Incus Instance fake-instance hasn't booted or is missing "
            "incus-agent",
        ):
            self.instance._spin_until_started(timeout=0)

    def test_restart(self):
        """Test that restart() restarts an instance."""
        self.patch_spin_until_started()
        self.instance.start()
        instance_name = self.instance._instance
        self.run_incus_cmd.reset_mock()

        self.instance.restart()

        self.run_incus_cmd.assert_called_with("restart", instance_name)
        self.assertIsNotNone(self.instance._instance)

    def test_stop(self):
        """Test that stop() stops an instance."""
        self.patch_spin_until_started()
        self.instance.start()
        instance_name = self.instance._instance
        self.run_incus_cmd.reset_mock()

        self.instance.stop()

        self.run_incus_cmd.assert_called_with("stop", instance_name)
        self.assertIsNone(self.instance._instance)

    def test_file_push(self):
        """Test that file_push() copies a file into an instance."""
        self.patch_spin_until_started()
        self.instance.start()
        instance_name = self.instance._instance
        self.run_incus_cmd.reset_mock()

        self.instance.file_push(Path("/some/input"), Path("/tmp/destination"))

        self.run_incus_cmd.assert_called_with(
            "file",
            "push",
            "--uid",
            "0",
            "--gid",
            "0",
            "--mode",
            "644",
            "/some/input",
            f"{instance_name}/tmp/destination",
        )

    def test_directory_push(self):
        """Test that directory_push() copies a directory into an instance."""
        self.patch_spin_until_started()
        self.instance.start()
        instance_name = self.instance._instance
        self.run_incus_cmd.reset_mock()
        self.run_incus_cmd.side_effect = None

        self.instance.directory_push(Path("/some/directory"), Path("/tmp"))

        self.run_incus_cmd.assert_has_calls(
            [
                call(
                    "file",
                    "push",
                    "--recursive",
                    "/some/directory",
                    f"{instance_name}/tmp",
                ),
                call(
                    "exec",
                    instance_name,
                    "--",
                    "chown",
                    "-R",
                    "0:0",
                    "/tmp/directory",
                ),
            ]
        )

    def test_file_pull(self):
        """Test that file_pull() copies a file into an instance."""
        self.patch_spin_until_started()
        self.instance.start()
        instance_name = self.instance._instance
        self.run_incus_cmd.reset_mock()

        self.instance.file_pull(Path("/etc/hosts"), Path("/tmp/destination"))

        self.run_incus_cmd.assert_called_with(
            "file", "pull", f"{instance_name}/etc/hosts", "/tmp/destination"
        )

    @patch("subprocess.run")
    def test_run(self, run):
        """Test that run() executes commands as root."""
        self.patch_spin_until_started()
        self.instance.start()
        instance_name = self.instance._instance
        self.run_incus_cmd.reset_mock()

        self.instance._uid_map["_debusine"] = 1042

        self.instance.run(["true"])

        run.assert_called_with(
            ["incus", "exec", instance_name, "--user", "1042", "--", "true"],
            text=None,
            stdout=PIPE,
            stderr=PIPE,
        )

    @patch("subprocess.run")
    def test_run_as_root(self, run):
        """Test that run() executes commands as non-root."""
        self.patch_spin_until_started()
        self.instance.start()
        instance_name = self.instance._instance

        self.instance.run(["true", "--help"], run_as_root=True)

        run.assert_called_with(
            ["incus", "exec", instance_name, "--", "true", "--help"],
            text=None,
            stdout=PIPE,
            stderr=PIPE,
        )
