# Copyright 2023 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 SimpleSystemImageBuild class."""
import importlib.resources
from copy import deepcopy
from pathlib import Path
from typing import Any
from unittest import TestCase
from unittest.mock import call, patch

import responses

import yaml

from debusine.artifacts.local_artifact import DebianSystemImageArtifact
from debusine.tasks.models import (
    DiskImageFormat,
    SystemBootstrapRepositoryCheckSignatureWith,
    SystemBootstrapRepositoryType,
    WorkerType,
)
from debusine.tasks.simplesystemimagebuild import SimpleSystemImageBuild
from debusine.tasks.tests.helper_mixin import ExternalTaskHelperMixin
from debusine.test import TestHelpersMixin


class SimpleSystemImageBuildTests(
    TestHelpersMixin, ExternalTaskHelperMixin[SimpleSystemImageBuild], TestCase
):
    """Unit tests for SimpleSystemImageBuild class."""

    SAMPLE_TASK_DATA = {
        "bootstrap_options": {
            "architecture": "amd64",
        },
        "bootstrap_repositories": [
            {
                "mirror": "https://deb.debian.org/debian",
                "suite": "stable",
            },
        ],
        "disk_image": {
            "format": "raw",
            "partitions": [
                {
                    "size": 2,
                    "filesystem": "ext4",
                },
            ],
        },
    }

    def setUp(self):
        """Initialize test."""
        self.configure_task()

    def assertActionInRecipe(
        self, expected_action: dict[str, Any], recipe: dict[str, Any]
    ) -> None:
        """Assert that expected_action is found in recipe."""
        for action in recipe["actions"]:
            if action == expected_action:
                return
        raise AssertionError(f"Action {expected_action} not found in recipe")

    def assertActionNotInRecipe(
        self, expected_action: dict[str, Any], recipe: dict[str, Any]
    ) -> None:
        """Assert that expected_action is not found in recipe."""
        for action in recipe["actions"]:
            if action == expected_action:
                raise AssertionError(
                    f"Action {expected_action} unexpectedly found in recipe"
                )
        return

    def findActionByDescription(
        self, description: str, recipe: dict[str, Any]
    ) -> dict[str, Any] | None:
        """Find an action with description in recipe. Return it."""
        for action in recipe["actions"]:
            if action.get("description") == description:
                return action
        return None

    def test_configure_task(self):
        """self.configure_task() does not raise any exception."""
        self.configure_task(self.SAMPLE_TASK_DATA)

    def test_configure_task_defaults(self):
        """self.configure_task() set default values."""
        bootstrap_options = {
            "architecture": "amd64",
            "extra_packages": ["hello", "bye"],
        }

        self.configure_task(override={"bootstrap_options": bootstrap_options})

        self.assertEqual(
            self.task.data.dict(),
            {
                'bootstrap_options': {
                    'architecture': 'amd64',
                    'extra_packages': ['hello', 'bye'],
                    'variant': None,
                },
                'bootstrap_repositories': [
                    {
                        'check_signature_with': (
                            SystemBootstrapRepositoryCheckSignatureWith.SYSTEM
                        ),
                        'components': None,
                        'mirror': 'https://deb.debian.org/debian',
                        'suite': 'stable',
                        'keyring': None,
                        'keyring_package': None,
                        'types': [SystemBootstrapRepositoryType.DEB],
                    }
                ],
                'customization_script': None,
                'disk_image': {
                    'format': DiskImageFormat.RAW,
                    'bootloader': None,
                    'filename': 'image',
                    'kernel_package': None,
                    'partitions': [
                        {'size': 2, 'filesystem': 'ext4', 'mountpoint': 'none'}
                    ],
                },
            },
        )

    def test_analyze_worker(self):
        """Test the analyze_worker() method."""
        with patch("os.access", return_value=True):
            self.mock_is_command_available(
                {
                    "/lib/systemd/systemd-resolved": True,
                    "debos": True,
                    "linux.uml": False,
                }
            )
            metadata = self.task.analyze_worker()
            self.assertTrue(metadata["simplesystemimagebuild:available"])

    def test_analyze_worker_debos_not_available(self):
        """analyze_worker() handles debos not being available."""
        with patch("os.access", return_value=True):
            self.mock_is_command_available(
                {
                    "debos": False,
                    "/lib/systemd/systemd-resolved": True,
                    "linux.uml": False,
                }
            )
            metadata = self.task.analyze_worker()
            self.assertFalse(metadata["simplesystemimagebuild:available"])

    def test_analyze_worker_systemd_resolved_not_available(self):
        """analyze_worker() handles systemd-resolved not being available."""
        with patch("os.access", return_value=True):
            self.mock_is_command_available(
                {
                    "/lib/systemd/systemd-resolved": False,
                    "debos": True,
                    "linux.uml": False,
                }
            )
            metadata = self.task.analyze_worker()
            self.assertFalse(metadata["simplesystemimagebuild:available"])

    def test_analyze_worker_kvm_not_available(self):
        """analyze_worker() handles KVM not being available."""
        with patch("os.access", return_value=False):
            self.mock_is_command_available(
                {
                    "/lib/systemd/systemd-resolved": True,
                    "debos": True,
                    "linux.uml": False,
                }
            )
            metadata = self.task.analyze_worker()
            self.assertFalse(metadata["simplesystemimagebuild:available"])

    def test_analyze_worker_uml(self):
        """analyze_worker() will accept UML instead of KVM."""
        with patch("os.access", return_value=False):
            self.mock_is_command_available(
                {
                    "/lib/systemd/systemd-resolved": True,
                    "debos": True,
                    "linux.uml": True,
                }
            )
            metadata = self.task.analyze_worker()
            self.assertTrue(metadata["simplesystemimagebuild:available"])

    def test_can_run_on(self):
        """can_run_on returns True if debos is available."""
        self.assertTrue(
            self.task.can_run_on(
                {
                    "system:architectures": ["amd64"],
                    "system:worker_type": WorkerType.EXTERNAL,
                    "simplesystemimagebuild:available": True,
                    "simplesystemimagebuild:version": self.task.TASK_VERSION,
                }
            )
        )

    def test_can_run_on_mismatched_task_version(self):
        """can_run_on returns False for mismatched task versions."""
        self.assertFalse(
            self.task.can_run_on(
                {
                    "system:architectures": ["amd64"],
                    "system:worker_type": WorkerType.EXTERNAL,
                    "simplesystemimagebuild:available": True,
                    "simplesystemimagebuild:version": (
                        self.task.TASK_VERSION + 1
                    ),
                }
            )
        )

    def test_can_run_on_missing_tool(self):
        """can_run_on returns False if debos is not available."""
        self.assertFalse(
            self.task.can_run_on(
                {
                    "system:architectures": ["amd64"],
                    "system:worker_type": WorkerType.EXTERNAL,
                    "simplesystemimagebuild:available": False,
                    "simplesystemimagebuild:version": self.task.TASK_VERSION,
                }
            )
        )

    def test_can_run_different_architecture(self):
        """can_run_on returns False if the architecture is different."""
        self.assertFalse(
            self.task.can_run_on(
                {
                    "system:architectures": ["alpha"],
                    "system:worker_type": WorkerType.EXTERNAL,
                    "simplesystemimagebuild:available": True,
                    "simplesystemimagebuild:version": self.task.TASK_VERSION,
                }
            )
        )

    def test_can_run_compatible_architecture(self):
        """can_run_on returns True if the architecture is compatible."""
        bootstrap_options = {"architecture": "i386"}

        self.configure_task(override={"bootstrap_options": bootstrap_options})
        self.assertTrue(
            self.task.can_run_on(
                {
                    "system:architectures": ["amd64", "i386"],
                    "system:worker_type": WorkerType.EXTERNAL,
                    "simplesystemimagebuild:available": True,
                    "simplesystemimagebuild:version": self.task.TASK_VERSION,
                }
            )
        )

    def test_cmdline(self):
        """Command line has minimum options."""
        self.task._debos_recipe = Path("/somewhere/some-file.sources")

        expected = ["debos", "--verbose", "/somewhere/some-file.sources"]

        cmdline = self.task._cmdline()
        self.assertEqual(cmdline, expected)

    def test_fetch_input(self):
        """Test fetch_input method."""
        # Directory does not need to exist: it is not used
        directory = Path()
        self.assertTrue(self.task.fetch_input(directory))

    def test_configure_for_execution(self):
        """Test configure_for_execution()."""
        download_dir = self.create_temporary_directory()

        # Assume that, in production code, the directory was created
        # with restrictive permissions
        download_dir.chmod(0o700)

        self.configure_task()

        self.assertTrue(self.task.configure_for_execution(download_dir))
        with open(download_dir / "debusine.yaml") as recipe_file:
            recipe = yaml.safe_load(recipe_file)

        expected = {
            "actions": [
                {
                    "action": "debootstrap",
                    "description": "Run debootstrap",
                    "mirror": "https://deb.debian.org/debian",
                    "suite": "stable",
                },
                {
                    "action": "image-partition",
                    "description": "Create and partition image",
                    "imagename": "image.img",
                    "imagesize": "2.2560000000000002G",
                    "mountpoints": [
                        {"mountpoint": "/", "partition": "root"},
                        {"mountpoint": "/efi", "partition": "EFI"},
                    ],
                    "partitions": [
                        {
                            "end": "256M",
                            "fs": "vfat",
                            "name": "EFI",
                            "options": ["x-systemd.automount"],
                            "parttype": "c12a7328-f81f-11d2-ba4b-00a0c93ec93b",
                            "start": "0%",
                        },
                        {
                            "end": "100%",
                            "fs": "ext4",
                            "name": "root",
                            "parttype": "4f68bce3-e8cd-4db1-96e7-fbcaf984b709",
                            "start": "256M",
                            "features": [],
                        },
                    ],
                    "partitiontype": "gpt",
                },
                {
                    "action": "filesystem-deploy",
                    "append-kernel-cmdline": (
                        "console=ttyS0 rootwait rw fsck.mode=auto "
                        "fsck.repair=yes"
                    ),
                    "setup-kernel-cmdline": True,
                },
                {
                    "action": "overlay",
                    "source": "overlays/incus-agent",
                    "description": "Install incus-agent-setup",
                },
                {
                    "action": "run",
                    "description": "Make incus-agent-setup executable",
                    "command": (
                        "chmod 755 ${ROOTDIR}/lib/systemd/incus-agent-setup"
                    ),
                },
                {
                    "action": "apt",
                    "description": "Install init",
                    "packages": ["init"],
                },
                {
                    "action": "apt",
                    "description": "Install bootloader",
                    "packages": ["systemd-boot"],
                },
                {
                    "action": "run",
                    "chroot": True,
                    "command": "bootctl install",
                    "description": "Install systemd-boot to /efi",
                },
                {
                    "action": "run",
                    "command": (
                        "umask 0022 && echo debian > ${ROOTDIR}/etc/hostname"
                    ),
                    "description": "Set hostname",
                },
                {
                    "action": "apt",
                    "description": "Install systemd-resolved",
                    "packages": ["systemd-resolved"],
                },
                {
                    "action": "run",
                    "command": (
                        "systemctl --root=${ROOTDIR} enable systemd-networkd"
                    ),
                    "description": "Enable systemd-networkd",
                },
                {
                    "action": "run",
                    "command": (
                        "echo '[Match]\n"
                        "Name=en*\n"
                        "[Network]\n"
                        "DHCP=yes' > "
                        "${ROOTDIR}/etc/systemd/network/default.network"
                    ),
                    "description": "Setup network interface",
                },
                {
                    "action": "apt",
                    "description": "Install kernel",
                    "packages": ["linux-image-generic"],
                },
                {
                    "action": "run",
                    "command": "passwd --root=${ROOTDIR} --delete root",
                    "description": "Allow login without password",
                },
                {
                    "action": "run",
                    "command": "cat ${ROOTDIR}/etc/os-release",
                    "description": "Get os-release",
                },
                {
                    "action": "run",
                    "command": "dpkg-query --root ${ROOTDIR} -W",
                    "description": "Get package list",
                },
                {
                    "action": "run",
                    "command": (
                        "tar --create --auto-compress --file "
                        "image.tar.xz image.img"
                    ),
                    "description": "tar image",
                    "postprocess": True,
                },
            ],
            "architecture": "amd64",
        }

        self.assertEqual(recipe, expected)
        self.assertTrue((download_dir / "overlays" / "incus-agent").exists())

    def test_configure_for_execution_variant(self):
        """Test configure_for_execution() change variant."""
        download_dir = self.create_temporary_directory()

        task_data = {
            "bootstrap_options": {
                "architecture": "armhf",
                "variant": "minbase",
                "extra_packages": ["hello"],
            },
            "bootstrap_repositories": [
                {
                    "mirror": "http://ftp.de.debian.org/debian",
                    "suite": "bullseye",
                    "components": ["main", "contrib"],
                    "keyring_package": "debian-archive-keyring",
                },
            ],
            "disk_image": {
                "format": "qcow2",
                "filename": "debian",
                "kernel_package": "linux-image-armmp",
                "bootloader": None,
                "partitions": [
                    {
                        "size": 2,
                        "filesystem": "ext4",
                    },
                ],
            },
        }
        self.configure_task(task_data)

        self.assertTrue(self.task.configure_for_execution(download_dir))
        with open(download_dir / "debusine.yaml") as recipe_file:
            recipe = yaml.safe_load(recipe_file)

        expected = {
            "actions": [
                {
                    "action": "debootstrap",
                    "components": task_data["bootstrap_repositories"][0][
                        "components"
                    ],
                    "description": "Run debootstrap",
                    "mirror": task_data["bootstrap_repositories"][0]["mirror"],
                    "suite": task_data["bootstrap_repositories"][0]["suite"],
                    "variant": task_data["bootstrap_options"]["variant"],
                    "keyring-package": task_data["bootstrap_repositories"][0][
                        "keyring_package"
                    ],
                },
                {
                    "description": "Create and partition image",
                    "action": "image-partition",
                    "imagename": "debian.img",
                    "imagesize": "2.2560000000000002G",
                    "mountpoints": [
                        {"mountpoint": "/", "partition": "root"},
                        {"mountpoint": "/efi", "partition": "EFI"},
                    ],
                    "partitions": [
                        {
                            "end": "256M",
                            "fs": "vfat",
                            "name": "EFI",
                            "options": ["x-systemd.automount"],
                            "parttype": "c12a7328-f81f-11d2-ba4b-00a0c93ec93b",
                            "start": "0%",
                        },
                        {
                            "end": "100%",
                            "fs": "ext4",
                            "name": "root",
                            "parttype": "69dad710-2ce4-4e3c-b16c-21a1d49abed3",
                            "start": "256M",
                            "features": [],
                        },
                    ],
                    "partitiontype": "gpt",
                },
                {
                    "action": "filesystem-deploy",
                    "append-kernel-cmdline": (
                        "console=ttyS0 rootwait rw "
                        "fsck.mode=auto fsck.repair=yes"
                    ),
                    "setup-kernel-cmdline": True,
                },
                {
                    "action": "overlay",
                    "source": "overlays/incus-agent",
                    "description": "Install incus-agent-setup",
                },
                {
                    "action": "run",
                    "description": "Make incus-agent-setup executable",
                    "command": (
                        "chmod 755 ${ROOTDIR}/lib/systemd/incus-agent-setup"
                    ),
                },
                {
                    "action": "apt",
                    "description": "Install init",
                    "packages": ["init"],
                },
                {"action": "overlay", "source": "overlays/systemd-boot"},
                {
                    "action": "run",
                    "description": "Make systemd-boot .d files executable",
                    "command": (
                        "chmod 755 "
                        "${ROOTDIR}/etc/kernel/*/zz-update-systemd-boot"
                    ),
                },
                {
                    "action": "run",
                    "command": (
                        "mkdir -p ${ROOTDIR}/efi/$(cat "
                        "${ROOTDIR}/etc/machine-id)"
                    ),
                    "description": "Create loader entries directory",
                },
                {
                    "action": "run",
                    "chroot": True,
                    "command": "bootctl install",
                    "description": "Install systemd-boot to /efi",
                },
                {
                    "action": "run",
                    "command": (
                        "umask 0022 && echo debian > ${ROOTDIR}/etc/hostname"
                    ),
                    "description": "Set hostname",
                },
                {
                    "action": "run",
                    "command": (
                        "systemctl --root=${ROOTDIR} enable systemd-resolved"
                    ),
                    "description": "Enable systemd-resolved",
                },
                {
                    "action": "run",
                    "command": (
                        "systemctl --root=${ROOTDIR} enable systemd-networkd"
                    ),
                    "description": "Enable systemd-networkd",
                },
                {
                    "action": "run",
                    "command": (
                        "echo '[Match]\n"
                        "Name=en*\n"
                        "[Network]\n"
                        "DHCP=yes' > "
                        "${ROOTDIR}/etc/systemd/network/default.network"
                    ),
                    "description": "Setup network interface",
                },
                {
                    "action": "apt",
                    "description": "Install kernel",
                    "packages": ["linux-image-armmp"],
                },
                {
                    "action": "apt",
                    "description": "Install keyring",
                    "packages": ["debian-archive-keyring"],
                },
                {
                    "action": "apt",
                    "description": "Install additional packages",
                    "packages": ["hello"],
                },
                {
                    "action": "run",
                    "command": "passwd --root=${ROOTDIR} --delete root",
                    "description": "Allow login without password",
                },
                {
                    "action": "run",
                    "command": "cat ${ROOTDIR}/etc/os-release",
                    "description": "Get os-release",
                },
                {
                    "action": "run",
                    "command": "dpkg-query --root ${ROOTDIR} -W",
                    "description": "Get package list",
                },
                {
                    "action": "run",
                    "command": (
                        "qemu-img convert -O qcow2 debian.img debian.qcow2"
                    ),
                    "description": "Convert image to qcow2",
                    "postprocess": True,
                },
            ],
            "architecture": task_data["bootstrap_options"]["architecture"],
        }

        self.assertEqual(recipe, expected)
        self.assertTrue((download_dir / "overlays" / "incus-agent").exists())
        self.assertTrue((download_dir / "overlays" / "systemd-boot").exists())

    def test_configure_for_execution_explicit_bootloader(self):
        """Test configure_for_execution() with an explicit bootloader."""
        download_dir = self.create_temporary_directory()

        override = {"disk_image": deepcopy(self.SAMPLE_TASK_DATA["disk_image"])}
        override["disk_image"]["bootloader"] = "systemd-boot"
        self.configure_task(override=override)

        self.assertTrue(self.task.configure_for_execution(download_dir))
        with open(download_dir / "debusine.yaml") as recipe_file:
            recipe = yaml.safe_load(recipe_file)

        action = self.findActionByDescription("Install bootloader", recipe)
        self.assertIsNotNone(action)
        self.assertIn("systemd-boot", action["packages"])

    def test_configure_for_execution_missing_bootloader(self):
        """Test configure_for_execution() when no default bootloader."""
        download_dir = self.create_temporary_directory()

        self.configure_task(
            override={
                "bootstrap_options": {"architecture": "s390x"},
            }
        )

        with self.assertRaisesRegex(
            ValueError, "No default bootloader for s390x"
        ):
            self.task.configure_for_execution(download_dir)

    def test_configure_for_execution_gummiboot(self):
        """
        Test configure_for_execution() with gummiboot.

        Not that we expect it to work, but it exercises more branches.
        """
        download_dir = self.create_temporary_directory()

        override = {"disk_image": deepcopy(self.SAMPLE_TASK_DATA["disk_image"])}
        override["disk_image"]["bootloader"] = "gummiboot"
        self.configure_task(override=override)

        self.assertTrue(self.task.configure_for_execution(download_dir))
        with open(download_dir / "debusine.yaml") as recipe_file:
            recipe = yaml.safe_load(recipe_file)

        action = self.findActionByDescription("Install bootloader", recipe)
        self.assertIsNotNone(action)
        self.assertIn("gummiboot", action["packages"])
        self.assertIsNone(
            self.findActionByDescription("Install grub-efi to /efi", recipe)
        )
        self.assertIsNone(
            self.findActionByDescription("Install systemd-boot to /efi", recipe)
        )

    def test_configure_for_execution_grub(self):
        """Test configure_for_execution() with grub-efi."""
        download_dir = self.create_temporary_directory()

        override = {"disk_image": deepcopy(self.SAMPLE_TASK_DATA["disk_image"])}
        override["disk_image"]["bootloader"] = "grub-efi"
        self.configure_task(override=override)

        self.assertTrue(self.task.configure_for_execution(download_dir))
        with open(download_dir / "debusine.yaml") as recipe_file:
            recipe = yaml.safe_load(recipe_file)

        action = self.findActionByDescription("Install bootloader", recipe)
        self.assertIsNotNone(action)
        self.assertIn("grub-efi", action["packages"])
        self.assertIsNone(
            self.findActionByDescription("Install systemd-boot to /efi", recipe)
        )
        action = self.findActionByDescription(
            "Install grub-efi to /efi", recipe
        )
        self.assertEqual(
            action["command"],
            "grub-install --target=x86_64-efi --efi-directory=/efi --removable",
        )
        action = self.findActionByDescription(
            "Enable serial console kernel cmdline", recipe
        )
        self.assertEqual(
            action["command"],
            (
                "echo GRUB_CMDLINE_LINUX=\"console=ttyS0\" "
                ">> ${ROOTDIR}/etc/default/grub"
            ),
        )
        action = self.findActionByDescription(
            "Write initial grub configuration", recipe
        )
        self.assertEqual(action["command"], "update-grub")

    def test_configure_for_execution_jessie(self):
        """Test configure_for_execution() for jessie."""
        download_dir = self.create_temporary_directory()

        override = {
            "bootstrap_repositories": deepcopy(
                self.SAMPLE_TASK_DATA["bootstrap_repositories"]
            )
        }
        override["bootstrap_repositories"][0]["suite"] = "jessie"
        self.configure_task(override=override)

        self.assertTrue(self.task.configure_for_execution(download_dir))
        with open(download_dir / "debusine.yaml") as recipe_file:
            recipe = yaml.safe_load(recipe_file)

        action = self.findActionByDescription(
            "Create and partition image", recipe
        )
        features = action["partitions"][1]["features"]
        self.assertEqual(features, ["^metadata_csum"])

        action = self.findActionByDescription("Install bootloader", recipe)
        self.assertIsNotNone(action)
        self.assertIn("grub-efi", action["packages"])
        self.assertIsNotNone(
            self.findActionByDescription("Enable systemd-resolved", recipe)
        )
        self.assertIsNotNone(
            self.findActionByDescription("Install grub-efi to /efi", recipe)
        )
        self.assertActionNotInRecipe(
            {"action": "overlay", "source": "overlays/systemd-boot"}, recipe
        )
        self.assertIsNone(
            self.findActionByDescription("Install systemd-boot to /efi", recipe)
        )
        action = self.findActionByDescription("Run debootstrap", recipe)
        self.assertIsNotNone(action)
        self.assertFalse(action["merged-usr"])
        self.assertActionInRecipe(
            {
                "action": "run",
                "command": (
                    "echo '[Match]\n"
                    "Name=eth*\n"
                    "[Network]\n"
                    "DHCP=both' > "
                    "${ROOTDIR}/etc/systemd/network/default.network"
                ),
                "description": "Setup network interface",
            },
            recipe,
        )
        self.assertActionInRecipe(
            {
                "action": "overlay",
                "source": "overlays/incus-agent-jessie",
                "description": "Install incus-agent.service for jessie",
            },
            recipe,
        )

    def test_configure_for_execution_stretch(self):
        """Test configure_for_execution() for stretch."""
        download_dir = self.create_temporary_directory()

        override = {
            "bootstrap_repositories": deepcopy(
                self.SAMPLE_TASK_DATA["bootstrap_repositories"]
            )
        }
        override["bootstrap_repositories"][0]["suite"] = "stretch"
        self.configure_task(override=override)

        self.assertTrue(self.task.configure_for_execution(download_dir))
        with open(download_dir / "debusine.yaml") as recipe_file:
            recipe = yaml.safe_load(recipe_file)

        action = self.findActionByDescription("Install bootloader", recipe)
        self.assertIsNone(action)
        self.assertActionInRecipe(
            {"action": "overlay", "source": "overlays/systemd-boot"}, recipe
        )
        self.assertIsNotNone(
            self.findActionByDescription("Enable systemd-resolved", recipe)
        )

        action = self.findActionByDescription("Run debootstrap", recipe)
        self.assertIsNotNone(action)
        self.assertFalse(action["merged-usr"])
        self.assertActionInRecipe(
            {
                "action": "run",
                "command": (
                    "echo '[Match]\n"
                    "Name=en*\n"
                    "[Network]\n"
                    "DHCP=yes' > "
                    "${ROOTDIR}/etc/systemd/network/default.network"
                ),
                "description": "Setup network interface",
            },
            recipe,
        )

    def test_configure_for_execution_buster(self):
        """Test configure_for_execution() for buster."""
        download_dir = self.create_temporary_directory()

        override = {
            "bootstrap_repositories": deepcopy(
                self.SAMPLE_TASK_DATA["bootstrap_repositories"]
            )
        }
        override["bootstrap_repositories"][0]["suite"] = "buster"
        self.configure_task(override=override)

        self.assertTrue(self.task.configure_for_execution(download_dir))
        with open(download_dir / "debusine.yaml") as recipe_file:
            recipe = yaml.safe_load(recipe_file)

        action = self.findActionByDescription("Install bootloader", recipe)
        self.assertIsNone(action)
        self.assertActionInRecipe(
            {"action": "overlay", "source": "overlays/systemd-boot"}, recipe
        )
        self.assertIsNotNone(
            self.findActionByDescription("Enable systemd-resolved", recipe)
        )
        action = self.findActionByDescription("Run debootstrap", recipe)
        self.assertIsNotNone(action)
        self.assertNotIn("merged-usr", action)

    def test_configure_for_execution_bullseye(self):
        """Test configure_for_execution() for bullseye."""
        download_dir = self.create_temporary_directory()

        override = {
            "bootstrap_repositories": deepcopy(
                self.SAMPLE_TASK_DATA["bootstrap_repositories"]
            )
        }
        override["bootstrap_repositories"][0]["suite"] = "bullseye"
        self.configure_task(override=override)

        self.assertTrue(self.task.configure_for_execution(download_dir))
        with open(download_dir / "debusine.yaml") as recipe_file:
            recipe = yaml.safe_load(recipe_file)

        action = self.findActionByDescription("Install bootloader", recipe)
        self.assertIsNone(action)
        self.assertActionInRecipe(
            {"action": "overlay", "source": "overlays/systemd-boot"}, recipe
        )
        self.assertIsNotNone(
            self.findActionByDescription("Enable systemd-resolved", recipe)
        )
        action = self.findActionByDescription("Run debootstrap", recipe)
        self.assertIsNotNone(action)
        self.assertNotIn("merged-usr", action)

    def test_configure_for_execution_bookworm(self):
        """Test configure_for_execution() for bookworm."""
        download_dir = self.create_temporary_directory()

        override = {
            "bootstrap_repositories": deepcopy(
                self.SAMPLE_TASK_DATA["bootstrap_repositories"]
            )
        }
        override["bootstrap_repositories"][0]["suite"] = "bookworm"
        self.configure_task(override=override)

        self.assertTrue(self.task.configure_for_execution(download_dir))
        with open(download_dir / "debusine.yaml") as recipe_file:
            recipe = yaml.safe_load(recipe_file)

        action = self.findActionByDescription("Install bootloader", recipe)
        self.assertIsNotNone(action)
        self.assertIn("systemd-boot", action["packages"])
        self.assertActionNotInRecipe(
            {"action": "overlay", "source": "overlays/systemd-boot"}, recipe
        )
        self.assertIsNone(
            self.findActionByDescription("Enable systemd-resolved", recipe)
        )
        action = self.findActionByDescription("Run debootstrap", recipe)
        self.assertIsNotNone(action)
        self.assertNotIn("merged-usr", action)

    def test_configure_for_execution_bookworm_buildd(self):
        """Test configure_for_execution() for bookworm, buildd variant."""
        download_dir = self.create_temporary_directory()

        override = {
            "bootstrap_options": deepcopy(
                self.SAMPLE_TASK_DATA["bootstrap_options"]
            ),
            "bootstrap_repositories": deepcopy(
                self.SAMPLE_TASK_DATA["bootstrap_repositories"]
            ),
        }
        override["bootstrap_options"]["variant"] = "buildd"
        override["bootstrap_repositories"][0]["suite"] = "bookworm"
        self.configure_task(override=override)

        self.assertTrue(self.task.configure_for_execution(download_dir))
        with open(download_dir / "debusine.yaml") as recipe_file:
            recipe = yaml.safe_load(recipe_file)

        action = self.findActionByDescription("Run debootstrap", recipe)
        self.assertIsNotNone(action)
        self.assertFalse(action["merged-usr"])

    def test_configure_for_execution_trixie(self):
        """Test configure_for_execution() for trixie."""
        download_dir = self.create_temporary_directory()

        override = {
            "bootstrap_repositories": deepcopy(
                self.SAMPLE_TASK_DATA["bootstrap_repositories"]
            )
        }
        override["bootstrap_repositories"][0]["suite"] = "trixie"
        self.configure_task(override=override)

        self.assertTrue(self.task.configure_for_execution(download_dir))
        with open(download_dir / "debusine.yaml") as recipe_file:
            recipe = yaml.safe_load(recipe_file)

        action = self.findActionByDescription("Install bootloader", recipe)
        self.assertIsNotNone(action)
        self.assertIn("systemd-boot", action["packages"])
        self.assertActionNotInRecipe(
            {"action": "overlay", "source": "overlays/systemd-boot"}, recipe
        )
        self.assertIsNone(
            self.findActionByDescription("Enable systemd-resolved", recipe)
        )
        action = self.findActionByDescription("Run debootstrap", recipe)
        self.assertIsNotNone(action)
        self.assertNotIn("merged-usr", action)

    def test_copy_resource_tree_file(self):
        """copy_resource_tree will copy a single file."""
        dest_dir = self.create_temporary_directory()
        dest_file = dest_dir / "file"
        self.task.copy_resource_tree(
            importlib.resources.files("debusine.tasks")
            .joinpath("data")
            .joinpath("overlays")
            .joinpath("systemd-boot")
            .joinpath("etc/kernel/postrm.d/zz-update-systemd-boot"),
            dest_file,
        )
        self.assertTrue(dest_file.exists())

    def test_copy_resource_tree_directory(self):
        """copy_resource_tree will copy a directory."""
        dest_dir = self.create_temporary_directory()
        self.task.copy_resource_tree(
            importlib.resources.files("debusine.tasks")
            .joinpath("data")
            .joinpath("overlays")
            .joinpath("systemd-boot")
            .joinpath("etc/kernel/postrm.d"),
            dest_dir,
        )
        self.assertTrue((dest_dir / "zz-update-systemd-boot").exists())

    def test_copy_resource_tree_recursively(self):
        """copy_resource_tree will copy a directory recursively."""
        dest_dir = self.create_temporary_directory()
        self.task.copy_resource_tree(
            importlib.resources.files("debusine.tasks")
            .joinpath("data")
            .joinpath("overlays")
            .joinpath("systemd-boot"),
            dest_dir,
        )
        self.assertTrue(
            (dest_dir / "etc/kernel/postrm.d/zz-update-systemd-boot").exists()
        )
        self.assertTrue(
            (dest_dir / "etc/kernel/postinst.d/zz-update-systemd-boot").exists()
        )

    def test_write_overlays(self):
        """write_overlays will write named overlays."""
        work_dir = self.create_temporary_directory()
        overlays = work_dir / "overlays"

        self.task.write_overlays(overlays, ["systemd-boot"])
        self.assertTrue(overlays.exists())
        sd_boot = overlays / "systemd-boot"
        self.assertTrue(sd_boot.exists())
        self.assertTrue(
            (sd_boot / "etc/kernel/postrm.d/zz-update-systemd-boot").exists()
        )
        self.assertTrue(
            (sd_boot / "etc/kernel/postinst.d/zz-update-systemd-boot").exists()
        )

    @responses.activate
    def test_configure_for_execution_keyring(self):
        """Test configure_for_execution() with keyring."""
        download_dir = self.create_temporary_directory()

        url_0 = "https://example.com/keyring1.asc"

        repositories = [
            {
                "mirror": "https://deb.debian.org/debian",
                "suite": "stable",
                "keyring": {"url": url_0},
            },
        ]

        keyring_contents_0 = b"The contents of the keyring 0"

        responses.add(
            responses.GET,
            url_0,
            body=keyring_contents_0,
        )

        self.configure_task(override={"bootstrap_repositories": repositories})

        self.assertTrue(self.task.configure_for_execution(download_dir))
        with open(download_dir / "debusine.yaml") as recipe_file:
            recipe = yaml.safe_load(recipe_file)

        self.assertRegex(
            recipe["actions"][0]["keyring-file"],
            r"/.*/debusine-tests-.*/keyring-repo-.*\.asc",
        )

    def test_configure_for_execution_customization_script(self):
        """Test configure_for_execution() write customization_script."""
        download_dir = self.create_temporary_directory()

        script = "#!/bin/sh\n\necho 'something'\n"
        self.configure_task(
            override={
                "customization_script": script,
            }
        )

        self.assertTrue(self.task.configure_for_execution(download_dir))

        with open(download_dir / "debusine.yaml") as recipe_file:
            recipe = yaml.safe_load(recipe_file)

        self.assertRegex(
            recipe["actions"][-2]["command"],
            r"/tmp/debusine-tests-.*/customization_script",
        )

        self.assertEqual(
            Path(recipe["actions"][-2]["command"]).read_text(), script
        )

    def test_configure_for_execution_customization_script_autopkgtest(self):
        """Test configure_for_execution() with autopkgtests setup-testbed."""
        download_dir = self.create_temporary_directory()

        self.configure_task(
            override={
                "customization_script": (
                    "/usr/share/autopkgtest/setup-commands/setup-testbed"
                ),
            }
        )

        self.assertTrue(self.task.configure_for_execution(download_dir))

        with open(download_dir / "debusine.yaml") as recipe_file:
            recipe = yaml.safe_load(recipe_file)

        self.assertEqual(
            recipe["actions"][-2]["command"],
            "env AUTOPKGTEST_KEEP_APT_SOURCES=1 "
            "/usr/share/autopkgtest/setup-commands/setup-testbed ${ROOTDIR}",
        )

    def test_upload_artifacts(self):
        """Test upload_artifacts()."""
        directory = self.create_temporary_directory()

        debos_log = directory / SimpleSystemImageBuild.CAPTURE_OUTPUT_FILENAME
        debos_log.write_text(
            """
Running /debos --artifactdir /some/folder debusine2.yaml --internal-image
2024/01/18 10:15:44 ==== Bootstrap stable minbase ====
2024/01/18 10:15:44 Debootstrap | I: Target architecture can be executed
2024/01/18 10:15:44 Debootstrap | I: Retrieving InRelease
2024/01/18 10:16:56 apt | Building dependency tree...
2024/01/18 10:16:56 apt | Reading state information...
2024/01/18 10:16:56 ==== Get os-release ====
2024/01/18 10:16:56 cat ${ROOTDIR}/etc/os-release | NAME="Debian GNU/Linux"
2024/01/18 10:16:56 cat ${ROOTDIR}/etc/os-release | VERSION_ID="12"
2024/01/18 10:16:56 cat ${ROOTDIR}/etc/os-release | VERSION="12 (bookworm)"
2024/01/18 10:16:56 cat ${ROOTDIR}/etc/os-release | VERSION_CODENAME=bookworm
2024/01/18 10:16:56 cat ${ROOTDIR}/etc/os-release | ID=debian
2024/01/18 10:16:56 ==== Get package list ====
2024/01/18 10:16:56 dpkg-query --root ${ROOTDIR} -W | adduser	3.134
2024/01/18 10:16:56 dpkg-query --root ${ROOTDIR} -W | apt	2.6.1
2024/01/18 10:16:56 dpkg-query --root ${ROOTDIR} -W | base-files	12.4+deb12u4
2024/01/18 10:16:56 dpkg-query --root ${ROOTDIR} -W | base-passwd	3.6.1
2024/01/18 10:16:56 ==== tar image ====
2024/01/18 10:16:56 Compressing to /some/folder/image.tar.xz
"""
        )

        system_image = directory / self.task.filename
        system_image.write_bytes(b"Generated image")

        # Debusine.upload_artifact is mocked to verify the call only
        debusine_mock = self.mock_debusine()

        self.task.upload_artifacts(directory, execution_success=True)

        calls = []

        expected_system_artifact = DebianSystemImageArtifact.create(
            system_image,
            data={
                "variant": None,
                "architecture": self.SAMPLE_TASK_DATA["bootstrap_options"][
                    "architecture"
                ],
                "vendor": "debian",
                "codename": "bookworm",
                "pkglist": {
                    "adduser": "3.134",
                    "apt": "2.6.1",
                    "base-files": "12.4+deb12u4",
                    "base-passwd": "3.6.1",
                },
                "with_dev": True,
                "with_init": True,
                "mirror": self.SAMPLE_TASK_DATA["bootstrap_repositories"][0][
                    "mirror"
                ],
                "image_format": "raw",
                "filesystem": "ext4",
                "size": 2e9,
                "boot_mechanism": "efi",
            },
        )

        calls.append(
            call(
                expected_system_artifact,
                workspace=self.task.workspace_name,
                work_request=self.task.work_request_id,
            )
        )

        debusine_mock.upload_artifact.assert_has_calls(calls)

    def test_upload_artifacts_jessie(self):
        """Test upload_artifacts on jessie()."""
        directory = self.create_temporary_directory()

        debos_log = directory / SimpleSystemImageBuild.CAPTURE_OUTPUT_FILENAME
        debos_log.write_text(
            """\
Running /debos --artifactdir /some/folder debusine2.yaml --internal-image
2024/01/18 10:15:44 ==== Bootstrap stable minbase ====
2024/01/18 10:15:44 Debootstrap | I: Target architecture can be executed
2024/01/18 10:15:44 Debootstrap | I: Retrieving InRelease
2024/01/18 10:16:56 apt | Building dependency tree...
2024/01/18 10:16:56 apt | Reading state information...
2024/01/18 10:16:56 ==== Get os-release ====
2024/02/21 17:13:38 cat ${ROOTDIR}/etc/os-release |\
 PRETTY_NAME="Debian GNU/Linux 8 (jessie)"
2024/02/21 17:13:38 cat ${ROOTDIR}/etc/os-release | NAME="Debian GNU/Linux"
2024/02/21 17:13:38 cat ${ROOTDIR}/etc/os-release | VERSION_ID="8"
2024/02/21 17:13:38 cat ${ROOTDIR}/etc/os-release | VERSION="8 (jessie)"
2024/02/21 17:13:38 cat ${ROOTDIR}/etc/os-release | ID=debian
2024/02/21 17:13:38 cat ${ROOTDIR}/etc/os-release |\
 HOME_URL="http://www.debian.org/"
2024/02/21 17:13:38 cat ${ROOTDIR}/etc/os-release |\
 SUPPORT_URL="http://www.debian.org/support"
2024/02/21 17:13:38 cat ${ROOTDIR}/etc/os-release |\
 BUG_REPORT_URL="https://bugs.debian.org/"
2024/01/18 10:16:56 ==== Get package list ====
2024/01/18 10:16:56 dpkg-query --root ${ROOTDIR} -W | adduser	3.134
2024/01/18 10:16:56 dpkg-query --root ${ROOTDIR} -W | apt	2.6.1
2024/01/18 10:16:56 dpkg-query --root ${ROOTDIR} -W | base-files	12.4+deb12u4
2024/01/18 10:16:56 dpkg-query --root ${ROOTDIR} -W | base-passwd	3.6.1
2024/01/18 10:16:56 ==== tar image ====
2024/01/18 10:16:56 Compressing to /some/folder/image.tar.xz
"""
        )

        system_image = directory / self.task.filename
        system_image.write_bytes(b"Generated image")

        # Debusine.upload_artifact is mocked to verify the call only
        debusine_mock = self.mock_debusine()

        self.task.data.bootstrap_repositories[0].suite = "jessie"
        self.task.upload_artifacts(directory, execution_success=True)

        calls = []

        expected_system_artifact = DebianSystemImageArtifact.create(
            system_image,
            data={
                "variant": None,
                "architecture": self.SAMPLE_TASK_DATA["bootstrap_options"][
                    "architecture"
                ],
                "vendor": "debian",
                "codename": "jessie",
                "pkglist": {
                    "adduser": "3.134",
                    "apt": "2.6.1",
                    "base-files": "12.4+deb12u4",
                    "base-passwd": "3.6.1",
                },
                "with_dev": True,
                "with_init": True,
                "mirror": self.SAMPLE_TASK_DATA["bootstrap_repositories"][0][
                    "mirror"
                ],
                "image_format": "raw",
                "filesystem": "ext4",
                "size": 2e9,
                "boot_mechanism": "efi",
            },
        )

        calls.append(
            call(
                expected_system_artifact,
                workspace=self.task.workspace_name,
                work_request=self.task.work_request_id,
            )
        )

        debusine_mock.upload_artifact.assert_has_calls(calls)

    def test_upload_artifacts_unstable(self):
        """Test upload_artifacts on unstable()."""
        directory = self.create_temporary_directory()

        debos_log = directory / SimpleSystemImageBuild.CAPTURE_OUTPUT_FILENAME
        debos_log.write_text(
            """\
Running /debos --artifactdir /some/folder debusine2.yaml --internal-image
2024/01/18 10:15:44 ==== Bootstrap stable minbase ====
2024/01/18 10:15:44 Debootstrap | I: Target architecture can be executed
2024/01/18 10:15:44 Debootstrap | I: Retrieving InRelease
2024/01/18 10:16:56 apt | Building dependency tree...
2024/01/18 10:16:56 apt | Reading state information...
2024/01/18 10:16:56 ==== Get os-release ====
2024/02/21 17:13:38 cat ${ROOTDIR}/etc/os-release |\
 PRETTY_NAME="Debian GNU/Linux trixie/sid"
2024/02/21 17:13:38 cat ${ROOTDIR}/etc/os-release | NAME="Debian GNU/Linux"
2024/02/21 17:13:38 cat ${ROOTDIR}/etc/os-release | VERSION_CODENAME="trixie"
2024/02/21 17:13:38 cat ${ROOTDIR}/etc/os-release | ID=debian
2024/02/21 17:13:38 cat ${ROOTDIR}/etc/os-release |\
 HOME_URL="http://www.debian.org/"
2024/02/21 17:13:38 cat ${ROOTDIR}/etc/os-release |\
 SUPPORT_URL="http://www.debian.org/support"
2024/02/21 17:13:38 cat ${ROOTDIR}/etc/os-release |\
 BUG_REPORT_URL="https://bugs.debian.org/"
2024/01/18 10:16:56 ==== Get package list ====
2024/01/18 10:16:56 dpkg-query --root ${ROOTDIR} -W | adduser	3.134
2024/01/18 10:16:56 dpkg-query --root ${ROOTDIR} -W | apt	2.6.1
2024/01/18 10:16:56 dpkg-query --root ${ROOTDIR} -W | base-files	12.4+deb12u4
2024/01/18 10:16:56 dpkg-query --root ${ROOTDIR} -W | base-passwd	3.6.1
2024/01/18 10:16:56 ==== tar image ====
2024/01/18 10:16:56 Compressing to /some/folder/image.tar.xz
"""
        )

        system_image = directory / self.task.filename
        system_image.write_bytes(b"Generated image")

        # Debusine.upload_artifact is mocked to verify the call only
        debusine_mock = self.mock_debusine()

        self.task.data.bootstrap_repositories[0].suite = "unstable"
        self.task.upload_artifacts(directory, execution_success=True)

        calls = []

        expected_system_artifact = DebianSystemImageArtifact.create(
            system_image,
            data={
                "variant": None,
                "architecture": self.SAMPLE_TASK_DATA["bootstrap_options"][
                    "architecture"
                ],
                "vendor": "debian",
                "codename": "sid",
                "pkglist": {
                    "adduser": "3.134",
                    "apt": "2.6.1",
                    "base-files": "12.4+deb12u4",
                    "base-passwd": "3.6.1",
                },
                "with_dev": True,
                "with_init": True,
                "mirror": self.SAMPLE_TASK_DATA["bootstrap_repositories"][0][
                    "mirror"
                ],
                "image_format": "raw",
                "filesystem": "ext4",
                "size": 2e9,
                "boot_mechanism": "efi",
            },
        )

        calls.append(
            call(
                expected_system_artifact,
                workspace=self.task.workspace_name,
                work_request=self.task.work_request_id,
            )
        )

        debusine_mock.upload_artifact.assert_has_calls(calls)

    def test_upload_artifacts_do_nothing(self):
        """Test upload_artifacts() doing nothing: execution_success=False."""
        self.mock_debusine()
        self.task.upload_artifacts(Path(), execution_success=False)
