Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

apt packages from config #35

Closed
wants to merge 10 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 7 additions & 0 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does service.PackageInstallError excludes network-related errors?
I would think network-related error would not need to go to block state.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The apt lib provided uses subprocess.run under the hood so there's not much ways to differentiate the network related errors. What the lib does expose are errors like PackageNotFound, which was suitable for user to unblock by re-configuring the config option.
Do you think ErrorState might be more well-fitting for both cases?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For error that requires config change, it should be in block state.
If the PackageNotFound only is thrown when user need to unblock it, then it should use BlockedState.

For network-related it should be error state.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since network related errors would not be caught (subprocess error would be raised here) - it should fallthrough and go into ErroredState automatically.

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)
Expand Down
20 changes: 20 additions & 0 deletions src/charm_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
# agent relation name
AGENT_RELATION = "agent"

APT_PACKAGES_CONFIG = "apt-packages"

logger = logging.getLogger()


Expand Down Expand Up @@ -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.
Expand All @@ -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"

Expand Down Expand Up @@ -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,
)
19 changes: 19 additions & 0 deletions src/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
21 changes: 21 additions & 0 deletions tests/integration/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
25 changes: 25 additions & 0 deletions tests/unit/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions tests/unit/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""Test for service interaction."""

import os
import typing
from pathlib import Path
from unittest.mock import MagicMock

Expand All @@ -17,6 +18,7 @@
import service
from charm import JenkinsAgentCharm
from charm_state import AGENT_RELATION
from service import JenkinsAgentService, PackageInstallError


@pytest.mark.parametrize(
Expand Down Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions tests/unit/test_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,6 +18,7 @@

import charm_state
from charm import JenkinsAgentCharm
from charm_state import APT_PACKAGES_CONFIG


def test_from_charm_invalid_metadata(
Expand All @@ -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
Loading