sts-sponsors team mailing list archive
-
sts-sponsors team
-
Mailing list archive
-
Message #05008
[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