← Back to team overview

sts-sponsors team mailing list archive

[Merge] ~maas-committers/maas-ci/+git/system-tests:ansible-tests into ~maas-committers/maas-ci/+git/system-tests:master

 

Jack Lloyd-Walters has proposed merging ~maas-committers/maas-ci/+git/system-tests:ansible-tests into ~maas-committers/maas-ci/+git/system-tests:master.

Commit message:
Add system tests for Ansible Playbooks

Requested reviews:
  Adam Collard (adam-collard)
  MAAS Lander (maas-lander)
  Diego Mascialino (dmascialino)

For more details, see:
https://code.launchpad.net/~maas-committers/maas-ci/+git/system-tests/+merge/433880
-- 
Your team MAAS Committers is subscribed to branch ~maas-committers/maas-ci/+git/system-tests:master.
diff --git a/README.md b/README.md
index 2a998fc..f0fd167 100644
--- a/README.md
+++ b/README.md
@@ -47,17 +47,18 @@ cog.outl(f"We have {len(inits)} test suites:")
 for init in inits:
 	package = init.parent.name
 	module = pydoc.importfile(str(init))
-	docstring = textwrap.fill(module.__doc__, 80)
+	docstring = textwrap.fill(module.__doc__, 180)
 	cog.outl(f" - `{package}`: {docstring}\n")
 ]]] -->
-We have 4 test suites:
+We have 5 test suites:
+ - `ansible_tests`:  Prepares a container running Ansible, and clones maas-ansible-playbooks, to test that MAAS topology is configurable with Ansible, and that the MAAS install behaves as expected
+under testing.
+
  - `collect_sos_report`: Collect an SOS report from the test run.
 
- - `env_builder`:  Prepares a container with a running MAAS ready to run the tests, and writes a
-credentials.yaml to allow a MAAS client to use it.
+ - `env_builder`:  Prepares a container with a running MAAS ready to run the tests, and writes a credentials.yaml to allow a MAAS client to use it.
 
- - `general_tests`:  Uses credentials.yaml info to access a running MAAS deployment, asserts the
-state is useful to these tests and executes them.
+ - `general_tests`:  Uses credentials.yaml info to access a running MAAS deployment, asserts the state is useful to these tests and executes them.
 
  - `tests_per_machine`: Contains tests that are per machine, run in parallel by tox for efficiency.
 
@@ -88,6 +89,16 @@ The MAAS container is bridged on to the networks defined in `config.yaml` which 
 
 If you want to do a clean run, delete existing containers first.
 
