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