From 8ba0366c3fa2ef9055b6a002e3c711248930a89d Mon Sep 17 00:00:00 2001 From: Paul Larson Date: Thu, 9 Jan 2025 08:23:46 -0600 Subject: [PATCH] Unified agent host charm (#437) * Use config options instead of resource files for ssh keys (#328) * Shared agent code location (#329) * Move run_with_logged_errors out of charm.py * Update testflinger source and add action for it * Add integration tests and docs for the update-testflinger action * cleanup * Add update-configs action (#337) * Write out the supervisord configs for all agents (#351) * Write out the supervisord configs for all agents * Update the supervisor services when configs/code is updated * Update README for the agent charm with instructions for test/debug * redirect stderr for all supervisord agents * Fix charm to expect base64 encoded ssh keys as already documented * important fix - reservations won't work without this Any script (like ssh-copy-id) which uses $HOME, or ~ or anything like that won't work without this. Although supervisord execs the process as the specified user, there's no shell. So we need to set certain vars like this in the supervisord config. * Also install the device-connector so that things like reserve work * Rename ssh config options for better consistency * Update testflinger-agent-host-charm README * Update tf-cmd-scripts to work with the new agent-host charm (#355) * Add a config option with a sane default for the agent host ssh config (#370) * Add agent-host terraform module (#362) * Add agent-host terraform module * Separate variables from main.tf * Agent host terraform constraints (#387) * Fix some formatting issues in the README.md * Add variables to build constraints to the agent-host terraform module * Update tf-cleanup for this branch to keep current with PR#382 that was just merged (#405) * Remove vars no longer used from tf-cleanup * Unit/Integration test fixes for testflinger-agent-host-charm --- .../testflinger-agent-host-charm/README.md | 135 ++++++++- .../testflinger-agent-host-charm/actions.yaml | 4 + .../testflinger-agent-host-charm/config.yaml | 29 ++ .../metadata.yaml | 9 - .../requirements.txt | 2 + .../testflinger-agent-host-charm/src/charm.py | 278 ++++++++++++++---- .../src/common.py | 17 ++ .../src/defaults.py | 8 + .../src/testflinger_source.py | 76 +++++ .../src/tf-cmd-scripts/tf-allocate | 11 +- .../src/tf-cmd-scripts/tf-firmware-update | 11 +- .../src/tf-cmd-scripts/tf-provision | 11 +- .../src/tf-cmd-scripts/tf-reserve | 11 +- .../src/tf-cmd-scripts/tf-setup | 7 +- .../src/tf-cmd-scripts/tf-test | 17 +- .../testflinger-agent.supervisord.conf.j2 | 5 + .../data/test01/agent001/default.yaml | 2 + .../test01/agent001/testflinger-agent.conf | 10 + .../data/test02/agent001/default.yaml | 2 + .../test02/agent001/testflinger-agent.conf | 10 + .../data/test02/agent002/default.yaml | 2 + .../test02/agent002/testflinger-agent.conf | 10 + .../integration/test_charm_integration.py | 143 +++++++++ .../tests/test_charm.py | 73 ----- .../tests/unit/test_charm.py | 105 +++++++ .../tests/unit/test_testflinger_source.py | 55 ++++ agent/terraform/README.md | 79 +++++ agent/terraform/locals.tf | 3 + agent/terraform/main.tf | 22 ++ agent/terraform/variables.tf | 60 ++++ agent/terraform/versions.tf | 9 + agent/tox.ini | 14 +- 32 files changed, 1078 insertions(+), 152 deletions(-) create mode 100644 agent/charms/testflinger-agent-host-charm/actions.yaml create mode 100644 agent/charms/testflinger-agent-host-charm/config.yaml create mode 100644 agent/charms/testflinger-agent-host-charm/src/common.py create mode 100644 agent/charms/testflinger-agent-host-charm/src/defaults.py create mode 100644 agent/charms/testflinger-agent-host-charm/src/testflinger_source.py create mode 100644 agent/charms/testflinger-agent-host-charm/templates/testflinger-agent.supervisord.conf.j2 create mode 100644 agent/charms/testflinger-agent-host-charm/tests/integration/data/test01/agent001/default.yaml create mode 100644 agent/charms/testflinger-agent-host-charm/tests/integration/data/test01/agent001/testflinger-agent.conf create mode 100644 agent/charms/testflinger-agent-host-charm/tests/integration/data/test02/agent001/default.yaml create mode 100644 agent/charms/testflinger-agent-host-charm/tests/integration/data/test02/agent001/testflinger-agent.conf create mode 100644 agent/charms/testflinger-agent-host-charm/tests/integration/data/test02/agent002/default.yaml create mode 100644 agent/charms/testflinger-agent-host-charm/tests/integration/data/test02/agent002/testflinger-agent.conf create mode 100644 agent/charms/testflinger-agent-host-charm/tests/integration/test_charm_integration.py delete mode 100644 agent/charms/testflinger-agent-host-charm/tests/test_charm.py create mode 100644 agent/charms/testflinger-agent-host-charm/tests/unit/test_charm.py create mode 100644 agent/charms/testflinger-agent-host-charm/tests/unit/test_testflinger_source.py create mode 100644 agent/terraform/README.md create mode 100644 agent/terraform/locals.tf create mode 100644 agent/terraform/main.tf create mode 100644 agent/terraform/variables.tf create mode 100644 agent/terraform/versions.tf diff --git a/agent/charms/testflinger-agent-host-charm/README.md b/agent/charms/testflinger-agent-host-charm/README.md index 397fd419..bfdb9e11 100644 --- a/agent/charms/testflinger-agent-host-charm/README.md +++ b/agent/charms/testflinger-agent-host-charm/README.md @@ -1,4 +1,6 @@ -# Overview +# Testflinger Agent Host Charm + +## Overview This charm provides the base system for a host system that will be used for testflinger device agents. It installs the base dependencies and provides a @@ -7,17 +9,138 @@ target for deploying the "testflinger-agent" along with in `src/tf-cmd-scripts/` to the host system. The scripts would be used by the testflinger-agent to trigger the testflinger-device-connector at each phase. -# Building +## Building To build this charm, first install charmcraft (`sudo snap install --classic charmcraft`) then run `charmcraft pack` -# Configuration +## Testing +This charm includes both unit and integration tests. The integration tests +take some time to run and require setting up juju in your test environment +as described [here](https://juju.is/docs/sdk/dev-setup#heading--manual-set-up-juju). + +Once you've done that, you can run all the unit and integration tests by +going up 2 directories from here to the `agent` directory, and running: + +``` +$ tox -e charm +``` + +For debugging, it can be useful to keep the model that was deployed so that +it can be reused. To do this, you can add a few additional arguments to tox: +``` +$ tox -e charm -v -- --model=testmodel --keep-models +``` + +This will create a model called `testmodel` and keep it after the run is +complete. If you want to reuse it without deploying again, you can +run the same command again with `--no-deploy` at the end. + +## Configuration Supported options for this charm are: - - ssh-priv-key: + - ssh-private-key: base64 encoded ssh private keyfile - - ssh-pub-key: + - ssh-public-key: base64 encoded ssh public keyfile + - ssh-config: + config data to write as ~/.ssh/config + - config-repo: + Git repo containing device agent config data + - config-branch: + Git branch to pull for the config data + - config-dir: + Path from the root of the config repo where the directories and configs are located for this agent host To keep the tf-cmd-scripts files up-to-date, run `juju upgrade-charm -{testflinger-agent-host-application}`. \ No newline at end of file +{testflinger-agent-host-application}`. + +The config-repo where the Testflinger configs for the agents are stored should +contain a directory tree. The leaf directories should be named the same as the +agent-id configured for the agent in the `testflinger-agent.conf` file, and +this directory should contain the config files for both the agent and the +device connector. For example, consider a repo containing the configs for +multiple locations and agents. It might have a structure like this: +``` +/ +- lab1/ + - agent-101/ + - testflinger-agent.conf + - default.yaml + - agent-102/ + - testflinger-agent.conf + - default.yaml + - ... +- lab2/ + - agent-201/ + - testflinger-agent.conf + - default.yaml + - ... +... +``` + +In order to make the charm consider only the agents under the `lab1` directory, +you should set the config-dir to `lab1`. + +## Actions +The following actions are supported for this charm: + + - update-testflinger: + This action is used to update the testflinger-agent and install it to a + location shared by all the agents running on this host. + This action will trigger all running agents to restart when they are not running a job. + - update-configs: + This action pulls the git repo set in the charm config to update the + agent configs on the agent host. + This action will run `supervisorctl update` to force start new agents, and stop any agents + that are no longer configured. This *could* also force restart any agents, if the + supervisord config for that agent has changed. This should not normally happen. This action + will also trigger all running agents to restart when they are not running a job in case the + testflinger config has changed. + +## Operational Notes + +### Using supervisorctl on the agent host to check status + +The agent host is configured to use supervisorctl to manage the agents. From the agent host, +you can run `sudo supervisorctl status` to see the status of all the agents configured on it. + +### Viewing the agent logs +To show the logs for a specific agent, run `sudo supervisorctl tail `. +You can also use the `-f` option to follow the logs. +To show the logs for supervisorctl itself, to see what it's recently started, stopped, or +signalled, you can use `supervisorctl maintail`. + +### Stopping and restarting agents +You can use `sudo supervisorctl stop ` to stop a specific agent. +Be aware that other actions on the charm such as `update-configs` might later cause this to +restart the agents. In order to mark it offline so that it will no longer process jobs, you may +want to either remove it completely from the configs, or set a disable marker file using +`touch /tmp/TESTFLINGER-DEVICE-OFFLINE-` instead. + +To signal an agent to safely restart when it's no longer running a job, you can run +`sudo supervisorctl signal USR1 `. + +### Updating agent configs + +If the agent configs in the config-repo have changed, you can run +`juju run testflinger-agent-host-application/0 update-configs` (use `run-action` for juju < 3.x) +to update the agent configs on the agent host. This will automatically add any new agents, +remove any agents that are no longer configured, and all agents will be sent a signal to restart +when they are not running a job. + +### Updating the Testflinger repo + +When the code for the agent or device connectors changes, you can run +`juju run testflinger-agent-host-application/0 update-testflinger` to update the Testflinger +repo on the agent host. This will automatically trigger a safe restart on all the agents after +installing the new code. + +### Changes to the supervisord configs + +Be aware that if you change anything in the supervisord configs under /etc/supervisor/conf.d, +the agents will be **forced** to restart without waiting for running jobs to terminate the next +time you run the `update-configs` action or perform a configuration change in the charm. This +is because it runs `supervisorctl update`. This should not normally happen, but if for some +reason you need to make a change to the charm that causes it to write changes to the supervisord +configs, then you should take precautions to ensure that the agents are not running jobs when +`supervisord update` is run the next time. diff --git a/agent/charms/testflinger-agent-host-charm/actions.yaml b/agent/charms/testflinger-agent-host-charm/actions.yaml new file mode 100644 index 00000000..4a5ecdeb --- /dev/null +++ b/agent/charms/testflinger-agent-host-charm/actions.yaml @@ -0,0 +1,4 @@ +update-configs: + description: Update Testflinger agent configs +update-testflinger: + description: Update Testflinger agent code diff --git a/agent/charms/testflinger-agent-host-charm/config.yaml b/agent/charms/testflinger-agent-host-charm/config.yaml new file mode 100644 index 00000000..8391e19c --- /dev/null +++ b/agent/charms/testflinger-agent-host-charm/config.yaml @@ -0,0 +1,29 @@ +options: + config-repo: + type: string + description: Git repo containing device agent config data + default: "" + config-branch: + type: string + description: Git branch to pull for the config data + default: "main" + config-dir: + type: string + description: Path from the root of the config repo where the directories and configs are located for this agent host + default: "" + ssh-private-key: + type: string + description: ssh private key for connecting to local test devices + default: "" + ssh-public-key: + type: string + description: ssh public key for connecting to local test devices + default: "" + ssh-config: + type: string + description: ssh config for connecting to local test devices + default: | + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel QUIET + ConnectTimeout 30 diff --git a/agent/charms/testflinger-agent-host-charm/metadata.yaml b/agent/charms/testflinger-agent-host-charm/metadata.yaml index 63b62bab..bdf813d3 100644 --- a/agent/charms/testflinger-agent-host-charm/metadata.yaml +++ b/agent/charms/testflinger-agent-host-charm/metadata.yaml @@ -10,12 +10,3 @@ description: | This charm provides support for device agents running in the hwcert lab summary: | Host system for device agents -resources: - ssh_priv_key: - type: file - filename: id_rsa - description: SSH private key - ssh_pub_key: - type: file - filename: id_rsa.pub - description: SSH public key diff --git a/agent/charms/testflinger-agent-host-charm/requirements.txt b/agent/charms/testflinger-agent-host-charm/requirements.txt index 96faf889..99a12ec2 100644 --- a/agent/charms/testflinger-agent-host-charm/requirements.txt +++ b/agent/charms/testflinger-agent-host-charm/requirements.txt @@ -1 +1,3 @@ ops >= 1.4.0 +GitPython==3.1.43 +Jinja2==3.1.4 diff --git a/agent/charms/testflinger-agent-host-charm/src/charm.py b/agent/charms/testflinger-agent-host-charm/src/charm.py index 993338a3..5ae3ae30 100755 --- a/agent/charms/testflinger-agent-host-charm/src/charm.py +++ b/agent/charms/testflinger-agent-host-charm/src/charm.py @@ -6,14 +6,17 @@ import logging -import subprocess -import shutil import os -from pathlib import PosixPath +import shutil +import sys +from base64 import b64decode +from pathlib import Path +from common import run_with_logged_errors +from git import Repo, GitCommandError +from jinja2 import Template from charms.operator_libs_linux.v0 import apt from ops.charm import CharmBase -from ops.framework import StoredState from ops.main import main from ops.model import ( ActiveStatus, @@ -21,6 +24,12 @@ MaintenanceStatus, ModelError, ) +import testflinger_source +from defaults import ( + AGENT_CONFIGS_PATH, + LOCAL_TESTFLINGER_PATH, + VIRTUAL_ENV_PATH, +) logger = logging.getLogger(__name__) @@ -28,67 +37,211 @@ class TestflingerAgentHostCharm(CharmBase): """Base charm for testflinger agent host systems""" - _stored = StoredState() - def __init__(self, *args): super().__init__(*args) self.framework.observe(self.on.install, self.on_install) self.framework.observe(self.on.start, self.on_start) self.framework.observe(self.on.config_changed, self.on_config_changed) self.framework.observe(self.on.upgrade_charm, self.on_upgrade_charm) - self._stored.set_default( - ssh_priv="", - ssh_pub="", + self.framework.observe( + self.on.update_configs_action, + self.on_update_configs_action, + ) + self.framework.observe( + self.on.update_testflinger_action, + self.on_update_testflinger_action, ) def on_install(self, _): """Install hook""" - self.unit.status = MaintenanceStatus("Installing dependencies") - self.install_apt_packages( - ["python3-pip", "python3-virtualenv", "docker.io"] - ) - # maas cli comes from maas snap now - self.run_with_logged_errors(["snap", "install", "maas"]) + self.install_dependencies() self.setup_docker() self.update_tf_cmd_scripts() + self.update_testflinger_repo() + try: + self.update_config_files() + except ValueError: + self.unit.status = BlockedStatus( + "config-repo and config-dir must be set" + ) + return - def setup_docker(self): - self.run_with_logged_errors(["groupadd", "docker"]) - self.run_with_logged_errors(["gpasswd", "-a", "ubuntu", "docker"]) + def install_dependencies(self): + """Install the packages needed for the agent""" + self.unit.status = MaintenanceStatus("Installing dependencies") + # maas cli comes from maas snap now + run_with_logged_errors(["snap", "install", "maas"]) - def run_with_logged_errors(self, cmd): - """Run a command, log output if errors, return proc just in case""" - proc = subprocess.run( - cmd, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, text=True + self.install_apt_packages( + [ + "python3-pip", + "python3-virtualenv", + "docker.io", + "git", + "openssh-client", + "sshpass", + "snmp", + "supervisor", + ] ) - if proc.returncode: - logger.error(proc.stdout) - return proc + + def update_testflinger_repo(self): + """Update the testflinger repo""" + self.unit.status = MaintenanceStatus("Creating virtualenv") + testflinger_source.create_virtualenv() + self.unit.status = MaintenanceStatus("Cloning testflinger repo") + testflinger_source.clone_repo(LOCAL_TESTFLINGER_PATH) + + def update_config_files(self): + """ + Clone the config files from the repo and swap it in for whatever is + in AGENT_CONFIGS_PATH + """ + config_repo = self.config.get("config-repo") + config_dir = self.config.get("config-dir") + config_branch = self.config.get("config-branch") + if not config_repo or not config_dir: + logger.error("config-repo and config-dir must be set") + raise ValueError("config-repo and config-dir must be set") + tmp_repo_path = Path("/srv/tmp-agent-configs") + repo_path = Path(AGENT_CONFIGS_PATH) + if tmp_repo_path.exists(): + shutil.rmtree(tmp_repo_path, ignore_errors=True) + try: + Repo.clone_from( + url=config_repo, + branch=config_branch, + to_path=tmp_repo_path, + depth=1, + ) + except GitCommandError: + logger.exception("Failed to update config files") + self.unit.status = BlockedStatus( + "Failed to update or config files" + ) + sys.exit(1) + + if repo_path.exists(): + shutil.rmtree(repo_path, ignore_errors=True) + shutil.move(tmp_repo_path, repo_path) + + def write_supervisor_service_files(self): + """ + Generate supervisord service files for all agents + + We assume that the path pointed to by the config-dir config option + contains a directory for each agent that needs to run from this host. + The agent directory name will be used as the service name. + """ + + config_dirs = Path(AGENT_CONFIGS_PATH) / self.config.get("config-dir") + + if not config_dirs.is_dir(): + logger.error("config-dir must point to a directory") + self.unit.status = BlockedStatus( + "config-dir must point to a directory" + ) + sys.exit(1) + + agent_dirs = [dir for dir in config_dirs.iterdir() if dir.is_dir()] + if not agent_dirs: + logger.error("No agent directories found in config-dirs") + self.unit.status = BlockedStatus( + "No agent directories found in config-dirs" + ) + sys.exit(1) + + # Remove all the old service files in case agents have been removed + for conf_file in os.listdir("/etc/supervisor/conf.d"): + if conf_file.endswith(".conf"): + os.unlink(f"/etc/supervisor/conf.d/{conf_file}") + + # now write the supervisord service files + with open( + "templates/testflinger-agent.supervisord.conf.j2", "r" + ) as service_template: + template = Template(service_template.read()) + for agent_dir in agent_dirs: + agent_config_path = agent_dir + rendered = template.render( + agent_name=agent_dir.name, + agent_config_path=agent_config_path, + virtual_env_path=VIRTUAL_ENV_PATH, + ) + with open( + f"/etc/supervisor/conf.d/{agent_dir.name}.conf", "w" + ) as agent_file: + agent_file.write(rendered) + + def supervisor_update(self): + """ + Once supervisord service files have been written, new agents will be + automatically started, and missing agents will be removed by running + `supervisorctl update`. This only applies to supervisor conf files + that have changed. So any agents for which the conf file has not + changed will be unaffected. + """ + run_with_logged_errors(["supervisorctl", "update"]) + + def restart_agents(self): + """ + Mark all agents as needing a restart when they are not running a job + so that they read any updated config files and run the latest + version of the agent code. + """ + run_with_logged_errors(["supervisorctl", "signal", "USR1", "all"]) + + def setup_docker(self): + run_with_logged_errors(["groupadd", "docker"]) + run_with_logged_errors(["gpasswd", "-a", "ubuntu", "docker"]) def write_file(self, location, contents): - # Sanity check to make sure we're actually about to write something - if not contents: - return with open(location, "w", encoding="utf-8", errors="ignore") as out: out.write(contents) def copy_ssh_keys(self): - priv_key = self.read_resource("ssh_priv_key") - if self._stored.ssh_priv != priv_key: - self._stored.ssh_priv = priv_key - self.write_file("/home/ubuntu/.ssh/id_rsa", priv_key) - pub_key = self.read_resource("ssh_pub_key") - if self._stored.ssh_pub != pub_key: - self._stored.ssh_pub = pub_key - self.write_file("/home/ubuntu/.ssh/id_rsa.pub", pub_key) + try: + ssh_config = self.config.get("ssh-config") + self.write_file("/home/ubuntu/.ssh/config", ssh_config) + os.chown("/home/ubuntu/.ssh/config", 1000, 1000) + os.chmod("/home/ubuntu/.ssh/config", 0o640) + + priv_key = self.config.get("ssh-private-key", "") + self.write_file( + "/home/ubuntu/.ssh/id_rsa", b64decode(priv_key).decode() + ) + os.chown("/home/ubuntu/.ssh/id_rsa", 1000, 1000) + os.chmod("/home/ubuntu/.ssh/id_rsa", 0o600) + + pub_key = self.config.get("ssh-public-key", "") + self.write_file( + "/home/ubuntu/.ssh/id_rsa.pub", b64decode(pub_key).decode() + ) + os.chown("/home/ubuntu/.ssh/id_rsa.pub", 1000, 1000) + except (TypeError, UnicodeDecodeError): + logger.error( + "Failed to decode ssh keys - ensure they are base64 encoded" + ) + raise def update_tf_cmd_scripts(self): """Update tf-cmd-scripts""" + self.unit.status = MaintenanceStatus("Installing tf-cmd-scripts") tf_cmd_dir = "src/tf-cmd-scripts/" - usr_local_bin = "/usr/local/bin/" + usr_local_bin = Path("/usr/local/bin") + for tf_cmd_file in os.listdir(tf_cmd_dir): - shutil.copy(os.path.join(tf_cmd_dir, tf_cmd_file), usr_local_bin) - os.chmod(os.path.join(usr_local_bin, tf_cmd_file), 0o775) + template = Template( + open(os.path.join(tf_cmd_dir, tf_cmd_file)).read() + ) + rendered = template.render( + agent_configs_path=AGENT_CONFIGS_PATH, + config_dir=self.config.get("config-dir"), + virtual_env_path=VIRTUAL_ENV_PATH, + ) + agent_file = usr_local_bin / tf_cmd_file + agent_file.write_text(rendered) + agent_file.chmod(0o775) def on_upgrade_charm(self, _): """Upgrade hook""" @@ -102,7 +255,17 @@ def on_start(self, _): def on_config_changed(self, _): self.unit.status = MaintenanceStatus("Handling config_changed hook") + try: + self.update_config_files() + except ValueError: + self.unit.status = BlockedStatus( + "config-repo and config-dir must be set" + ) + return self.copy_ssh_keys() + self.write_supervisor_service_files() + self.supervisor_update() + self.restart_agents() self.unit.status = ActiveStatus() def install_apt_packages(self, packages: list): @@ -119,22 +282,29 @@ def install_apt_packages(self, packages: list): logger.error("could not install package") self.unit.status = BlockedStatus("Failed to install packages") - def read_resource(self, resource): - """Read the specified resource and return the contents""" + def on_update_testflinger_action(self, event): + """Update Testflinger agent code""" + self.unit.status = MaintenanceStatus("Updating Testflinger Agent Code") + self.update_testflinger_repo() + self.restart_agents() + self.unit.status = ActiveStatus() + + def on_update_configs_action(self, event): + """Update agent configs""" + self.unit.status = MaintenanceStatus( + "Updating Testflinger Agent Configs" + ) try: - resource_file = self.model.resources.fetch(resource) - except ModelError: - # resource doesn't exist yet, return empty string - return "" - if ( - not isinstance(resource_file, PosixPath) - or not resource_file.exists() - ): - # Return empty string if it's invalid - return "" - with open(resource_file, encoding="utf-8", errors="ignore") as res: - contents = res.read() - return contents + self.update_config_files() + except ValueError: + self.unit.status = BlockedStatus( + "config-repo and config-dir must be set" + ) + return + self.write_supervisor_service_files() + self.supervisor_update() + self.restart_agents() + self.unit.status = ActiveStatus() if __name__ == "__main__": diff --git a/agent/charms/testflinger-agent-host-charm/src/common.py b/agent/charms/testflinger-agent-host-charm/src/common.py new file mode 100644 index 00000000..ed7ac7b3 --- /dev/null +++ b/agent/charms/testflinger-agent-host-charm/src/common.py @@ -0,0 +1,17 @@ +# Copyright 2024 Canonical +# See LICENSE file for licensing details. + +import logging +import subprocess + +logger = logging.getLogger(__name__) + + +def run_with_logged_errors(cmd: list) -> int: + """Run a command, log output if errors, return exit code""" + proc = subprocess.run( + cmd, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, text=True + ) + if proc.returncode: + logger.error(proc.stdout) + return proc.returncode diff --git a/agent/charms/testflinger-agent-host-charm/src/defaults.py b/agent/charms/testflinger-agent-host-charm/src/defaults.py new file mode 100644 index 00000000..903e560c --- /dev/null +++ b/agent/charms/testflinger-agent-host-charm/src/defaults.py @@ -0,0 +1,8 @@ +# Copyright 2024 Canonical +# See LICENSE file for licensing details. + +AGENT_CONFIGS_PATH = "/srv/agent-configs" +DEFAULT_TESTFLINGER_REPO = "https://github.com/canonical/testflinger.git" +DEFAULT_BRANCH = "main" +LOCAL_TESTFLINGER_PATH = "/srv/testflinger" +VIRTUAL_ENV_PATH = "/srv/testflinger-venv" diff --git a/agent/charms/testflinger-agent-host-charm/src/testflinger_source.py b/agent/charms/testflinger-agent-host-charm/src/testflinger_source.py new file mode 100644 index 00000000..669ddc95 --- /dev/null +++ b/agent/charms/testflinger-agent-host-charm/src/testflinger_source.py @@ -0,0 +1,76 @@ +# Copyright 2024 Canonical +# See LICENSE file for licensing details. + +import os +import shutil +from git import Repo +from common import run_with_logged_errors +from defaults import DEFAULT_TESTFLINGER_REPO, DEFAULT_BRANCH, VIRTUAL_ENV_PATH + + +# Only keep these directories from the repo in the sparse checkout +CHECKOUT_DIRS = ("agent", "common", "device-connectors") + + +def clone_repo( + local_path, + testflinger_repo=DEFAULT_TESTFLINGER_REPO, + branch=DEFAULT_BRANCH, +): + """Recreate the git repos and reinstall everything needed""" + + # First, remove the old repo + shutil.rmtree(local_path, ignore_errors=True) + + # Clone the repo + repo = Repo.clone_from( + url=testflinger_repo, + branch=branch, + to_path=local_path, + no_checkout=True, + depth=1, + ) + + # do a sparse checkout of only the parts of the repo we need + repo.git.checkout( + f"origin/{branch}", + "--", + *CHECKOUT_DIRS, + ) + for dir in ( + "agent", + "device-connectors", + ): + run_with_logged_errors( + [ + f"{VIRTUAL_ENV_PATH}/bin/pip3", + "install", + "-I", + f"{local_path}/{dir}", + ] + ) + + +def create_virtualenv(): + """Create a virtualenv for the agent unless one already exists""" + if os.path.exists(VIRTUAL_ENV_PATH): + return + + run_with_logged_errors( + [ + "python3", + "-m", + "virtualenv", + VIRTUAL_ENV_PATH, + ] + ) + + # Update pip in the virtualenv so that poetry works in focal + run_with_logged_errors( + [ + f"{VIRTUAL_ENV_PATH}/bin/pip3", + "install", + "-U", + "pip", + ] + ) diff --git a/agent/charms/testflinger-agent-host-charm/src/tf-cmd-scripts/tf-allocate b/agent/charms/testflinger-agent-host-charm/src/tf-cmd-scripts/tf-allocate index 52b725fc..69d7b064 100755 --- a/agent/charms/testflinger-agent-host-charm/src/tf-cmd-scripts/tf-allocate +++ b/agent/charms/testflinger-agent-host-charm/src/tf-cmd-scripts/tf-allocate @@ -3,4 +3,13 @@ AGENT=$(echo $agent_id | sed 's/-\([0-9]*\)$/\1/g') PROVISION_TYPE="$provision_type" -. /srv/testflinger-agent/$AGENT/env/bin/activate && PYTHONUNBUFFERED=1 testflinger-device-connector $PROVISION_TYPE allocate -c /srv/testflinger-agent/$AGENT/default.yaml testflinger.json +# The following variables are set by the agent charm +AGENT_CONFIGS_PATH={{ agent_configs_path }} +CONFIG_DIR="{{ config_dir }}" +VIRTUAL_ENV_PATH="{{ virtual_env_path }}" + +if [ -d "$VIRTUAL_ENV_PATH" ]; then + PYTHONUNBUFFERED=1 $VIRTUAL_ENV_PATH/bin/testflinger-device-connector $PROVISION_TYPE allocate -c $AGENT_CONFIGS_PATH/$CONFIG_DIR/$agent_id/default.yaml testflinger.json +else + . /srv/testflinger-agent/$AGENT/env/bin/activate && PYTHONUNBUFFERED=1 testflinger-device-connector $PROVISION_TYPE allocate -c /srv/testflinger-agent/$AGENT/default.yaml testflinger.json +fi diff --git a/agent/charms/testflinger-agent-host-charm/src/tf-cmd-scripts/tf-firmware-update b/agent/charms/testflinger-agent-host-charm/src/tf-cmd-scripts/tf-firmware-update index eb4ad0e7..e8ef8c08 100755 --- a/agent/charms/testflinger-agent-host-charm/src/tf-cmd-scripts/tf-firmware-update +++ b/agent/charms/testflinger-agent-host-charm/src/tf-cmd-scripts/tf-firmware-update @@ -3,4 +3,13 @@ AGENT=$(echo $agent_id | sed 's/-\([0-9]*\)$/\1/g') PROVISION_TYPE="$provision_type" -. /srv/testflinger-agent/$AGENT/env/bin/activate && PYTHONUNBUFFERED=1 testflinger-device-connector $PROVISION_TYPE firmware_update -c /srv/testflinger-agent/$AGENT/default.yaml testflinger.json +# The following variables are set by the agent charm +AGENT_CONFIGS_PATH={{ agent_configs_path }} +CONFIG_DIR="{{ config_dir }}" +VIRTUAL_ENV_PATH="{{ virtual_env_path }}" + +if [ -d "$VIRTUAL_ENV_PATH" ]; then + PYTHONUNBUFFERED=1 $VIRTUAL_ENV_PATH/bin/testflinger-device-connector $PROVISION_TYPE firmware_update -c $AGENT_CONFIGS_PATH/$CONFIG_DIR/$agent_id/default.yaml testflinger.json +else + . /srv/testflinger-agent/$AGENT/env/bin/activate && PYTHONUNBUFFERED=1 testflinger-device-connector $PROVISION_TYPE firmware_update -c /srv/testflinger-agent/$AGENT/default.yaml testflinger.json +fi diff --git a/agent/charms/testflinger-agent-host-charm/src/tf-cmd-scripts/tf-provision b/agent/charms/testflinger-agent-host-charm/src/tf-cmd-scripts/tf-provision index 020f6e22..1d34f349 100755 --- a/agent/charms/testflinger-agent-host-charm/src/tf-cmd-scripts/tf-provision +++ b/agent/charms/testflinger-agent-host-charm/src/tf-cmd-scripts/tf-provision @@ -3,4 +3,13 @@ AGENT=$(echo $agent_id | sed 's/-\([0-9]*\)$/\1/g') PROVISION_TYPE="$provision_type" -. /srv/testflinger-agent/$AGENT/env/bin/activate && PYTHONUNBUFFERED=1 testflinger-device-connector $PROVISION_TYPE provision -c /srv/testflinger-agent/$AGENT/default.yaml testflinger.json +# The following variables are set by the agent charm +AGENT_CONFIGS_PATH={{ agent_configs_path }} +CONFIG_DIR="{{ config_dir }}" +VIRTUAL_ENV_PATH="{{ virtual_env_path }}" + +if [ -d "$VIRTUAL_ENV_PATH" ]; then + PYTHONUNBUFFERED=1 $VIRTUAL_ENV_PATH/bin/testflinger-device-connector $PROVISION_TYPE provision -c $AGENT_CONFIGS_PATH/$CONFIG_DIR/$agent_id/default.yaml testflinger.json +else + . /srv/testflinger-agent/$AGENT/env/bin/activate && PYTHONUNBUFFERED=1 testflinger-device-connector $PROVISION_TYPE provision -c /srv/testflinger-agent/$AGENT/default.yaml testflinger.json +fi diff --git a/agent/charms/testflinger-agent-host-charm/src/tf-cmd-scripts/tf-reserve b/agent/charms/testflinger-agent-host-charm/src/tf-cmd-scripts/tf-reserve index fd0ab204..bbec9fb9 100755 --- a/agent/charms/testflinger-agent-host-charm/src/tf-cmd-scripts/tf-reserve +++ b/agent/charms/testflinger-agent-host-charm/src/tf-cmd-scripts/tf-reserve @@ -3,4 +3,13 @@ AGENT=$(echo $agent_id | sed 's/-\([0-9]*\)$/\1/g') PROVISION_TYPE="$provision_type" -. /srv/testflinger-agent/$AGENT/env/bin/activate && PYTHONUNBUFFERED=1 testflinger-device-connector $PROVISION_TYPE reserve -c /srv/testflinger-agent/$AGENT/default.yaml testflinger.json +# The following variables are set by the agent charm +AGENT_CONFIGS_PATH={{ agent_configs_path }} +CONFIG_DIR="{{ config_dir }}" +VIRTUAL_ENV_PATH="{{ virtual_env_path }}" + +if [ -d "$VIRTUAL_ENV_PATH" ]; then + PYTHONUNBUFFERED=1 $VIRTUAL_ENV_PATH/bin/testflinger-device-connector $PROVISION_TYPE reserve -c $AGENT_CONFIGS_PATH/$CONFIG_DIR/$agent_id/default.yaml testflinger.json +else + . /srv/testflinger-agent/$AGENT/env/bin/activate && PYTHONUNBUFFERED=1 testflinger-device-connector $PROVISION_TYPE reserve -c /srv/testflinger-agent/$AGENT/default.yaml testflinger.json +fi diff --git a/agent/charms/testflinger-agent-host-charm/src/tf-cmd-scripts/tf-setup b/agent/charms/testflinger-agent-host-charm/src/tf-cmd-scripts/tf-setup index 8ef6906b..c6e70777 100755 --- a/agent/charms/testflinger-agent-host-charm/src/tf-cmd-scripts/tf-setup +++ b/agent/charms/testflinger-agent-host-charm/src/tf-cmd-scripts/tf-setup @@ -2,6 +2,11 @@ AGENT=$(echo $agent_id | sed 's/-\([0-9]*\)$/\1/g') PROVISION_TYPE="$provision_type" +VIRTUAL_ENV_PATH="{{ virtual_env_path }}" -echo Cleaning up container if it exists... && docker rm -f $AGENT || /bin/true +if [ -d "$VIRTUAL_ENV_PATH" ]; then + echo Cleaning up container if it exists... && docker rm -f $agent_id || /bin/true +else + echo Cleaning up container if it exists... && docker rm -f $AGENT || /bin/true +fi diff --git a/agent/charms/testflinger-agent-host-charm/src/tf-cmd-scripts/tf-test b/agent/charms/testflinger-agent-host-charm/src/tf-cmd-scripts/tf-test index 1255abf4..ed9d2ffb 100755 --- a/agent/charms/testflinger-agent-host-charm/src/tf-cmd-scripts/tf-test +++ b/agent/charms/testflinger-agent-host-charm/src/tf-cmd-scripts/tf-test @@ -3,4 +3,19 @@ AGENT=$(echo $agent_id | sed 's/-\([0-9]*\)$/\1/g') PROVISION_TYPE="$provision_type" -docker run -t --name $AGENT -v $PWD:/home/ubuntu -v ~/.ssh:/home/ubuntu/.ssh:ro -v /srv/testflinger-agent/$AGENT:/srv/testflinger-agent/$AGENT plars/testflinger-testenv-focal bash -c "(cd /srv/testflinger-agent/$AGENT/testflinger/device-connectors && sudo pip install . &> /dev/null) && PYTHONUNBUFFERED=1 testflinger-device-connector $PROVISION_TYPE runtest -c /srv/testflinger-agent/$AGENT/default.yaml testflinger.json" +# The following variables are set by the agent charm +AGENT_CONFIGS_PATH={{ agent_configs_path }} +CONFIG_DIR="{{ config_dir }}" +VIRTUAL_ENV_PATH="{{ virtual_env_path }}" + +if [ -d "$VIRTUAL_ENV_PATH" ]; then + docker run -t --name $agent_id \ + -v $PWD:/home/ubuntu \ + -v ~/.ssh:/home/ubuntu/.ssh:ro \ + -v /srv/testflinger:/srv/testflinger \ + -v $AGENT_CONFIGS_PATH/$CONFIG_DIR/$agent_id:$AGENT_CONFIGS_PATH/$CONFIG_DIR/$agent_id \ + plars/testflinger-testenv-focal \ + bash -c "(cd /srv/testflinger/device-connectors && sudo pip install . &> /dev/null) && PYTHONUNBUFFERED=1 testflinger-device-connector $PROVISION_TYPE runtest -c /$AGENT_CONFIGS_PATH/$CONFIG_DIR/$agent_id/default.yaml testflinger.json" +else + docker run -t --name $AGENT -v $PWD:/home/ubuntu -v ~/.ssh:/home/ubuntu/.ssh:ro -v /srv/testflinger-agent/$AGENT:/srv/testflinger-agent/$AGENT plars/testflinger-testenv-focal bash -c "(cd /srv/testflinger-agent/$AGENT/testflinger/device-connectors && sudo pip install . &> /dev/null) && PYTHONUNBUFFERED=1 testflinger-device-connector $PROVISION_TYPE runtest -c /srv/testflinger-agent/$AGENT/default.yaml testflinger.json" +fi diff --git a/agent/charms/testflinger-agent-host-charm/templates/testflinger-agent.supervisord.conf.j2 b/agent/charms/testflinger-agent-host-charm/templates/testflinger-agent.supervisord.conf.j2 new file mode 100644 index 00000000..aa193997 --- /dev/null +++ b/agent/charms/testflinger-agent-host-charm/templates/testflinger-agent.supervisord.conf.j2 @@ -0,0 +1,5 @@ +[program:{{ agent_name }}] +redirect_stderr=true +environment=USER="ubuntu",HOME="/home/ubuntu",PYTHONIOENCODING=utf-8 +user=ubuntu +command={{ virtual_env_path }}/bin/testflinger-agent -c {{ agent_config_path }}/testflinger-agent.conf diff --git a/agent/charms/testflinger-agent-host-charm/tests/integration/data/test01/agent001/default.yaml b/agent/charms/testflinger-agent-host-charm/tests/integration/data/test01/agent001/default.yaml new file mode 100644 index 00000000..88db0938 --- /dev/null +++ b/agent/charms/testflinger-agent-host-charm/tests/integration/data/test01/agent001/default.yaml @@ -0,0 +1,2 @@ +agent_name: agent001 +device_ip: 127.0.0.1 diff --git a/agent/charms/testflinger-agent-host-charm/tests/integration/data/test01/agent001/testflinger-agent.conf b/agent/charms/testflinger-agent-host-charm/tests/integration/data/test01/agent001/testflinger-agent.conf new file mode 100644 index 00000000..dc60419d --- /dev/null +++ b/agent/charms/testflinger-agent-host-charm/tests/integration/data/test01/agent001/testflinger-agent.conf @@ -0,0 +1,10 @@ +agent_id: agent-001 +server_address: http://127.0.0.1:5000 +logging_level: DEBUG +job_queues: + - agent-001 +setup_command: bash -c "echo setup phase" +provision_command: bash -c "echo provision phase" +test_command: bash -c "echo test phase" +provision_type: noprovision +identifier: 202402-01010 diff --git a/agent/charms/testflinger-agent-host-charm/tests/integration/data/test02/agent001/default.yaml b/agent/charms/testflinger-agent-host-charm/tests/integration/data/test02/agent001/default.yaml new file mode 100644 index 00000000..88db0938 --- /dev/null +++ b/agent/charms/testflinger-agent-host-charm/tests/integration/data/test02/agent001/default.yaml @@ -0,0 +1,2 @@ +agent_name: agent001 +device_ip: 127.0.0.1 diff --git a/agent/charms/testflinger-agent-host-charm/tests/integration/data/test02/agent001/testflinger-agent.conf b/agent/charms/testflinger-agent-host-charm/tests/integration/data/test02/agent001/testflinger-agent.conf new file mode 100644 index 00000000..dc60419d --- /dev/null +++ b/agent/charms/testflinger-agent-host-charm/tests/integration/data/test02/agent001/testflinger-agent.conf @@ -0,0 +1,10 @@ +agent_id: agent-001 +server_address: http://127.0.0.1:5000 +logging_level: DEBUG +job_queues: + - agent-001 +setup_command: bash -c "echo setup phase" +provision_command: bash -c "echo provision phase" +test_command: bash -c "echo test phase" +provision_type: noprovision +identifier: 202402-01010 diff --git a/agent/charms/testflinger-agent-host-charm/tests/integration/data/test02/agent002/default.yaml b/agent/charms/testflinger-agent-host-charm/tests/integration/data/test02/agent002/default.yaml new file mode 100644 index 00000000..88db0938 --- /dev/null +++ b/agent/charms/testflinger-agent-host-charm/tests/integration/data/test02/agent002/default.yaml @@ -0,0 +1,2 @@ +agent_name: agent001 +device_ip: 127.0.0.1 diff --git a/agent/charms/testflinger-agent-host-charm/tests/integration/data/test02/agent002/testflinger-agent.conf b/agent/charms/testflinger-agent-host-charm/tests/integration/data/test02/agent002/testflinger-agent.conf new file mode 100644 index 00000000..ad79b292 --- /dev/null +++ b/agent/charms/testflinger-agent-host-charm/tests/integration/data/test02/agent002/testflinger-agent.conf @@ -0,0 +1,10 @@ +agent_id: agent-002 +server_address: http://127.0.0.1:5000 +logging_level: DEBUG +job_queues: + - agent-002 +setup_command: bash -c "echo setup phase" +provision_command: bash -c "echo provision phase" +test_command: bash -c "echo test phase" +provision_type: noprovision +identifier: 202402-01010 diff --git a/agent/charms/testflinger-agent-host-charm/tests/integration/test_charm_integration.py b/agent/charms/testflinger-agent-host-charm/tests/integration/test_charm_integration.py new file mode 100644 index 00000000..c6dec575 --- /dev/null +++ b/agent/charms/testflinger-agent-host-charm/tests/integration/test_charm_integration.py @@ -0,0 +1,143 @@ +from pathlib import Path +import pytest +from pytest_operator.plugin import OpsTest + +# Root of the charm we need to build is two dirs up +CHARM_PATH = Path(__file__).parent.parent.parent +APP_NAME = "testflinger-agent-host" +TEST_CONFIG_01 = { + "config-repo": "https://github.com/canonical/testflinger.git", + "config-dir": "agent/charms/testflinger-agent-host-charm/tests/integration/data/test01", + "config-branch": "unified-agent-host-charm", +} +TEST_CONFIG_02 = { + "config-repo": "https://github.com/canonical/testflinger.git", + "config-dir": "agent/charms/testflinger-agent-host-charm/tests/integration/data/test02", + "config-branch": "unified-agent-host-charm", +} + + +@pytest.mark.skip_if_deployed +@pytest.mark.abort_on_fail +async def test_build_and_deploy(ops_test: OpsTest): + charm = await ops_test.build_charm(CHARM_PATH) + app = await ops_test.model.deploy(charm) + await ops_test.model.applications[APP_NAME].set_config(TEST_CONFIG_01) + + await ops_test.model.wait_for_idle(status="active", timeout=600) + assert app.status == "active" + + +async def test_action_update_testflinger(ops_test: OpsTest): + await ops_test.model.wait_for_idle(status="active", timeout=600) + action = await ops_test.model.units.get(f"{APP_NAME}/0").run_action( + "update-testflinger" + ) + await action.wait() + assert action.status == "completed" + assert action.results["return-code"] == 0 + + +async def test_action_update_configs(ops_test: OpsTest): + await ops_test.model.wait_for_idle(status="active", timeout=600) + + # First, un-set the config-repo to trigger BlockedStatus + bad_config = {"config-repo": ""} + await ops_test.model.applications[APP_NAME].set_config(bad_config) + action = await ops_test.model.units.get(f"{APP_NAME}/0").run_action( + "update-configs" + ) + await action.wait() + assert action.status == "completed" + assert ( + ops_test.model.applications[APP_NAME].units[0].workload_status + == "blocked" + ) + + # Go back to the good config and make sure we get back to ActiveStatus + await ops_test.model.applications[APP_NAME].set_config(TEST_CONFIG_01) + action = await ops_test.model.units.get(f"{APP_NAME}/0").run_action( + "update-configs" + ) + await action.wait() + assert action.status == "completed" + assert ( + ops_test.model.applications[APP_NAME].units[0].workload_status + == "active" + ) + + +async def test_supervisord_files_written(ops_test: OpsTest): + await ops_test.model.applications[APP_NAME].set_config(TEST_CONFIG_01) + action = await ops_test.model.units.get(f"{APP_NAME}/0").run_action( + "update-configs" + ) + await action.wait() + assert action.status == "completed" + assert ( + ops_test.model.applications[APP_NAME].units[0].workload_status + == "active" + ) + # check that agent001.conf was written in /etc/supervisor/conf.d/ + expected_contents = ( + "[program:agent001]\n" + "redirect_stderr=true\n" + 'environment=USER="ubuntu",HOME="/home/ubuntu",' + "PYTHONIOENCODING=utf-8\n" + "user=ubuntu\n" + "command=/srv/testflinger-venv/bin/testflinger-agent -c " + "/srv/agent-configs/agent/charms/testflinger-agent-host-charm/tests/" + "integration/data/test01/agent001/testflinger-agent.conf\n" + ) + + conf_file = "/etc/supervisor/conf.d/agent001.conf" + unit_name = f"{APP_NAME}/0" + command = ["exec", "--unit", unit_name, "--", "cat", conf_file] + returncode, stdout, _ = await ops_test.juju(*command) + assert returncode == 0 + assert stdout == expected_contents + + +async def test_supervisord_num_agents_running(ops_test: OpsTest): + # Check that the number of running agents is correct after an update + await ops_test.model.applications[APP_NAME].set_config(TEST_CONFIG_01) + action = await ops_test.model.units.get(f"{APP_NAME}/0").run_action( + "update-configs" + ) + await action.wait() + assert action.status == "completed" + assert ( + ops_test.model.applications[APP_NAME].units[0].workload_status + == "active" + ) + + unit_name = f"{APP_NAME}/0" + command = ["exec", "--unit", unit_name, "--", "supervisorctl", "status"] + returncode, stdout, _ = await ops_test.juju(*command) + assert returncode == 0 + running_agents = [ + line for line in stdout.splitlines() if "RUNNING" in line + ] + assert len(running_agents) == 1 + + # Update the configs used to one that should launch two agents + await ops_test.model.applications[APP_NAME].set_config(TEST_CONFIG_02) + action = await ops_test.model.units.get(f"{APP_NAME}/0").run_action( + "update-configs" + ) + await action.wait() + assert action.status == "completed" + assert ( + ops_test.model.applications[APP_NAME].units[0].workload_status + == "active" + ) + + # Check that the number of running agents is now 2 + unit_name = f"{APP_NAME}/0" + command = ["exec", "--unit", unit_name, "--", "supervisorctl", "status"] + returncode, stdout, _ = await ops_test.juju(*command) + assert returncode == 0 + running_agents = [ + line for line in stdout.splitlines() if "RUNNING" in line + ] + assert len(running_agents) == 2 diff --git a/agent/charms/testflinger-agent-host-charm/tests/test_charm.py b/agent/charms/testflinger-agent-host-charm/tests/test_charm.py deleted file mode 100644 index f2bd7ae1..00000000 --- a/agent/charms/testflinger-agent-host-charm/tests/test_charm.py +++ /dev/null @@ -1,73 +0,0 @@ -# Copyright 2024 Canonical -# See LICENSE file for licensing details. -# -# Learn more about testing at: https://juju.is/docs/sdk/testing - -import unittest -import os -from unittest.mock import patch -from charm import TestflingerAgentHostCharm -from ops.testing import Harness - - -class TestCharm(unittest.TestCase): - def setUp(self): - self.harness = Harness(TestflingerAgentHostCharm) - self.addCleanup(self.harness.cleanup) - self.harness.begin() - - @patch("charm.TestflingerAgentHostCharm.write_file") - @patch("charm.TestflingerAgentHostCharm.read_resource") - def test_copy_ssh_keys(self, mock_read_resource, mock_write_file): - """Test the copy_ssh_keys method""" - charm = self.harness.charm - mock_read_resource.side_effect = [ - "ssh_priv_key_content", - "ssh_pub_key_content", - ] - - charm.copy_ssh_keys() - - mock_read_resource.assert_any_call("ssh_priv_key") - mock_read_resource.assert_any_call("ssh_pub_key") - self.assertEqual(mock_read_resource.call_count, 2) - - mock_write_file.assert_any_call( - "/home/ubuntu/.ssh/id_rsa", "ssh_priv_key_content" - ) - mock_write_file.assert_any_call( - "/home/ubuntu/.ssh/id_rsa.pub", "ssh_pub_key_content" - ) - self.assertEqual(mock_write_file.call_count, 2) - - @patch("os.listdir") - @patch("shutil.copy") - @patch("os.chmod") - def test_update_tf_cmd_scripts( - self, mock_chmod, mock_copy, mock_listdir - ): - """Test the update_tf_cmd_scripts method""" - charm = self.harness.charm - tf_cmd_scripts_files = ["tf-script3", "tf-script4"] - - mock_listdir.side_effect = [tf_cmd_scripts_files] - - charm.update_tf_cmd_scripts() - tf_cmd_dir = "src/tf-cmd-scripts/" - usr_local_bin = "/usr/local/bin/" - mock_copy.assert_any_call( - os.path.join(tf_cmd_dir, "tf-script3"), - usr_local_bin, - ) - mock_copy.assert_any_call( - os.path.join(tf_cmd_dir, "tf-script4"), - usr_local_bin, - ) - self.assertEqual(mock_copy.call_count, 2) - mock_chmod.assert_any_call( - os.path.join(usr_local_bin, "tf-script3"), 0o775 - ) - mock_chmod.assert_any_call( - os.path.join(usr_local_bin, "tf-script4"), 0o775 - ) - self.assertEqual(mock_chmod.call_count, 2) diff --git a/agent/charms/testflinger-agent-host-charm/tests/unit/test_charm.py b/agent/charms/testflinger-agent-host-charm/tests/unit/test_charm.py new file mode 100644 index 00000000..03a0a4d3 --- /dev/null +++ b/agent/charms/testflinger-agent-host-charm/tests/unit/test_charm.py @@ -0,0 +1,105 @@ +# Copyright 2024 Canonical +# See LICENSE file for licensing details. +# +# Learn more about testing at: https://juju.is/docs/sdk/testing + +import unittest +import base64 +from unittest.mock import patch, mock_open +from charm import TestflingerAgentHostCharm +from ops.testing import Harness + + +class TestCharm(unittest.TestCase): + def setUp(self): + self.harness = Harness(TestflingerAgentHostCharm) + self.addCleanup(self.harness.cleanup) + self.harness.begin() + + @patch("os.chown") + @patch("os.chmod") + @patch("shutil.move") + @patch("git.Repo.clone_from") + @patch("charm.TestflingerAgentHostCharm.write_file") + @patch("charm.TestflingerAgentHostCharm.restart_agents") + @patch("charm.TestflingerAgentHostCharm.supervisor_update") + @patch("charm.TestflingerAgentHostCharm.write_supervisor_service_files") + def test_copy_ssh_keys( + self, + _, + __, + ___, + mock_write_file, + mock_clone_from, + mock_move, + mock_chmod, + mock_chown, + ): + """ + Test the copy_ssh_keys method + + The commands like supervisorctl in write_supervisor_files, + restart_agents, and supervisor_update won't work here and + are mocked out, but are tested in the integration tests. + """ + + mock_clone_from.return_value = None + mock_move.return_value = None + self.harness.update_config( + { + "ssh-private-key": base64.b64encode( + b"ssh_private_key_content" + ).decode(), + "ssh-public-key": base64.b64encode( + b"ssh_public_key_content" + ).decode(), + "config-repo": "foo", + "config-dir": "bar", + } + ) + mock_write_file.assert_any_call( + "/home/ubuntu/.ssh/id_rsa", "ssh_private_key_content" + ) + mock_write_file.assert_any_call( + "/home/ubuntu/.ssh/id_rsa.pub", "ssh_public_key_content" + ) + self.assertEqual(mock_write_file.call_count, 3) + + @patch("os.listdir") + @patch("builtins.open", new_callable=mock_open, read_data="test data") + @patch("pathlib.Path.write_text") + @patch("pathlib.Path.chmod") + def test_update_tf_cmd_scripts( + self, + mock_chmod, + mock_write_text, + mock_open, + mock_listdir, + ): + """Test the update_tf_cmd_scripts method""" + charm = self.harness.charm + tf_cmd_scripts_files = ["tf-setup"] + + mock_listdir.side_effect = [tf_cmd_scripts_files] + + charm.update_tf_cmd_scripts() + + # Ensure it tried to write the file correctly + mock_write_text.assert_any_call("test data") + + def test_blocked_on_no_config_repo(self): + """Test the on_config_changed method with no config-repo""" + self.harness.update_config( + {"config-repo": "", "config-dir": "agent-configs"} + ) + self.assertEqual(self.harness.charm.unit.status.name, "blocked") + + def test_blocked_on_no_config_dir(self): + """Test the on_config_changed method with no config-dir""" + self.harness.update_config( + { + "config-repo": "https://github.com/canonical/testflinger.git", + "config-dir": "", + } + ) + self.assertEqual(self.harness.charm.unit.status.name, "blocked") diff --git a/agent/charms/testflinger-agent-host-charm/tests/unit/test_testflinger_source.py b/agent/charms/testflinger-agent-host-charm/tests/unit/test_testflinger_source.py new file mode 100644 index 00000000..e0083b9e --- /dev/null +++ b/agent/charms/testflinger-agent-host-charm/tests/unit/test_testflinger_source.py @@ -0,0 +1,55 @@ +# Copyright 2024 Canonical +# See LICENSE file for licensing details. + +from unittest.mock import patch +import testflinger_source +from defaults import ( + DEFAULT_TESTFLINGER_REPO, + DEFAULT_BRANCH, + LOCAL_TESTFLINGER_PATH, + VIRTUAL_ENV_PATH, +) + + +@patch("git.Repo.clone_from") +@patch("testflinger_source.run_with_logged_errors") +def test_clone_repo(mock_run_with_logged_errors, mock_clone_from): + """Test the clone_repo method""" + testflinger_source.clone_repo(LOCAL_TESTFLINGER_PATH) + mock_clone_from.assert_called_once_with( + url=DEFAULT_TESTFLINGER_REPO, + branch=DEFAULT_BRANCH, + to_path=LOCAL_TESTFLINGER_PATH, + no_checkout=True, + depth=1, + ) + mock_run_with_logged_errors.assert_called_with( + [ + f"{VIRTUAL_ENV_PATH}/bin/pip3", + "install", + "-I", + "/srv/testflinger/device-connectors", + ] + ) + + +@patch("testflinger_source.run_with_logged_errors") +def test_create_virtualenv(mock_run_with_logged_errors): + """Test the create_virtualenv method""" + testflinger_source.create_virtualenv() + mock_run_with_logged_errors.assert_any_call( + [ + "python3", + "-m", + "virtualenv", + VIRTUAL_ENV_PATH, + ] + ) + mock_run_with_logged_errors.assert_any_call( + [ + f"{VIRTUAL_ENV_PATH}/bin/pip3", + "install", + "-U", + "pip", + ] + ) diff --git a/agent/terraform/README.md b/agent/terraform/README.md new file mode 100644 index 00000000..1853f719 --- /dev/null +++ b/agent/terraform/README.md @@ -0,0 +1,79 @@ +# Deploying with Terraform + Juju + +This Terraform module can be used for deploying the Testflinger agent +host on a physical or virtual machine model using Juju. + +## Deploying an Environment + +This terraform module assume that the Juju model you want to deploy +to has already been created. + +1. First, create the model + + ``` + $ juju add-model agent-host-1 + ``` + +2. Create ssh keys to use on the agent host + + ``` + $ ssh-keygen -t rsa -f mykey + ``` + +3. Create a git repo with the Testflinger configs + + If you have more than one agent host to deploy, create a separate directory + for each of them in this repo. Then create a separate directory for each + agent in the agent host directory where they reside. For example: + + ``` + /agent-host-1 + -/agent-101 + -testflinger-agent.yaml + -default.conf + -/agent-102 + -testflinger-agent.yaml + -default.conf + /agent-host-2 + ... + ``` + +4. Create a main.tf which specifies the required parameters for this module + + ``` + terraform { + required_providers { + juju = { + version = "~> 0.13.0" + source = "juju/juju" + } + } + } + + provider "juju" {} + + module "lab1" { + source = "/path/to/this/module" + agent_host_name = "agent-host-1" + juju_model = "agent-host-1" + config_repo = "https://github.com/path_to/config_repo.git" + config_branch = "main" + config_dir = "agent-host-1" + ssh_public_key = filebase64("mykey.pub") + ssh_private_key = filebase64("mykey") + } + ``` + + There are additional parameters you can adjust here if you want: + - **agent_host_cores**: (default: 4) Number of cpu cores to use for the agent host + - **agent_host_mem**: (default: "32768M") Amount of RAM to use for the agent host. This needs to be specified as a string with "M" at the end. + - **agent_host_storage: (default: 1048576M) Storage size for the agent host. This needs to be specified as a string with "M" at the end. + - **override_constraints**: By default, the constraints passed to Juju will use the previous parameters to build something in this format: `arch=amd64 cores=${var.agent_host_cores} mem=${var.agent_host_mem} root-disk=${var.agent_host_storage} root-disk-source=remote virt-type=virtual-machine`. If you need to override this entire line to send it something completely different, use this and the previous `agent_host_*` parameters will be ignored. + + +5. Initialize terraform and apply + ``` + $ terraform init + $ terraform apply + ``` + diff --git a/agent/terraform/locals.tf b/agent/terraform/locals.tf new file mode 100644 index 00000000..5a90d50c --- /dev/null +++ b/agent/terraform/locals.tf @@ -0,0 +1,3 @@ +locals { + agent_host_constraints = try(var.override_constraints, "arch=amd64 cores=${var.agent_host_cores} mem=${var.agent_host_mem} root-disk=${var.agent_host_storage} root-disk-source=remote virt-type=virtual-machine") +} diff --git a/agent/terraform/main.tf b/agent/terraform/main.tf new file mode 100644 index 00000000..c32ba78e --- /dev/null +++ b/agent/terraform/main.tf @@ -0,0 +1,22 @@ +resource "juju_application" "testflinger-agent-host" { + name = var.agent_host_name + model = var.juju_model + constraints = local.agent_host_constraints + + units = 1 + + charm { + name = "testflinger-agent-host" + base = "ubuntu@22.04" + channel = "latest/beta" + } + + config = { + ssh-public-key = var.ssh_public_key + ssh-private-key = var.ssh_private_key + config-repo = var.config_repo + config-branch = var.config_branch + config-dir = var.config_dir + } +} + diff --git a/agent/terraform/variables.tf b/agent/terraform/variables.tf new file mode 100644 index 00000000..52729a94 --- /dev/null +++ b/agent/terraform/variables.tf @@ -0,0 +1,60 @@ +variable "juju_model" { + type = string + description = "Name of the Juju model" +} + +variable "agent_host_name" { + type = string + description = "Name of the agent host juju application" +} + +variable "agent_host_cores" { + type = number + description = "Number of cpu cores to use for the agent host" + default = 4 +} + +variable "agent_host_mem" { + type = string + description = "Amount of RAM to use for the agent host" + default = "32768M" +} + +variable "agent_host_storage" { + type = string + description = "Storage size for the agent host" + default = "1048576M" +} + +variable "override_constraints" { + type = string + description = "Use if you need to override the constraints built with the other agent_host_* vars" + default = "" +} + +variable "config_repo" { + type = string + description = "Repository URL for the agent configs on this agent host" +} + +variable "config_branch" { + type = string + description = "Repository branch for the agent configs" +} + +variable "config_dir" { + type = string + description = "Directory within the config repo containing the charm configuration" +} + +variable "ssh_public_key" { + type = string + description = "base64 encoded ssh public key to use on the agent host" +} + +variable "ssh_private_key" { + type = string + description = "base64 encoded ssh private key to use on the agent host" +} + + diff --git a/agent/terraform/versions.tf b/agent/terraform/versions.tf new file mode 100644 index 00000000..1309a2b1 --- /dev/null +++ b/agent/terraform/versions.tf @@ -0,0 +1,9 @@ +terraform { + required_providers { + juju = { + version = "~> 0.13.0" + source = "juju/juju" + } + } +} + diff --git a/agent/tox.ini b/agent/tox.ini index b1a5df37..04e2dd0f 100644 --- a/agent/tox.ini +++ b/agent/tox.ini @@ -1,10 +1,8 @@ [tox] -envlist = py, charm +envlist = py skipsdist = true [testenv] -setenv = - HOME = {envtmpdir} deps = black flake8 @@ -24,9 +22,17 @@ commands = [testenv:charm] deps = -r charms/testflinger-agent-host-charm/requirements.txt + juju pytest + pytest-operator setenv = PYTHONPATH = {toxinidir}/charms/testflinger-agent-host-charm/lib:{toxinidir}:charms/testflinger-agent-host-charm/src commands = - {envbindir}/python -m pytest charms/testflinger-agent-host-charm/tests + pytest -v \ + -s \ + --tb native \ + --log-cli-level=INFO \ + {posargs} \ + charms/testflinger-agent-host-charm/tests/unit \ + charms/testflinger-agent-host-charm/tests/integration