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:
  MAAS Committers (maas-committers)

For more details, see:
https://code.launchpad.net/~maas-committers/maas-ci/+git/system-tests/+merge/433880
-- 
Your team MAAS Committers is requested to review the proposed merge of 
~maas-committers/maas-ci/+git/system-tests:ansible-tests into 
~maas-committers/maas-ci/+git/system-tests:master.
diff --git a/README.md b/README.md
index 2a998fc..52e2d75 100644
--- a/README.md
+++ b/README.md
@@ -50,7 +50,11 @@ for init in inits:
 	docstring = textwrap.fill(module.__doc__, 80)
 	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/maas-ansible-playbooks,
+to test their MAAS topolgy 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
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/systemtests/__init__.py b/systemtests/__init__.py
index 77623bb..93dfafa 100644
--- a/systemtests/__init__.py
+++ b/systemtests/__init__.py
@@ -1,6 +1,7 @@
 import pytest
 
 pytest.register_assert_rewrite(
+    "systemtests.ansible",
     "systemtests.api",
     "systemtests.region",
     "systemtests.state",
diff --git a/systemtests/ansible.py b/systemtests/ansible.py
new file mode 100644
index 0000000..17c1a64
--- /dev/null
+++ b/systemtests/ansible.py
@@ -0,0 +1,442 @@
+import json
+import re
+import warnings
+from contextlib import contextmanager
+from logging import getLogger
+from typing import Optional
+
+import pytest
+
+from .lxd import get_lxd
+from .region import MAASRegion
+
+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 MAASRegionExtension(MAASRegion):
+    @property
+    def version(self) -> str:
+        v_info = json.loads(self.execute(["maas", "admin", "version", "read"]).stdout)
+        return v_info.get("version")
+
+    def login(self, user: str) -> None:
+        self.execute(
+            [
+                "maas",
+                "login",
+                "admin",
+                f"{self.url}/api/2.0/",
+                self.get_api_token(user),
+            ]
+        )
+
+
+class AnsibleShared:
+    config = {}
+    lxd = get_lxd(LOG)
+
+    def __init__(self, user: Optional[str] = "ubuntu") -> None:
+        self.base_filepath = f"/home/{user}"
+        self.host_file = f"{self.base_filepath}/hosts"
+        self.user = user
+
+    def __repr__(self) -> str:
+        return f"<{self.__class__.__name__} in container {self.name}"
+
+    def apt_install(self, module: str) -> None:
+        if self.has_container:
+            self.execute(["apt-get", "update", "-y"])
+            self.execute(["dpkg", "--configure", "-a"])
+            self.execute(["apt", "install", module, "-y"])
+
+    def module_exists(self, module: str) -> bool:
+        if self.has_container:
+            return bool(self.quietly_execute(["pip3", "list", "|", "grep", module]))
+
+    def pip_install(self, module: str) -> None:
+        if self.has_container:
+            if not self.module_exists(module):
+                self.execute(["pip3", "install", module, "-y"])
+            else:
+                self.execute(["pip3", "install", module, "--upgrade"])
+
+    @property
+    def has_container(self) -> bool:
+        return self.lxd.container_exists(self.name)
+
+    @property
+    def ip(self) -> str:
+        if self.has_container:
+            return self.lxd.get_ip_address(self.name)
+
+    def restart(self) -> None:
+        self.lxd.restart(self.name, True)
+
+    def execute(self, command: list[str]) -> str:
+        if isinstance(command, str):
+            command = command.split(" ")
+        return self.lxd.execute(self.name, command).stdout
+
+    def try_execute(self, command: list[str]) -> str:
+        try:
+            return self.execute(command)
+        except Exception as e:
+            LOG.warning(e)
+
+    def quietly_execute(self, command: list[str]) -> str:
+        return self.lxd.quietly_execute(self.name, command).stdout
+
+    def absolute_file_path(self, file_path: str) -> str:
+        if file_path[0] == "~":
+            file_path = f"{self.base_filepath}{file_path[1:]}"
+        return file_path
+
+    def abs_fp(self, file_path: str) -> str:
+        return self.absolute_file_path(file_path)
+
+    def file_exists(self, file_path: str) -> bool:
+        return self.lxd.file_exists(self.name, self.absolute_file_path(file_path))
+
+    def get_file_contents(self, file_path: str) -> str:
+        if file_path[0] == "~":
+            file_path = f"{self.base_filepath}{file_path[1:]}"
+        return self.lxd.get_file_contents(self.name, self.absolute_file_path(file_path))
+
+    def push_text_file(
+        self,
+        content: str,
+        target_file: str,
+        uid: int = 0,
+        gid: int = 0,
+    ) -> None:
+        self.lxd.push_text_file(
+            self.name, content, self.absolute_file_path(target_file), uid, gid
+        )
+
+    def append_to_file(self, content: str, file_path: str) -> None:
+        file_content = (
+            self.get_file_contents(file_path).split("\n")
+            if self.file_exists(file_path)
+            else []
+        )
+        if file_content[-1] == "":
+            file_content = file_content[:-1]
+        file_content.extend(
+            content.split("\n") if isinstance(content, str) else content
+        )
+        self.push_text_file("\n".join(file_content), file_path)
+
+    def update_config(
+        self, config: dict[str, str], role: Optional[str] = None
+    ) -> dict[str, str]:
+        if role:
+            if role not in self.roles:
+                raise MissingRoleOnHost()
+            self.roles[role] |= config
+            return self.roles[role]
+        self.config |= config
+        return self.config
+
+    def fetch_config(self, config_key: str, role: Optional[str] = None) -> str:
+        if role:
+            if role not in self.roles:
+                raise MissingRoleOnHost()
+            role_conf = self.roles.get(role)
+            if config_key in role_conf:
+                return role_conf.get(config_key)
+        return self.config.get(config_key)
+
+
+class AnsibleHost(AnsibleShared):
+    roles = {}
+
+    def __init__(
+        self,
+        name: str,
+        image: str,
+        user_data: dict[str, str],
+        profile: str,
+    ) -> None:
+        self.name = name
+        self.image = image
+        self.user_data = user_data
+        self.profile = profile
+        super().__init__(image.split(":")[0])
+        self.config["ansible_user"] = self.user
+
+    def role_setup(self, role: str) -> list[str]:
+        return [f"{k}={v}" for k, v in self.roles.get(role, {}).items()]
+
+    def host_setup(self, role: Optional[str] = None) -> str:
+        cfg = [f"{k}={v}" for k, v in self.config.items()]
+        if role:
+            cfg += self.role_setup(role)
+        return " ".join([f"{self.ip}"] + cfg)
+
+    def add_test_db(self):
+        self.update_config({"maas_postgres_uri": "maas-test-db:///"})
+        if self.has_container:
+            self.execute("snap install maas-test-db")
+        return self
+
+    def _add_role_(self, role: str, config: dict[str, str]) -> None:
+        self.roles[role] = config | {"ansible_user": self.user}
+
+    def _remove_role_(self, role: str) -> None:
+        self.roles.pop(role)
+
+    def has_role(self, role: str) -> bool:
+        return self.has_container and role in self.roles
+
+    @property
+    def region(self) -> MAASRegionExtension:
+        if self.has_region:
+            url = self.fetch_config("maas_url", "maas_region_controller")
+            snap_install = (
+                self.fetch_config("maas_installation_type", "maas_region_controller")
+                == "snap",
+            )
+            return MAASRegionExtension(url, url, self.name, snap_install)
+
+    @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"
+        )
+
+    def add_rack(self, config: dict[str, str] = {}):
+        self._add_role_("maas_rack_controller", config)
+        return self
+
+    def add_region(self, config: dict[str, str] = {}):
+        self._add_role_("maas_region_controller", config)
+        return self
+
+    def add_region_rack(self, config: dict[str, str] = {}):
+        self._add_role_("maas_rack_controller", config)
+        self._add_role_("maas_region_controller", 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")
+
+
+class AnsibleMain(AnsibleShared):
+    _inventory_ = set()
+    ssh_key_file = "/home/ubuntu/.ssh/id_rsa.pub"
+
+    def __init__(self, name: str) -> None:
+        self.name = name
+        super().__init__()
+        self.apt_install("python3")
+        self.apt_install("python3-pip")
+        self.pip_install("ansible")
+        self.clone_repo("maas", "maas-ansible-playbook")
+        assert self.public_ssh_key
+
+    def clone_repo(self, user: str, repo: str):
+        repo_path = f"{self.base_filepath}/{repo}"
+        if not self.lxd.file_exists(self.name, repo_path):
+            self.execute(
+                [
+                    "git",
+                    "clone",
+                    f"https://github.com/{user}/{repo}.git";,
+                    f"{repo_path}",
+                ]
+            )
+
+    @property
+    def public_ssh_key(self) -> str:
+        if not self.file_exists(self.ssh_key_file):
+            self.execute(["ssh-keygen", "-t", "rsa", "-f", self.ssh_key_file[:-4]])
+        key = self.execute(["cat", self.ssh_key_file])
+        self.execute(["chmod", "600", self.ssh_key_file.replace(".pub", "")])
+        self.execute(["chmod", "600", self.ssh_key_file])
+        return " ".join(key.split()[:2])
+
+    def add_key_to_host(self, host: AnsibleHost, ssh_key_file: Optional[str] = None):
+        self.try_execute(
+            ["ssh-keygen", "-f", self.abs_fp("~/.ssh/known_hosts"), "-R", host.ip]
+        )
+        host.append_to_file(
+            self.public_ssh_key, f"{host.abs_fp('~/.ssh/authorized_keys')}"
+        )
+        self.try_execute(
+            [
+                "ssh",
+                "-i",
+                self.ssh_key_file,
+                "-o",
+                "StrictHostKeyChecking=no",
+                f"{host.user}@{host.ip}",
+            ]
+        )
+        self.execute(
+            [
+                "ssh-copy-id",
+                "-i",
+                self.ssh_key_file,
+                f"{host.user}@{host.ip}",
+            ]
+        )
+
+    @contextmanager
+    def collect_inventory(self) -> list[str]:
+        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,
+                )
+            self.lxd.create_container(
+                host.name,
+                host.image,
+                f"ssh_authorized_keys:\n- {self.public_ssh_key}",
+            )
+            if host.config.get("maas_postgres_uri", "") == "maas-test-db:///":
+                host.add_test_db()
+            self.add_key_to_host(host)
+            self._inventory_.add(host)
+            hosts.add(host)
+        yield hosts
+        for host in hosts:
+            self.remove_host(host, delete=True)
+
+    def _make_host_name_(self) -> str:
+        """Determine the smallest number not yet used as a name"""
+        name_scheme = "ansible-host"
+        nums = set(
+            [
+                int(re.search(r"\d+", host.name).group())
+                for host in self._inventory_
+                if re.match(rf"{name_scheme}-\d+", host.name)
+            ]
+        )
+        if nums:
+            return f"{name_scheme}-{min(set(range(1, max(nums)+2)) - nums)}"
+        return f"{name_scheme}-1"
+
+    def add_host(
+        self,
+        name: Optional[str] = None,
+        image: Optional[str] = "ubuntu:focal",
+        user_data: Optional[dict[str, str]] = {},
+        profile: Optional[str] = None,
+    ) -> AnsibleHost:
+        host = AnsibleHost(
+            f"{name if name else self._make_host_name_()}",
+            image,
+            user_data,
+            profile,
+        )
+        self._inventory_.add(host)
+        return host
+
+    def remove_host(self, host: AnsibleHost, delete: Optional[bool] = False) -> None:
+        self._inventory_.discard(host)
+        if delete:
+            self.lxd.delete(host.name)
+
+    def create_hosts_file(self) -> None:
+        inventory, inv = {}, []
+        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:
+                inventory[role] = inventory.get(role, []) + [host]
+        for role, hosts in inventory.items():
+            inv.append(f"[{role}]")
+            inv.extend([host.host_setup(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",
+                    "",
+                ]
+            )
+        inventory_content = "\n".join(inv)
+        self.push_text_file(inventory_content, self.host_file)
+
+    def create_config_file(self) -> None:
+        path = "~/maas-ansible-playbook/ansible.cfg"
+        if not self.file_exists(path):
+            self.push_text_file("[defaults]\ninventory = hosts", path)
+        if "host_key_checking" not in self.get_file_contents(path):
+            self.append_to_file(["host_key_checking = False"], path)
+        if "remote_user" not in self.get_file_contents(path):
+            self.append_to_file(["remote_user = ubuntu"], path)
+        self.execute("mkdir -p /etc/ansible")
+        self.push_text_file(self.get_file_contents(path), "/etc/ansible/ansible.cfg")
+
+    def run_playbook(
+        self, playbook: Optional[str] = "site.yaml", debug: Optional[str] = "-v"
+    ) -> None:
+        self.create_hosts_file()
+        self.create_config_file()
+        cmd = [
+            "ansible-playbook",
+            f"{self.base_filepath}/maas-ansible-playbook/{playbook}",
+            "-i",
+            self.host_file,
+            "--private-key",
+            self.ssh_key_file[:-4],
+        ]
+        debug = re.match(r"-(v)+", debug).group()
+        if debug:
+            cmd.append(debug)
+        if self.config:
+            cfg = " ".join([f"{k}={v}" for k, v in self.config.items()])
+            cmd.append("--extra-vars")
+            cmd.append(f'"{cfg}"')
+        self.execute(cmd)
+
+
+@pytest.fixture(scope="session")
+def ansible_main() -> AnsibleMain:
+    """Set up a new LXD container with ansible installed."""
+    container_name = "ansible-main"
+    lxd = get_lxd(LOG)
+    lxd.get_or_create(container_name, "ubuntu:focal")
+    main = AnsibleMain(container_name)
+    yield main
+    for host in main._inventory_:
+        if host.has_container:
+            lxd.delete(host.name)
+    lxd.delete(container_name)
diff --git a/systemtests/ansible_tests/__init__.py b/systemtests/ansible_tests/__init__.py
new file mode 100644
index 0000000..7c571b3
--- /dev/null
+++ b/systemtests/ansible_tests/__init__.py
@@ -0,0 +1,5 @@
+"""
+Prepares a container running ansible, and clones maas/maas-ansible-playbooks,
+to test their MAAS topolgy 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..74209e5
--- /dev/null
+++ b/systemtests/ansible_tests/test_ansible.py
@@ -0,0 +1,82 @@
+import pytest
+
+from systemtests.ansible import ansible_main
+
+
+@pytest.mark.usefixtures("ansible_main")
+class TestConfigSetup:
+    def test_setup_ansible_main(self, ansible_main: ansible_main) -> None:
+        assert ansible_main.module_exists("ansible")
+        assert ansible_main.lxd.file_exists(
+            ansible_main.name,
+            f"/home/{ansible_main.user}/maas-ansible-playbook/README.md",
+        )
+
+    def test_setup_maas_region(self, ansible_main: ansible_main) -> None:
+        host = ansible_main.add_host().add_region(
+            {
+                "maas_version": "3.2",
+            }
+        )
+        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_region_controller"
+            )
+
+            config = host.roles.get("maas_region_controller")
+            assert config == {
+                "ansible_user": "ubuntu",
+                "maas_version": "3.2",
+                "maas_url": f"http://{host.ip}:5240/MAAS";,
+            }
+        assert not ansible_main._inventory_
+        assert not host.has_container
+
+
+@pytest.mark.usefixtures("ansible_main")
+class TestAnsibleMAAS:
+    def test_maas_region_installed(self, ansible_main: ansible_main) -> None:
+        host = (
+            ansible_main.add_host()
+            .add_test_db()
+            .add_region_rack({"maas_version": "3.2", "maas_installation_type": "snap"})
+        )
+        with ansible_main.collect_inventory():
+            ansible_main.update_config({"maas_url": f"http://{host.ip}:5240/MAAS"})
+            ansible_main.run_playbook("site.yaml")
+            maas_region = host.region
+            maas_region.login("admin")
+            assert maas_region.user_exists("admin")
+            assert maas_region.version
+
+    def test_maas_region_updated(self, ansible_main: ansible_main) -> None:
+        start_version = "3.0"
+        upgrade_version = "3.2"
+        host = (
+            ansible_main.add_host()
+            .add_test_db()
+            .add_region_rack(
+                {"maas_version": start_version, "maas_installation_type": "snap"}
+            )
+        )
+        with ansible_main.collect_inventory():
+            ansible_main.update_config({"maas_url": f"http://{host.ip}:5240/MAAS"})
+            ansible_main.run_playbook("site.yaml")
+            host.region.login("admin")
+            assert host.region.version[:3] == start_version
+
+            ansible_main.run_playbook("site.yaml")
+            assert host.region.version[:3] == upgrade_version
+
+    # def test_maas_region_tests_pass(self, ansible_main: ansible_main) -> None:
+    #     host = ansible_main.add_host().add_region()
+    #     with ansible_main.collect_inventory():
+    #         ansible_main.update_config(
+    #             {
+    #                 "maas_url": f"http://{host.ip}:5240/MAAS";,
+    #             }
+    #         )
+    #         ansible_main.run_playbook("site.yaml")
+    #         assert host.region
diff --git a/tox.ini b/tox.ini
index aa171e3..cb72bf4 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]
@@ -55,6 +55,11 @@ commands=
 #    passenv = {{[base]passenv}}
 #    """)
 #]]]
+
+[testenv:{opelt,stunky,vm1}]
+description=Per-machine tests for {envname}
+passenv = {[base]passenv}
+
 #[[[end]]]
 
 [testenv:format]
-- 
Mailing list: https://launchpad.net/~sts-sponsors
Post to     : sts-sponsors@lists.launchpad.net
Unsubscribe : https://launchpad.net/~sts-sponsors
More help   : https://help.launchpad.net/ListHelp

Reply via email to