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