diff --git a/config.yaml b/config.yaml index 4e3bf91..04a22dc 100644 --- a/config.yaml +++ b/config.yaml @@ -7,3 +7,8 @@ options: description: | Comma-separated list of labels to be assigned to the agent in Jenkins. If not set it will default to the agents hardware identifier, e.g.: 'x86_64' + apt-packages: + type: string + default: "" + description: | + Comma-separated list of apt packages to install on the machine. diff --git a/src/charm.py b/src/charm.py index 376e6f0..92cebfa 100755 --- a/src/charm.py +++ b/src/charm.py @@ -59,6 +59,13 @@ def _on_install(self, _: ops.InstallEvent) -> None: def _on_config_changed(self, _: ops.ConfigChangedEvent) -> None: """Handle config changed event. Update the agent's label in the relation's databag.""" + try: + self.jenkins_agent_service.install_apt_packages(self.state.apt_packages) + except service.PackageInstallError as exc: + logger.error("Error installing apt packages %s", exc) + self.model.unit.status = ops.BlockedStatus("Error installing apt packages: %s") + return + if agent_relation := self.model.get_relation(AGENT_RELATION): relation_data = self.state.agent_meta.as_dict() agent_relation.data[self.unit].update(relation_data) diff --git a/src/charm_state.py b/src/charm_state.py index 17110a9..3959c8b 100644 --- a/src/charm_state.py +++ b/src/charm_state.py @@ -17,6 +17,8 @@ # agent relation name AGENT_RELATION = "agent" +APT_PACKAGES_CONFIG = "apt-packages" + logger = logging.getLogger() @@ -115,6 +117,21 @@ def _get_credentials_from_agent_relation( return Credentials(address=address, secret=secret) +def _get_packages_to_install(charm: ops.CharmBase) -> typing.Tuple[str, ...]: + """Get list of apt packages to install from charm configuration. + + Args: + charm: The charm instance. + + Returns: + The list of apt packages to install. + """ + packages = charm.config.get(APT_PACKAGES_CONFIG, "") + if not packages: + return () + return tuple(package.strip() for package in packages.split(",")) + + @dataclass class State: """The Jenkins agent state. @@ -123,12 +140,14 @@ class State: agent_meta: The Jenkins agent metadata to register on Jenkins server. agent_relation_credentials: The full set of credentials from the agent relation. None if partial data is set or the credentials do not belong to current agent. + apt_packages: The list of apt packages to install on the unit. unit_data: Data about the current unit. jenkins_agent_service_name: The Jenkins agent workload container name. """ agent_meta: AgentMeta agent_relation_credentials: typing.Optional[Credentials] + apt_packages: typing.Tuple[str, ...] unit_data: UnitData jenkins_agent_service_name: str = "jenkins-agent" @@ -176,5 +195,6 @@ def from_charm(cls, charm: ops.CharmBase) -> "State": return cls( agent_meta=agent_meta, agent_relation_credentials=agent_relation_credentials, + apt_packages=_get_packages_to_install(charm), unit_data=unit_data, ) diff --git a/src/service.py b/src/service.py index 4f8b8d4..93f8de8 100644 --- a/src/service.py +++ b/src/service.py @@ -8,6 +8,7 @@ import pwd import time from pathlib import Path +from typing import Iterable from charms.operator_libs_linux.v0 import apt from charms.operator_libs_linux.v1 import systemd @@ -123,6 +124,24 @@ def install(self) -> None: except (apt.PackageError, apt.PackageNotFoundError, apt.GPGKeyError) as exc: raise PackageInstallError("Error installing the agent package") from exc + @classmethod + def install_apt_packages(cls, packages: Iterable[str]) -> None: + """Install apt packages. + + Args: + packages: The apt packages to install. + + Raises: + PackageInstallError: If there was an error installing the package. + """ + to_install = list(packages) + if not to_install: + return + try: + apt.add_package(to_install, update_cache=True) + except apt.Error as exc: + raise PackageInstallError from exc + def restart(self) -> None: """Start the agent service. diff --git a/tests/integration/test_agent.py b/tests/integration/test_agent.py index 87892e8..6ababab 100644 --- a/tests/integration/test_agent.py +++ b/tests/integration/test_agent.py @@ -10,6 +10,9 @@ import jenkinsapi.jenkins from juju.application import Application from juju.model import Model +from juju.unit import Unit + +from charm_state import APT_PACKAGES_CONFIG from .conftest import NUM_AGENT_UNITS, assert_job_success @@ -63,3 +66,21 @@ async def test_agent_relation( assert len(nodes.values()) == NUM_AGENT_UNITS + 1 assert_job_success(jenkins_client, jenkins_agent_application.name, "machine") + + +async def test_agent_packages( + model: Model, + jenkins_agent_application: Application, +): + """ + arrange: given a jenkins agent application. + act: when the apt-packages configuration is set. + assert: the defined packages are installed. + """ + await jenkins_agent_application.set_config({APT_PACKAGES_CONFIG: "bzr, iputils-ping"}) + await model.wait_for_idle() + + unit: Unit = jenkins_agent_application.units[0] + + assert "/usr/bin/bzr\n" == await unit.ssh("which bzr") + assert "/usr/bin/ping\n" == await unit.ssh("which ping") diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index f17cd27..f70cf6a 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -5,16 +5,21 @@ """Test for charm hooks.""" +# Need access to protected functions for testing +# pylint:disable=protected-access + from unittest.mock import MagicMock, PropertyMock import ops import ops.testing import pytest +from charms.operator_libs_linux.v0 import apt from charms.operator_libs_linux.v1 import systemd import charm_state import service from charm import JenkinsAgentCharm +from charm_state import APT_PACKAGES_CONFIG def test___init___invalid_state(harness: ops.testing.Harness, monkeypatch: pytest.MonkeyPatch): @@ -53,6 +58,26 @@ def test__on_upgrade_charm(harness: ops.testing.Harness, monkeypatch: pytest.Mon assert charm.unit.status.name == ops.BlockedStatus.name +def test__on_config_changed_apt_install_fail( + harness: ops.testing.Harness, monkeypatch: pytest.MonkeyPatch +): + """ + arrange: given a charm with patched relation. + act: when _on_config_changed is called. + assert: The charm correctly updates the relation databag. + """ + harness.update_config({APT_PACKAGES_CONFIG: "hello,world"}) + harness.begin() + get_relation_mock = MagicMock() + monkeypatch.setattr(apt, "add_package", MagicMock(side_effect=apt.PackageNotFoundError)) + monkeypatch.setattr(ops.Model, "get_relation", get_relation_mock) + + charm: JenkinsAgentCharm = harness.charm + charm._on_config_changed(MagicMock()) + + assert charm.unit.status.name == ops.BlockedStatus.name + + def test__on_config_changed(harness: ops.testing.Harness, monkeypatch: pytest.MonkeyPatch): """ arrange: given a charm with patched relation. diff --git a/tests/unit/test_service.py b/tests/unit/test_service.py index fa6c552..418837c 100644 --- a/tests/unit/test_service.py +++ b/tests/unit/test_service.py @@ -6,6 +6,7 @@ """Test for service interaction.""" import os +import typing from pathlib import Path from unittest.mock import MagicMock @@ -17,6 +18,7 @@ import service from charm import JenkinsAgentCharm from charm_state import AGENT_RELATION +from service import JenkinsAgentService, PackageInstallError @pytest.mark.parametrize( @@ -72,6 +74,39 @@ def test_on_install(harness: ops.testing.Harness, monkeypatch: pytest.MonkeyPatc assert harness.charm.unit.status.name == ops.BlockedStatus.name +def test_on_install_packages_fail(monkeypatch: pytest.MonkeyPatch): + """ + arrange: Given a monkeypatched apt lib that raises an error. + act: when install_apt_packages is called. + assert: PackageInstallError is raised. + """ + monkeypatch.setattr(apt, "add_package", MagicMock(side_effect=apt.PackageNotFoundError)) + + with pytest.raises(PackageInstallError): + JenkinsAgentService.install_apt_packages(["hello", "world"]) + + +@pytest.mark.parametrize( + "packages", + [ + pytest.param(tuple(), id="No packages"), + pytest.param(("hello", "world"), id="Has packages"), + ], +) +def test_on_install_packages(monkeypatch: pytest.MonkeyPatch, packages: typing.Tuple[str, ...]): + """ + arrange: Given a monkeypatched apt lib and list of packages to install. + act: when install_apt_packages is called. + assert: package install call is made. + """ + monkeypatch.setattr(apt, "add_package", (apt_mock := MagicMock())) + + JenkinsAgentService.install_apt_packages(packages) + + if packages: + apt_mock.assert_called_once() + + def test_restart_service( harness: ops.testing.Harness, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/unit/test_state.py b/tests/unit/test_state.py index 49d5e08..d4ea44e 100644 --- a/tests/unit/test_state.py +++ b/tests/unit/test_state.py @@ -5,7 +5,11 @@ """Test for charm state.""" +# Need access to protected functions for testing +# pylint:disable=protected-access + import os +import typing from unittest.mock import MagicMock import ops @@ -14,6 +18,7 @@ import charm_state from charm import JenkinsAgentCharm +from charm_state import APT_PACKAGES_CONFIG def test_from_charm_invalid_metadata( @@ -30,3 +35,25 @@ def test_from_charm_invalid_metadata( with pytest.raises(charm_state.InvalidStateError, match="Invalid executor state."): charm_state.State.from_charm(charm=charm) + + +@pytest.mark.parametrize( + "packages, expected", + [ + pytest.param("", tuple(), id="empty"), + pytest.param("git,bzr", ("git", "bzr"), id="has packages"), + ], +) +def test__get_packages_to_install( + harness: ops.testing.Harness, packages: str, expected: typing.Tuple[str, ...] +): + """ + arrange: given charm apt-packages config. + act: when _get_packages_to_install is called. + assert: packages are correctly parsed. + """ + harness.begin() + harness.update_config({APT_PACKAGES_CONFIG: packages}) + charm: JenkinsAgentCharm = harness.charm + + assert charm_state._get_packages_to_install(charm=charm) == expected