+
+The Ansible tests use additional temporary containers that are removed automatically after test completion:
+
+ * The `ansible-main` container
+ * `ansible-host-$n` host containers
+
+Ansible is installed in the main container. All Ansible playbooks are executed from within this container, and all configuration options saved here for the duration of the test.
+
+Any number of host containers can be created for each system test, which will act as Ansible hosts for the MAAS playbooks.
+
 TODO: Automate container removal, so we don't need to keep this up to date. Maybe tox target?
 ```bash
 for param in maas-system-build maas-system-maas maas-client; do lxc delete --force $param; done
diff --git a/config.yaml.sample b/config.yaml.sample
index e9648fe..e656a27 100644
--- a/config.yaml.sample
+++ b/config.yaml.sample
@@ -117,3 +117,7 @@ o11y:
     grafana_agent_file_path: >
         /home/diego/canonical/agent-linux-amd64_0_24_2
     o11y_ip: 10.245.136.5
+
+ansible-playbooks:
+    git-repo: https://github.com/maas/maas-ansible-playbook.git
+    git-branch: main
diff --git a/lxd_configs/configure_lxd.sh b/lxd_configs/configure_lxd.sh
old mode 100644
new mode 100755
index 27fe4e6..cffcc1e
--- a/lxd_configs/configure_lxd.sh
+++ b/lxd_configs/configure_lxd.sh
@@ -24,7 +24,6 @@ cat maas_lab.profile | lxc profile edit prof-maas-lab
 lxc profile create prof-maas-test
 cat maas_test.profile | lxc profile edit prof-maas-test
 
-
 lxc init vm1 --vm --empty -p prof-maas-test
 VM1_MAC_ADDRESS=$(lxc config get vm1 volatile.eth0.hwaddr)
 echo "VM1 mac_address is $VM1_MAC_ADDRESS"
diff --git a/pyproject.toml b/pyproject.toml
index 251bf95..ca3654e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,8 +1,9 @@
 [tool.pytest.ini_options]
 addopts = "--strict-markers --durations=10"
 markers = [
-    "skip_if_installed_from_snap", # Skips tests if the MAAS is installed as a snap.
-    "skip_if_installed_from_deb_package" # Skips tests if the MASS is installed from package.
+    "skip_if_ansible_playbooks_unconfigured", # Skips tests if Ansible playbooks aren't present.
+    "skip_if_installed_from_snap", # Skips tests if MAAS is installed as a snap.
+    "skip_if_installed_from_deb_package" # Skips tests if MAAS is installed from package.
 ]
 log_level = "INFO"
 log_file = "systemtests.log"
diff --git a/systemtests/ansible.py b/systemtests/ansible.py
new file mode 100644
index 0000000..2ca5b40
--- /dev/null
+++ b/systemtests/ansible.py
@@ -0,0 +1,523 @@
+from __future__ import annotations
+
+import re
+import warnings
+from contextlib import contextmanager
+from datetime import timedelta
+from functools import cached_property
+from logging import getLogger
+from subprocess import CalledProcessError
+from typing import TYPE_CHECKING, Any, Iterator
+
+from retry import retry
+
+from .api import AuthenticatedAPIClient, UnauthenticatedMAASAPIClient
+from .lxd import Instance
+
+if TYPE_CHECKING:
+    from logging import Logger
+
+    from .lxd import CLILXD, _FileWrapper
+NAME = "systemtests.ansible"
+LOG = getLogger(NAME)
+
+
+class MissingRoleOnHost(Exception):
+    """Raised when a host is missing a role they were expected to have."""
+
+    pass
+
+
+class HostWithoutRole(Warning):
+    """Raised when a host does not have any assigned roles."""
+
+    pass
+
+
+class HostWithoutRegion(MissingRoleOnHost):
+    """Raised when a host attempts to access a region when
+    it does not yet have one installed."""
+
+    pass
+
+
+class RepoNotFound(Exception):
+    """Raised when trying to interact with a repo that does not exist."""
+
+    pass
+
+
+def is_ansible_enabled(config: dict[str, Any]) -> bool:
+    return "ansible-playbooks" in config
+
+
+def add_ansible_header(headers: list[str], config: dict[str, Any]) -> None:
+    if is_ansible_enabled(config):
+        headers.append("ansible-playbooks: true")
+
+
+def apt_update(instance: Instance) -> None:
+    """Update APT indices, fix broken dpkg."""
+    instance.quietly_execute(["apt-get", "update", "-y"])
+    instance.quietly_execute(["dpkg", "--configure", "-a"])
+
+
+def apt_install(instance: Instance, package: str) -> None:
+    """Install given package from apt."""
+    instance.quietly_execute(["apt", "install", package, "-y"])
+
+
+def pip_install(instance: Instance, package: str) -> None:
+    """Ensure latest version of Python package is installed."""
+    if not pip_package_exists(instance, package):
+        instance.quietly_execute(["pip3", "install", package])
+    else:
+        instance.quietly_execute(["pip3", "install", package, "--upgrade"])
+
+
+def pip_package_exists(instance: Instance, package: str) -> bool:
+    try:
+        instance.quietly_execute(["pip3", "show", "-qq", package])
+    except CalledProcessError:
+        return False
+    else:
+        return True
+
+
+def clone_repo(instance: Instance, repo: str, branch: str, clone_path: str) -> None:
+    clone_file = instance.files[clone_path]
+    if not clone_file.exists():
+        instance.execute(
+            [
+                "git",
+                "clone",
+                "-b",
+                branch,
+                "--depth",
+                "1",
+                repo,
+                clone_path,
+            ],
+        )
+        instance.logger.info(f"Cloned {branch} from {repo} to {clone_path}")
+
+
+class AnsibleHost:
+    def __init__(self, instance: Instance, image: str) -> None:
+        self.instance = instance
+        self._image = image
+        self.config: dict[str, str] = {"ansible_user": "ubuntu"}
+        self.roles: dict[str, dict[str, str]] = {}
+
+        LOG.info(f"Created {self}")
+
+    def __repr__(self) -> str:
+        return f"<AnsibleHost in {self.instance}>"
+
+    @property
+    def name(self) -> str:
+        return self.instance.name
+
+    @property
+    def ip(self) -> str:
+        return self.instance.get_ip_address()
+
+    def get_or_create_instance(self, public_ssh_key: str) -> None:
+        self.instance.create_container(
+            self._image,
+            f"ssh_authorized_keys:\n- {public_ssh_key}",
+        )
+
+    def update_config(
+        self, config: dict[str, str], role: str = "", all_roles: bool = False
+    ) -> None:
+        if not role:
+            self.config |= config
+            return
+        if all_roles:
+            for _role in self.roles.keys():
+                self.update_config(config, _role)
+            return
+        if role not in self.roles.keys():
+            raise MissingRoleOnHost(role)
+        self.roles[role] |= config
+
+    def fetch_config(self, config_key: str, role: str = "") -> str:
+        if not role:
+            return self.config.get(config_key, "")
+        if role not in self.roles.keys():
+            raise MissingRoleOnHost(role)
+        return self.roles[role].get(config_key, self.config.get(config_key, ""))
+
+    def remove_config(self, config_key: str, role: str = "") -> None:
+        if not role:
+            self.config.pop(config_key)
+            return
+        if role not in self.roles.keys():
+            raise MissingRoleOnHost(role)
+        self.roles[role].pop(config_key)
+
+    def clear_config(self, role: str = "") -> None:
+        if not role:
+            self.config.clear()
+            return
+        if role not in self.roles.keys():
+            raise MissingRoleOnHost(role)
+        self.roles[role].clear()
+
+    def host_setup(self, main_cfg: dict[str, str], role: str = "") -> str:
+        cfg = [self.ip]
+        cfg_dict = main_cfg.copy()
+        cfg_dict.update(self.config)
+        if role:
+            cfg_dict.update(self.roles.get(role, {}))
+        cfg.extend([f"{k}={v}" for k, v in cfg_dict.items()])
+        return " ".join(cfg)
+
+    def _add_role(self, role: str, config: dict[str, str]) -> None:
+        self.roles[role] = config | {"ansible_user": "ubuntu"}
+
+    def _remove_role(self, role: str) -> None:
+        self.roles.pop(role)
+
+    def has_role(self, role: str) -> bool:
+        return self.instance.exists() and role in self.roles
+
+    @property
+    def has_rack(self) -> bool:
+        return self.has_role("maas_rack_controller")
+
+    @property
+    def has_region(self) -> bool:
+        return self.has_role("maas_region_controller")
+
+    @property
+    def has_region_rack(self) -> bool:
+        return self.has_role("maas_region_controller") and self.has_role(
+            "maas_rack_controller"
+        )
+
+    @property
+    def has_postgres_primary(self) -> bool:
+        return self.has_role("maas_postgres_primary")
+
+    @property
+    def has_postgres_secondary(self) -> bool:
+        return self.has_role("maas_postgres_secondary")
+
+    @property
+    def has_proxy(self) -> bool:
+        return self.has_role("maas_proxy")
+
+    def add_rack(self, config: dict[str, str] = {}) -> AnsibleHost:
+        self._add_role("maas_rack_controller", config)
+        return self
+
+    def add_region(self, config: dict[str, str] = {}) -> AnsibleHost:
+        self._add_role("maas_region_controller", config)
+        return self
+
+    def add_region_rack(self, config: dict[str, str] = {}) -> AnsibleHost:
+        self._add_role("maas_rack_controller", config)
+        self._add_role("maas_region_controller", config)
+        return self
+
+    def add_postgres_primary(self, config: dict[str, str] = {}) -> AnsibleHost:
+        self._add_role("maas_postgres_primary", config)
+        return self
+
+    def add_postgres_secondary(self, config: dict[str, str] = {}) -> AnsibleHost:
+        self._add_role("maas_postgres_secondary", config)
+        return self
+
+    def add_proxy(self, config: dict[str, str] = {}) -> AnsibleHost:
+        self._add_role("maas_proxy", config)
+        return self
+
+    def remove_rack(self) -> None:
+        self._remove_role("maas_rack_controller")
+
+    def remove_region(self) -> None:
+        self._remove_role("maas_region_controller")
+
+    def remove_region_rack(self) -> None:
+        self._remove_role("maas_rack_controller")
+        self._remove_role("maas_region_controller")
+
+    def remove_postgres_primary(self) -> None:
+        self._remove_role("maas_postgres_primaryr")
+
+    def remove_postgres_secondary(self) -> None:
+        self._remove_role("maas_postgres_secondary")
+
+    def remove_proxy(self) -> None:
+        self._remove_role("maas_proxy")
+
+
+class AnsibleMain:
+    use_timeout = True
+    timeout = timedelta(minutes=5)
+    ssh_key_file = "/home/ubuntu/.ssh/id_rsa.pub"
+
+    def __init__(
+        self,
+        lxd: CLILXD,
+        instance: Instance,
+        playbooks_repo: str,
+        playbooks_branch: str,
+    ) -> None:
+        self._lxd = lxd
+        self.instance = instance
+        self._playbooks_repo = playbooks_repo
+        self._playbooks_branch = playbooks_branch
+        self._hosts_file = self.instance.files["/home/ubuntu/hosts"]
+
+        self.config: dict[str, str] = {}
+        self._inventory_: set[AnsibleHost] = set()
+
+        self.ansible_repo_path = "/home/ubuntu/ansible_repo"
+
+    def setup(self) -> None:
+        self.logger.info("Installing python3-pip")
+        apt_update(self.instance)
+        apt_install(self.instance, "python3-pip")
+        self.logger.info("Installing ansible")
+        pip_install(self.instance, "ansible")
+        self.logger.info("Installing netaddr")
+        pip_install(self.instance, "netaddr")
+        clone_repo(
+            self.instance,
+            self._playbooks_repo,
+            self._playbooks_branch,
+            self.ansible_repo_path,
+        )
+
+        self.create_config_file()
+        assert self.public_ssh_key
+        self.logger.info("Ansible environment setup")
+
+    def __repr__(self) -> str:
+        return f"<AnsibleMain in {self.instance}>"
+
+    @property
+    def logger(self) -> Logger:
+        return self.instance.logger
+
+    @logger.setter
+    def logger(self, logger: Logger) -> None:
+        self.instance.logger = logger
+
+    @cached_property
+    def public_ssh_key(self) -> str:
+        ssh_key = self.instance.files[self.ssh_key_file]
+        if not ssh_key.exists():
+            self.instance.execute(
+                ["ssh-keygen", "-t", "rsa", "-f", self.ssh_key_file[:-4]]
+            )
+        key = ssh_key.read()
+        self.instance.execute(["chmod", "600", self.ssh_key_file.replace(".pub", "")])
+        self.instance.execute(["chmod", "600", self.ssh_key_file])
+        return " ".join(key.split()[:2])
+
+    def add_key_to_host(self, host: AnsibleHost) -> None:
+        try:
+            self.instance.execute(
+                [
+                    "ssh-keygen",
+                    "-f",
+                    "/home/ubuntu/.ssh/known_hosts",
+                    "-R",
+                    host.ip,
+                ],
+            )
+        except CalledProcessError:
+            pass
+
+        host.instance.files["/home/ubuntu/.ssh/authorized_keys"].append(
+            self.public_ssh_key
+        )
+        self.instance.execute(
+            [
+                "ssh-copy-id",
+                "-i",
+                self.ssh_key_file,
+                "-o",
+                "StrictHostKeyChecking=no",
+                f"ubuntu@{host.ip}",
+            ],
+        )
+
+    def ping_hosts(self) -> None:
+        if not self._hosts_file.exists():
+            self.create_hosts_file()
+
+        @retry(tries=5, delay=10, logger=self.logger)
+        def _ping() -> None:
+            self.instance.execute(
+                [
+                    "ansible",
+                    "all",
+                    "-m",
+                    "ping",
+                    "-i",
+                    self._hosts_file.path,
+                    "--private-key",
+                    self.ssh_key_file[:-4],
+                    "-v",
+                ],
+            )
+
+        _ping()
+
+    @contextmanager
+    def collect_inventory(self, ping_hosts: bool = False) -> Iterator[set[AnsibleHost]]:
+        hosts = set()
+        for host in self._inventory_:
+            if not host.roles:
+                warnings.warn(
+                    f"Empty host: {host.name} has not been assigned a role.",
+                    HostWithoutRole,
+                )
+
+            host.get_or_create_instance(self.public_ssh_key)
+            self.add_key_to_host(host)
+            hosts.add(host)
+        if ping_hosts:
+            self.ping_hosts()
+        try:
+            yield hosts
+        finally:
+            self.remove_hosts(hosts)
+            self.clear_config()
+
+    def _make_host_name_(self) -> str:
+        """Determine the smallest number not yet used as a name"""
+        name_scheme = "ansible-host"
+        nums: set[int] = set()
+        for host in self._inventory_:
+            if re.match(rf"{name_scheme}-\d+", host.name):
+                val = re.search(r"\d+", host.name)
+                if val:
+                    nums.add(int(val.group()))
+        if nums:
+            return f"{name_scheme}-{min(set(range(1, max(nums)+2)) - nums)}"
+        return f"{name_scheme}-1"
+
+    def add_host(self, image: str = "ubuntu:20.04") -> AnsibleHost:
+        name = self._make_host_name_()
+        instance = Instance(self._lxd, name)
+        host = AnsibleHost(instance, image)
+        self._inventory_.add(host)
+        return host
+
+    def remove_hosts(self, hosts: set[AnsibleHost]) -> None:
+        for host in hosts:
+            self._remove_host(host)
+        self._inventory_.difference_update(hosts)
+
+    def _remove_host(self, host: AnsibleHost) -> None:
+        host.instance.delete()
+
+    def update_config(self, config: dict[str, str]) -> dict[str, str]:
+        self.config |= config
+        return self.config
+
+    def fetch_config(self, config_key: str) -> str:
+        return self.config.get(config_key, "")
+
+    def remove_config(self, config_key: str) -> None:
+        self.config.pop(config_key)
+
+    def clear_config(self) -> None:
+        self.config.clear()
+
+    def fetch_region(
+        self, host: AnsibleHost, user: str = "admin"
+    ) -> AuthenticatedAPIClient:
+        if host.has_region:
+            url = self.config.get(
+                "maas_url", host.fetch_config("maas_url", "maas_region_controller")
+            )
+            maas_client = UnauthenticatedMAASAPIClient(url, host.instance)
+            api_key = host.instance.execute(
+                ["maas", "apikey", "--username", user]
+            ).stdout.rstrip("\n")
+            _, authd_client = maas_client.log_in("admin", api_key)
+            return authd_client
+        raise HostWithoutRegion()
+
+    def create_hosts_file(self) -> None:
+        inventory: dict[str, list[AnsibleHost]] = {
+            "maas_postgres_primary": [],
+            "maas_postgres_secondary": [],
+            "maas_proxy": [],
+            "maas_region_controller": [],
+            "maas_rack_controller": [],
+        }
+        inv: list[str] = []
+        if self._hosts_file.exists():
+            self._hosts_file.delete()
+        for host in self._inventory_:
+            if not host.roles:
+                warnings.warn(
+                    f"Empty host: {host.name} has not been assigned a role.",
+                    HostWithoutRole,
+                )
+                continue
+            for role in host.roles:
+                if role not in inventory.keys():
+                    inventory[role] = []
+                inventory[role].append(host)
+        for role, hosts in inventory.items():
+            inv.append(f"[{role}]")
+            inv.extend([host.host_setup(self.config, role) for host in hosts])
+            inv.append("")
+        if [True for key in inventory.keys() if "postgres" in key]:
+            inv.extend(
+                [
+                    "[maas_postgres:children]",
+                    "maas_postgres_primary",
+                    "maas_postgres_secondary",
+                    "",
+                ]
+            )
+        file_content = "\n".join(inv)
+        LOG.info(f"Ansible hosts file generated:\n{file_content}")
+        self._hosts_file.write(file_content)
+
+    def create_config_file(self) -> None:
+        def append_if_not_found(
+            content: str, file_: _FileWrapper, loose_check: bool = True
+        ) -> None:
+            search = content if not loose_check else content.split()[0]
+            if search not in file_.read():
+                file_.append(content)
+
+        path = f"{self.ansible_repo_path}/ansible.cfg"
+        ansible_cfg = self.instance.files[path]
+        if not ansible_cfg.exists():
+            ansible_cfg.write("[defaults]\ninventory = hosts\n")
+        append_if_not_found("host_key_checking = False", ansible_cfg)
+        append_if_not_found("remote_user = ubuntu", ansible_cfg)
+        append_if_not_found("deprecation_warnings = False", ansible_cfg)
+        if self.use_timeout:
+            append_if_not_found(
+                f"task_timeout = {int(self.timeout.total_seconds())}", ansible_cfg
+            )
+        self.instance.execute(["mkdir", "-p", "/etc/ansible"])
+        etc_ansible_cfg = self.instance.files["/etc/ansible/ansible.cfg"]
+        etc_ansible_cfg.write(ansible_cfg.read())
+
+    def run_playbook(self, playbook: str = "site.yaml", debug: str = "-v") -> None:
+        self.create_hosts_file()
+        cmd: list[str] = [
+            "ansible-playbook",
+            f"{self.ansible_repo_path}/{playbook}",
+            "-i",
+            self._hosts_file.path,
+            "--private-key",
+            self.ssh_key_file[:-4],
+        ]
+        if _debug := re.match(r"-(v)+", debug):
+            cmd.append(str(_debug.group()))
+        self.instance.execute(cmd)
diff --git a/systemtests/ansible_tests/__init__.py b/systemtests/ansible_tests/__init__.py
new file mode 100644
index 0000000..f9c9339
--- /dev/null
+++ b/systemtests/ansible_tests/__init__.py
@@ -0,0 +1,5 @@
+"""
+Prepares a container running Ansible, and clones maas-ansible-playbooks, to
+test that MAAS topology is configurable with Ansible, and that the MAAS install
+behaves as expected under testing.
+"""
diff --git a/systemtests/ansible_tests/test_ansible.py b/systemtests/ansible_tests/test_ansible.py
new file mode 100644
index 0000000..f06fc6c
--- /dev/null
+++ b/systemtests/ansible_tests/test_ansible.py
@@ -0,0 +1,102 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from systemtests.ansible import AnsibleMain, pip_package_exists
+
+if TYPE_CHECKING:
+    from logging import Logger
+
+
+@pytest.mark.skip_if_ansible_playbooks_unconfigured(
+    "Needs Ansible playbook configuration"
+)
+class TestAnsibleConfig:
+    def test_setup_ansible_main(
+        self, testlog: Logger, ansible_main: AnsibleMain
+    ) -> None:
+        ansible_main.logger = testlog
+        assert pip_package_exists(ansible_main.instance, "ansible")
+        readme = ansible_main.instance.files[
+            f"{ansible_main.ansible_repo_path}/README.md"
+        ]
+        assert readme.exists()
+
+    def test_setup_maas_region(
+        self, testlog: Logger, ansible_main: AnsibleMain
+    ) -> None:
+        ansible_main.logger = testlog
+        host = ansible_main.add_host().add_region()
+        with ansible_main.collect_inventory() as inv:
+            assert inv == {host}
+            assert host.ip
+            host.update_config(
+                {"maas_url": f"http://{host.ip}:5240/MAAS";, "maas_version": "3.2"},
+                "maas_region_controller",
+            )
+            assert host.roles.get("maas_region_controller") == {
+                "ansible_user": "ubuntu",
+                "maas_version": "3.2",
+                "maas_url": f"http://{host.ip}:5240/MAAS";,
+            }
+        assert not ansible_main._inventory_
+        assert not host.instance.exists()
+
+
+@pytest.mark.skip_if_ansible_playbooks_unconfigured(
+    "Needs Ansible playbook configuration"
+)
+class TestAnsibleMAAS:
+    @pytest.mark.parametrize("installation_type", ["deb", "snap"])
+    def test_maas_region_installed(
+        self, installation_type: str, testlog: Logger, ansible_main: AnsibleMain
+    ) -> None:
+        ansible_main.logger = testlog
+        database = ansible_main.add_host(image="ubuntu:22.04").add_postgres_primary()
+        regionrack_host = ansible_main.add_host(image="ubuntu:22.04").add_region_rack()
+        with ansible_main.collect_inventory():
+            ansible_main.update_config(
+                {
+                    "maas_installation_type": installation_type,
+                    "maas_postgres_password": "sekret",
+                    "maas_url": f"http://{regionrack_host.ip}:5240/MAAS";,
+                    "maas_version": "3.3",
+                }
+            )
+            ansible_main.run_playbook("site.yaml", "-vv")
+            region = ansible_main.fetch_region(regionrack_host)
+            assert region.read_version_information()["version"][:3] == "3.3"
+        assert not ansible_main._inventory_
+        assert not regionrack_host.instance.exists()
+        assert not database.instance.exists()
+
+    def test_maas_region_updated(
+        self, testlog: Logger, ansible_main: AnsibleMain
+    ) -> None:
+        ansible_main.logger = testlog
+        start_version = "3.1"
+        upgrade_version = "3.3"
+        database = ansible_main.add_host().add_postgres_primary()
+        host = ansible_main.add_host().add_region_rack()
+
+        with ansible_main.collect_inventory():
+            ansible_main.update_config(
+                {
+                    "maas_installation_type": "snap",
+                    "maas_postgres_password": "sekret",
+                    "maas_url": f"http://{host.ip}:5240/MAAS";,
+                    "maas_version": start_version,
+                }
+            )
+            ansible_main.run_playbook("site.yaml", "-vvv")
+            region = ansible_main.fetch_region(host)
+            assert region.read_version_information()["version"][:3] == start_version
+
+            ansible_main.update_config({"maas_version": upgrade_version})
+            ansible_main.run_playbook("site.yaml", "-vvv")
+            assert region.read_version_information()["version"][:3] == upgrade_version
+        assert not ansible_main._inventory_
+        assert not host.instance.exists()
+        assert not database.instance.exists()
diff --git a/systemtests/conftest.py b/systemtests/conftest.py
index 5fd6f9c..58fc4aa 100644
--- a/systemtests/conftest.py
+++ b/systemtests/conftest.py
@@ -14,6 +14,7 @@ import pytest
 # ignore it - see tox.ini's [flake8] per-file-ignores
 
 pytest.register_assert_rewrite(
+    "systemtests.ansible",
     "systemtests.api",
     "systemtests.fixtures",
     "systemtests.region",
@@ -28,8 +29,10 @@ if TYPE_CHECKING:
     from .api import AuthenticatedAPIClient
     from typing import Set
 
+from .ansible import add_ansible_header
 from .device_config import HardwareSyncMachine
 from .fixtures import (
+    ansible_main,
     build_container,
     logstream,
     maas_api_client,
@@ -39,6 +42,7 @@ from .fixtures import (
     maas_deb_repo,
     maas_region,
     pool,
+    skip_if_ansible_playbooks_unconfigured,
     skip_if_installed_from_deb_package,
     skip_if_installed_from_snap,
     ssh_key,
@@ -69,6 +73,7 @@ if TYPE_CHECKING:
     from pluggy.callers import _Result  # type: ignore
 
 __all__ = [
+    "ansible_main",
     "authenticated_admin",
     "build_container",
     "configured_maas",
@@ -85,6 +90,7 @@ __all__ = [
     "ready_maas",
     "ready_remote_maas",
     "ssh_key",
+    "skip_if_ansible_playbooks_unconfigured",
     "skip_if_installed_from_deb_package",
     "skip_if_installed_from_snap",
     "tag_all",
@@ -174,6 +180,7 @@ def pytest_report_header(config: pytest.Config) -> list[str]:
     add_tls_header(headers, systemtests_config)
     add_vault_header(headers, systemtests_config)
     add_o11y_header(headers, systemtests_config)
+    add_ansible_header(headers, systemtests_config)
     return headers + ["END_SYSTEMTESTS_META"]
 
 
diff --git a/systemtests/fixtures.py b/systemtests/fixtures.py
index 9376f26..e552fed 100644
--- a/systemtests/fixtures.py
+++ b/systemtests/fixtures.py
@@ -11,6 +11,7 @@ import pytest
 import yaml
 from pytest_steps import one_fixture_per_step
 
+from .ansible import AnsibleMain
 from .api import AuthenticatedAPIClient, UnauthenticatedMAASAPIClient
 from .config import ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_USER
 from .lxd import Instance, get_lxd
@@ -37,6 +38,46 @@ def _add_maas_ppa(
 
 
 @pytest.fixture(scope="session")
+def ansible_main(config: dict[str, Any]) -> Optional[Iterator[AnsibleMain]]:
+    """Set up a new LXD container with ansible installed."""
+    playbooks_config = config.get("ansible-playbooks", {})
+    playbooks_repo = playbooks_config.get("git-repo")
+    if not playbooks_repo:
+        yield None
+        return
+    playbooks_branch = playbooks_config.get("git-branch", "main")
+    log = getLogger(f"{LOG_NAME}.ansible_main")
+    lxd = get_lxd(log)
+    instance = Instance(lxd, "ansible-main")
+    instance.create_container(config["containers-image"])
+    main = AnsibleMain(
+        lxd,
+        instance,
+        playbooks_repo=playbooks_repo,
+        playbooks_branch=playbooks_branch,
+    )
+    main.setup()
+    yield main
+    main.remove_hosts(main._inventory_)
+    instance.delete()
+
+
+@pytest.fixture(scope="session")
+def maas_from_ansible(ansible_main: AnsibleMain) -> Iterator[AuthenticatedAPIClient]:
+    """Deploy MAAS using Ansible."""
+    host = ansible_main.add_host().add_region()
+    with ansible_main.collect_inventory():
+        ansible_main.update_config(
+            {
+                "maas_installation_type": "snap",
+                "maas_url": f"http://{host.ip}:5240/MAAS";,
+            }
+        )
+        ansible_main.run_playbook("site.yaml", "-vv")
+        yield ansible_main.fetch_region(host)
+
+
+@pytest.fixture(scope="session")
 def build_container(config: dict[str, Any]) -> Optional[Iterator[Instance]]:
     """Create a container for building MAAS package in."""
     if "snap" in config:
@@ -60,8 +101,7 @@ def build_container(config: dict[str, Any]) -> Optional[Iterator[Instance]]:
             }
 
         user_data = "#cloud-config\n" + yaml.dump(cloud_config, default_style="|")
-        lxd.create_container(
-            container_name,
+        instance.create_container(
             config["containers-image"],
             user_data=user_data,
             profile=LXD_PROFILE,
@@ -247,8 +287,7 @@ def maas_container(
                 )
                 cloud_config["snap"]["commands"].insert(0, snap_proxy_cmd)
         user_data = get_user_data(devices, cloud_config=cloud_config)
-        lxd.create_container(
-            container_name,
+        instance.create_container(
             config["containers-image"],
             user_data=user_data,
             profile=container_name,
@@ -551,6 +590,18 @@ def skip_if_installed_from_deb_package(request: Any, config: dict[str, Any]) -> 
             pytest.skip(reason)
 
 
+@pytest.fixture(autouse=True)
+def skip_if_ansible_playbooks_unconfigured(
+    request: Any, config: dict[str, Any]
+) -> None:
+    """Skip tests that require ansible_playbooks configurationg."""
+    marker = request.node.get_closest_marker("skip_if_ansible_playbooks_unconfigured")
+    if marker:
+        reason = marker.args[0]
+        if "ansible-playbooks" not in config:
+            pytest.skip(reason)
+
+
 @pytest.fixture(scope="session")
 def ssh_key(authenticated_admin: AuthenticatedAPIClient) -> Iterator[paramiko.PKey]:
     """Generate an SSH key to access deployed machines."""
diff --git a/systemtests/lxd.py b/systemtests/lxd.py
index f660ffd..a264e10 100644
--- a/systemtests/lxd.py
+++ b/systemtests/lxd.py
@@ -40,21 +40,31 @@ class _FileWrapper:
     def __init__(self, lxd: CLILXD, instance_name: str, path: str):
         self._lxd = lxd
         self._instance_name = instance_name
-        self._path = path
+        self.path = path
 
     def __repr__(self) -> str:
-        return f"<FileWrapper for {self._path} on {self._instance_name}>"
+        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)
+        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
+            self._instance_name, content, self.path, uid=uid, gid=gid
         )
 
+    def append(self, content: str) -> None:
+        file_content = self.read().splitlines() if self.exists() else []
+        if file_content and file_content[-1] == "\n":
+            file_content = file_content[:-1]
+        file_content.extend(content.split("\n"))
+        self.write("\n".join(file_content))
+
     def exists(self) -> bool:
-        return self._lxd.file_exists(self._instance_name, self._path)
+        return self._lxd.file_exists(self._instance_name, self.path)
+
+    def delete(self) -> None:
+        self._lxd.execute(self._instance_name, ["rm", self.path])
 
     def push(
         self,
@@ -67,7 +77,7 @@ class _FileWrapper:
         return self._lxd.push_file(
             self._instance_name,
             str(local_path),
-            self._path,
+            self.path,
             uid=uid,
             gid=gid,
             mode=mode,
@@ -75,7 +85,7 @@ class _FileWrapper:
         )
 
     def pull(self, local_path: str) -> None:
-        self._lxd.pull_file(self._instance_name, self._path, local_path)
+        self._lxd.pull_file(self._instance_name, self.path, local_path)
 
 
 class _FilesWrapper:
@@ -113,6 +123,11 @@ class Instance:
     def exists(self) -> bool:
         return self._lxd.container_exists(self._instance_name)
 
+    def create_container(
+        self, image: str, user_data: Optional[str] = None, profile: Optional[str] = None
+    ) -> Instance:
+        return self._lxd.create_container(self.name, image, user_data, profile)
+
     def execute(
         self, command: list[str], environment: Optional[dict[str, str]] = None
     ) -> subprocess.CompletedProcess[str]:
diff --git a/tox.ini b/tox.ini
index 84ecf35..32e284c 100644
--- a/tox.ini
+++ b/tox.ini
@@ -26,7 +26,7 @@ passenv =
   MAAS_SYSTEMTESTS_CLIENT_CONTAINER
   MAAS_SYSTEMTESTS_LXD_PROFILE
 
-[testenv:{env_builder,collect_sos_report,general_tests}]
+[testenv:{env_builder,collect_sos_report,general_tests,ansible_tests}]
 passenv = {[base]passenv}
 
 [testenv:cog]
diff --git a/utils/gen_config.py b/utils/gen_config.py
index cb000a2..4a8a5b5 100755
--- a/utils/gen_config.py
+++ b/utils/gen_config.py
@@ -1,29 +1,49 @@
 #!/usr/bin/env python3
-"""Generate specific system-test config from a base config and options."""
+"""Generate specific system-tests config from a base config and options."""
 
 from __future__ import annotations
 
 import argparse
-import pathlib
 import sys
-from typing import Any
+from typing import Any, Optional
 
 from ruamel.yaml import YAML
 
 yaml = YAML()
 
 
+class WideHelpFormatter(argparse.HelpFormatter):
+    def __init__(
+        self,
+        prog: str,
+        indent_increment: int = 2,
+        max_help_position: int = 1024,
+        width: Optional[int] = None,
+    ) -> None:
+        # Copied from argparse.py <<EOC
+        # default setting for width
+        if width is None:
+            import shutil
+
+            width = shutil.get_terminal_size().columns
+            width -= 2
+        # EOC
+        super().__init__(
+            prog, indent_increment, max_help_position=width - 20, width=width
+        )
+
+
 def main(argv: list[str]) -> int:
-    parser = argparse.ArgumentParser()
+    parser = argparse.ArgumentParser(formatter_class=WideHelpFormatter)
     parser.add_argument(
         "base_config",
-        type=pathlib.Path,
-        help="Path to full config.yaml file for this environment",
+        type=argparse.FileType("r"),
+        help="Path to base config.yaml file to use as a basis for this one.",
     )
     parser.add_argument(
         "output_config",
-        type=pathlib.Path,
-        help="Path for filtered config.yaml for this test",
+        type=argparse.FileType("w"),
+        help="Path to write config.yaml to.",
     )
     installation_method = parser.add_mutually_exclusive_group(required=True)
     installation_method.add_argument(
@@ -33,7 +53,7 @@ def main(argv: list[str]) -> int:
         "--snap", action="store_true", help="Install MAAS via snap"
     )
 
-    snap_group = parser.add_argument_group("snap", "Install MAAS via snap")
+    snap_group = parser.add_argument_group("Snap options", "Install MAAS via snap")
     snap_group.add_argument(
         "--snap-channel",
         type=str,
@@ -47,7 +67,7 @@ def main(argv: list[str]) -> int:
         help="Install maas-test-db via snap using this channel",
     )
     deb_group = parser.add_argument_group(
-        "deb", "Build and install MAAS via deb package"
+        "Debian package options", "Build and install MAAS via debian package"
     )
     deb_group.add_argument(
         "--ppa",
@@ -63,7 +83,7 @@ def main(argv: list[str]) -> int:
         "--git-branch", type=str, help="Which git branch use to get MAAS"
     )
     enabled_features_group = parser.add_argument_group(
-        "features", "Set up MAAS with these features enabled"
+        "Features", "Set up MAAS with these features enabled"
     )
     enabled_features_group.add_argument(
         "--tls",
@@ -80,8 +100,21 @@ def main(argv: list[str]) -> int:
         action="store_true",
         help="Add hw_sync tests for this test",
     )
+    ansible_playbooks_group = parser.add_argument_group(
+        "Ansible playbooks", "Config options for Ansible Playbook testing"
+    )
+    ansible_playbooks_group.add_argument(
+        "--ansible-repo",
+        metavar="GIT_REPO",
+        help="Git repository to clone Ansible Playbooks from",
+    )
+    ansible_playbooks_group.add_argument(
+        "--ansible-branch",
+        metavar="GIT_BRANCH",
+        help="Git branch to clone Ansible Playbooks from",
+    )
     filter_machines_group = parser.add_argument_group(
-        "filters", "Use this option for filter machines to test"
+        "filters", "Use these options to filter machines to test"
     )
     filter_machines_group.add_argument(
         "--architecture",
@@ -110,7 +143,7 @@ def main(argv: list[str]) -> int:
 
     args = parser.parse_args(argv)
 
-    with open(args.base_config, "r") as fh:
+    with args.base_config as fh:
         config: dict[str, Any] = yaml.load(fh)
 
     if args.containers_image:
@@ -124,7 +157,7 @@ def main(argv: list[str]) -> int:
 
     if args.tls:
         if "tls" not in config:
-            parser.error("TlS section required but not present in base_config")
+            parser.error("TLS section required but not present in base_config")
     else:
         config.pop("tls", None)
 
@@ -162,6 +195,13 @@ def main(argv: list[str]) -> int:
         if args.git_branch:
             config["deb"]["git_branch"] = args.git_branch
 
+    if args.ansible_repo:
+        config["ansible-playbooks"] = config.get("ansible-playbooks", {})
+        config["ansible-playbooks"]["git-repo"] = args.ansible_repo
+        config["ansible-playbooks"]["git-branch"] = args.ansible_branch
+    else:
+        config.pop("ansible-playbooks", None)
+
     machines = config.get("machines", {})
     vms = machines.get("vms", {})
     hardware = machines.get("hardware", {})
@@ -178,22 +218,21 @@ def main(argv: list[str]) -> int:
         if not vms_with_devices:
             parser.error("There are no VMs valid for hw_sync in base_config")
     else:
-        # Drop out VMs with devices.
+        # Filter out VMs with devices.
         for vm_name in vms_with_devices:
             vms["instances"].pop(vm_name)
 
-    if args.architecture:
-        if hardware:
-            # Drop out machines with architectures distinct of specified ones.
+    if hardware:
+        if args.architecture:
+            # Filter out machines with architectures not matching specified ones.
             machines["hardware"] = {
                 name: details
                 for name, details in hardware.items()
                 if details.get("architecture", "amd64") in args.architecture
             }
 
-    if args.machine:
-        if hardware:
-            # Drop out machines not listed in specified machines.
+        if args.machine:
+            # Filter out machines not listed in specified machines.
             machines["hardware"] = {
                 name: details
                 for name, details in hardware.items()
@@ -201,7 +240,7 @@ def main(argv: list[str]) -> int:
             }
 
     if args.vm_machine:
-        # Drop out VMs with name not listed in specified vm_machines
+        # Filter out VMs with name not listed in specified vm_machines
         if vms:
             vms["instances"] = {
                 vm_name: vm_config
@@ -209,7 +248,7 @@ def main(argv: list[str]) -> int:
                 if vm_name in args.vm_machine
             }
 
-    with open(args.output_config, "w") as fh:
+    with args.output_config as fh:
         yaml.dump(config, fh)
 
     return 0

References