From c5ac369c7ec1aa13afeea920a12d13b848dc4808 Mon Sep 17 00:00:00 2001 From: Yanks Yoon <37652070+yanksyoon@users.noreply.github.com> Date: Thu, 4 Jan 2024 16:44:21 +0800 Subject: [PATCH] fix: failure recovery (#41) --- .github/workflows/integration_test.yaml | 10 +- jenkins_agent_k8s_rock/files/entrypoint.sh | 2 +- src-docs/agent.py.md | 30 +++++ src/agent.py | 49 ++++---- src/charm.py | 27 ++++- tests/conftest.py | 2 + tests/integration/conftest.py | 87 ++++++++++++-- tests/integration/helpers.py | 47 ++++++++ tests/integration/test_agent_k8s.py | 60 ++++++++++ .../{test_agent.py => test_agent_machine.py} | 6 +- tests/unit/test_agent.py | 2 +- tests/unit/test_charm.py | 113 ++++++++++++++---- 12 files changed, 375 insertions(+), 60 deletions(-) create mode 100644 tests/integration/helpers.py create mode 100644 tests/integration/test_agent_k8s.py rename tests/integration/{test_agent.py => test_agent_machine.py} (87%) diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml index 736e791..4eb51ff 100644 --- a/.github/workflows/integration_test.yaml +++ b/.github/workflows/integration_test.yaml @@ -6,6 +6,12 @@ on: jobs: integration-tests: uses: canonical/operator-workflows/.github/workflows/integration_test.yaml@main - with: - pre-run-script: tests/integration/pre_run_script.sh secrets: inherit + with: + pre-run-script: | + -c "sudo microk8s config > ${GITHUB_WORKSPACE}/kube-config + chmod +x tests/integration/pre_run_script.sh + ./tests/integration/pre_run_script.sh" + extra-arguments: | + --kube-config ${GITHUB_WORKSPACE}/kube-config + modules: '["test_agent_k8s.py", "test_agent_machine.py"]' diff --git a/jenkins_agent_k8s_rock/files/entrypoint.sh b/jenkins_agent_k8s_rock/files/entrypoint.sh index e892c23..6c6b4f7 100644 --- a/jenkins_agent_k8s_rock/files/entrypoint.sh +++ b/jenkins_agent_k8s_rock/files/entrypoint.sh @@ -36,7 +36,7 @@ touch "${JENKINS_HOME}/agents/.ready" # Start Jenkins agent echo "${JENKINS_AGENT}" -${JAVA} -jar ${AGENT_JAR} -jnlpUrl "${JENKINS_URL}/computer/${JENKINS_AGENT}/slave-agent.jnlp" -workDir "${JENKINS_HOME}" -noReconnect -secret "${JENKINS_TOKEN}" || echo "Invalid or already used credentials." +${JAVA} -jar ${AGENT_JAR} -jnlpUrl "${JENKINS_URL}/computer/${JENKINS_AGENT}/jenkins-agent.jnlp" -workDir "${JENKINS_HOME}" -noReconnect -secret "${JENKINS_TOKEN}" || echo "Invalid or already used credentials." # Remove ready mark if unsuccessful rm ${JENKINS_HOME}/agents/.ready diff --git a/src-docs/agent.py.md b/src-docs/agent.py.md index cbec0fc..68dd17a 100644 --- a/src-docs/agent.py.md +++ b/src-docs/agent.py.md @@ -43,4 +43,34 @@ Shortcut for more simple access the model. +--- + + + +### function `start_agent_from_relation` + +```python +start_agent_from_relation( + container: Container, + credentials: Credentials, + agent_name: str +) → None +``` + +Start agent from agent relation. + + + +**Args:** + + - `container`: The Jenkins agent workload container. + - `credentials`: The agent registration details for jenkins server. + - `agent_name`: The jenkins agent to register as. + + + +**Raises:** + + - `AgentJarDownloadError`: if the agent jar executable failed to download. + diff --git a/src/agent.py b/src/agent.py index 5d93328..2e4f41d 100644 --- a/src/agent.py +++ b/src/agent.py @@ -98,7 +98,7 @@ def _on_slave_relation_changed(self, event: ops.RelationChangedEvent) -> None: event: The event fired when slave relation data has changed. Raises: - RuntimeError: if the Jenkins agent failed to download. + AgentJarDownloadError: if the Jenkins agent failed to download. """ logger.info("%s relation changed.", event.relation.name) @@ -133,7 +133,7 @@ def _on_slave_relation_changed(self, event: ops.RelationChangedEvent) -> None: ) except server.AgentJarDownloadError as exc: logger.error("Failed to download Jenkins agent executable, %s", exc) - raise RuntimeError("Failed to download Jenkins agent.") from exc + raise self.charm.unit.status = ops.MaintenanceStatus("Validating credentials.") if not server.validate_credentials( @@ -152,25 +152,17 @@ def _on_slave_relation_changed(self, event: ops.RelationChangedEvent) -> None: self.charm.unit.status = ops.WaitingStatus("Waiting for credentials.") return - self.charm.unit.status = ops.MaintenanceStatus("Starting agent pebble service.") - self.pebble_service.reconcile( - server_url=self.state.slave_relation_credentials.address, - agent_token_pair=( - self.state.agent_meta.name, - self.state.slave_relation_credentials.secret, - ), + self.start_agent_from_relation( container=container, + credentials=self.state.slave_relation_credentials, + agent_name=self.state.agent_meta.name, ) - self.charm.unit.status = ops.ActiveStatus() def _on_agent_relation_changed(self, event: ops.RelationChangedEvent) -> None: """Handle agent relation changed event. Args: event: The event fired when the agent relation data has changed. - - Raises: - RuntimeError: if the Jenkins agent failed to download. """ logger.info("%s relation changed.", event.relation.name) @@ -198,21 +190,38 @@ def _on_agent_relation_changed(self, event: ops.RelationChangedEvent) -> None: event.defer() return + self.start_agent_from_relation( + container=container, + credentials=self.state.agent_relation_credentials, + agent_name=self.state.agent_meta.name, + ) + + def start_agent_from_relation( + self, container: ops.Container, credentials: server.Credentials, agent_name: str + ) -> None: + """Start agent from agent relation. + + Args: + container: The Jenkins agent workload container. + credentials: The agent registration details for jenkins server. + agent_name: The jenkins agent to register as. + + Raises: + AgentJarDownloadError: if the agent jar executable failed to download. + """ self.charm.unit.status = ops.MaintenanceStatus("Downloading Jenkins agent executable.") try: - server.download_jenkins_agent( - server_url=self.state.agent_relation_credentials.address, container=container - ) + server.download_jenkins_agent(server_url=credentials.address, container=container) except server.AgentJarDownloadError as exc: logger.error("Failed to download Jenkins agent executable, %s", exc) - raise RuntimeError("Failed to download Jenkins agent.") from exc + raise server.AgentJarDownloadError("Failed to download Jenkins agent.") from exc self.charm.unit.status = ops.MaintenanceStatus("Starting agent pebble service.") self.pebble_service.reconcile( - server_url=self.state.agent_relation_credentials.address, + server_url=credentials.address, agent_token_pair=( - self.state.agent_meta.name, - self.state.agent_relation_credentials.secret, + agent_name, + credentials.secret, ), container=container, ) diff --git a/src/charm.py b/src/charm.py index 1fcf32a..c93bd8d 100755 --- a/src/charm.py +++ b/src/charm.py @@ -41,6 +41,10 @@ def __init__(self, *args: typing.Any): self.framework.observe(self.on.config_changed, self._on_config_changed) self.framework.observe(self.on.upgrade_charm, self._on_upgrade_charm) + self.framework.observe( + self.on.jenkins_k8s_agent_pebble_ready, self._on_jenkins_k8s_agent_pebble_ready + ) + def _register_via_config( self, event: typing.Union[ops.ConfigChangedEvent, ops.UpgradeCharmEvent] ) -> None: @@ -50,7 +54,7 @@ def _register_via_config( event: The event fired on config changed or upgrade charm. Raises: - RuntimeError: if the Jenkins agent failed to download. + AgentJarDownloadError: if the Jenkins agent failed to download. """ container = self.unit.get_container(self.state.jenkins_agent_service_name) if not container.can_connect(): @@ -85,7 +89,7 @@ def _register_via_config( ) except server.AgentJarDownloadError as exc: logger.error("Failed to download Agent JAR executable, %s", exc) - raise RuntimeError("Failed to download Jenkins agent. Fix issue ") from exc + raise valid_agent_token = server.find_valid_credentials( agent_name_token_pairs=self.state.jenkins_config.agent_name_token_pairs, @@ -123,6 +127,25 @@ def _on_upgrade_charm(self, event: ops.UpgradeCharmEvent) -> None: """ self._register_via_config(event) + def _on_jenkins_k8s_agent_pebble_ready(self, _: ops.PebbleReadyEvent) -> None: + """Handle pebble ready event. + + Pebble ready is fired + 1. during initial charm launch. + 2. when the container has restarted for various reasons. + It is necessary to handle case 2 for recovery cases. + """ + container = self.unit.get_container(self.state.jenkins_agent_service_name) + if not container.can_connect() or not self.state.agent_relation_credentials: + logger.warning("Preconditions not ready.") + return + + self.agent_observer.start_agent_from_relation( + container=container, + credentials=self.state.agent_relation_credentials, + agent_name=self.state.agent_meta.name, + ) + if __name__ == "__main__": # pragma: no cover main(JenkinsAgentCharm) diff --git a/tests/conftest.py b/tests/conftest.py index 95b936b..e07cc1e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,3 +16,5 @@ def pytest_addoption(parser: pytest.Parser): parser.addoption("--jenkins-agent-k8s-image", action="store", default="") # The prebuilt charm file. parser.addoption("--charm-file", action="store", default="") + # The path to kubernetes config. + parser.addoption("--kube-config", action="store", default="~/.kube/config") diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index abfaca6..f632d12 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -8,6 +8,7 @@ import typing import jenkinsapi.jenkins +import kubernetes import pytest import pytest_asyncio from juju.action import Action @@ -15,6 +16,7 @@ from juju.client._definitions import FullStatus, UnitStatus from juju.model import Controller, Model from juju.unit import Unit +from pytest import FixtureRequest from pytest_operator.plugin import OpsTest logger = logging.getLogger(__name__) @@ -39,6 +41,23 @@ def model_fixture(ops_test: OpsTest) -> Model: return ops_test.model +@pytest.fixture(scope="module", name="kube_config") +def kube_config_fixture(request: FixtureRequest) -> str: + """The kubernetes config file path.""" + kube_config = request.config.getoption("--kube-config") + assert ( + kube_config + ), "--kube-confg argument is required which should contain the path to kube config." + return kube_config + + +@pytest.fixture(scope="module", name="kube_core_client") +def kube_core_client_fixture(kube_config: str) -> kubernetes.client.CoreV1Api: + """Create a kubernetes client for core v1 API.""" + kubernetes.config.load_kube_config(config_file=kube_config) + return kubernetes.client.CoreV1Api() + + @pytest.fixture(scope="module", name="agent_image") def agent_image_fixture(request: pytest.FixtureRequest) -> str: """The OCI image for jenkins-agent-k8s charm.""" @@ -108,8 +127,10 @@ async def jenkins_machine_server_fixture(machine_model: Model) -> Application: return app -@pytest_asyncio.fixture(scope="module", name="server_unit_ip") -async def server_unit_ip_fixture(machine_model: Model, jenkins_machine_server: Application): +@pytest_asyncio.fixture(scope="module", name="machine_server_unit_ip") +async def machine_server_unit_ip_fixture( + machine_model: Model, jenkins_machine_server: Application +): """Get Jenkins machine server charm unit IP.""" status: FullStatus = await machine_model.get_status([jenkins_machine_server.name]) try: @@ -122,16 +143,16 @@ async def server_unit_ip_fixture(machine_model: Model, jenkins_machine_server: A raise StopIteration("Invalid unit status") from exc -@pytest_asyncio.fixture(scope="module", name="web_address") -async def web_address_fixture(server_unit_ip: str): +@pytest_asyncio.fixture(scope="module", name="machine_web_address") +async def machine_web_address_fixture(machine_server_unit_ip: str): """Get Jenkins machine server charm web address.""" - return f"http://{server_unit_ip}:8080" + return f"http://{machine_server_unit_ip}:8080" -@pytest_asyncio.fixture(scope="module", name="jenkins_client") -async def jenkins_client_fixture( +@pytest_asyncio.fixture(scope="module", name="machine_jenkins_client") +async def machine_jenkins_client_fixture( jenkins_machine_server: Application, - web_address: str, + machine_web_address: str, ) -> jenkinsapi.jenkins.Jenkins: """The Jenkins API client.""" jenkins_unit: Unit = jenkins_machine_server.units[0] @@ -143,5 +164,53 @@ async def jenkins_client_fixture( # Initialization of the jenkins client will raise an exception if unable to connect to the # server. return jenkinsapi.jenkins.Jenkins( - baseurl=web_address, username="admin", password=password, timeout=60 + baseurl=machine_web_address, username="admin", password=password, timeout=60 + ) + + +@pytest_asyncio.fixture(scope="module", name="jenkins_k8s_server") +async def jenkins_k8s_server_fixture(model: Model) -> Application: + """The jenkins k8s server.""" + app = await model.deploy("jenkins-k8s", series="jammy", channel="latest/edge") + await model.wait_for_idle(apps=[app.name], timeout=1200, raise_on_error=False) + + return app + + +@pytest_asyncio.fixture(scope="module", name="k8s_server_unit_ip") +async def k8s_server_unit_ip_fixture(model: Model, jenkins_k8s_server: Application): + """Get Jenkins k8s server charm unit IP.""" + status: FullStatus = await model.get_status([jenkins_k8s_server.name]) + try: + unit_status: UnitStatus = next( + iter(status.applications[jenkins_k8s_server.name].units.values()) + ) + assert unit_status.address, "Invalid unit address" + return unit_status.address + except StopIteration as exc: + raise StopIteration("Invalid unit status") from exc + + +@pytest_asyncio.fixture(scope="module", name="k8s_web_address") +async def k8s_web_address_fixture(k8s_server_unit_ip: str): + """Get Jenkins k8s server charm web address.""" + return f"http://{k8s_server_unit_ip}:8080" + + +@pytest_asyncio.fixture(scope="module", name="jenkins_client") +async def jenkins_client_fixture( + jenkins_k8s_server: Application, + k8s_web_address: str, +) -> jenkinsapi.jenkins.Jenkins: + """The Jenkins API client.""" + jenkins_unit: Unit = jenkins_k8s_server.units[0] + action: Action = await jenkins_unit.run_action("get-admin-password") + await action.wait() + assert action.status == "completed", "Failed to get credentials." + password = action.results["password"] + + # Initialization of the jenkins client will raise an exception if unable to connect to the + # server. + return jenkinsapi.jenkins.Jenkins( + baseurl=k8s_web_address, username="admin", password=password, timeout=60 ) diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py new file mode 100644 index 0000000..c070a5a --- /dev/null +++ b/tests/integration/helpers.py @@ -0,0 +1,47 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Helpers for Jenkins-agent-k8s-operator charm integration tests.""" +import asyncio +import inspect +import time +import typing + + +async def wait_for( + func: typing.Callable[[], typing.Union[typing.Awaitable, typing.Any]], + timeout: int = 300, + check_interval: int = 10, +) -> typing.Any: + """Wait for function execution to become truthy. + + Args: + func: A callback function to wait to return a truthy value. + timeout: Time in seconds to wait for function result to become truthy. + check_interval: Time in seconds to wait between ready checks. + + Raises: + TimeoutError: if the callback function did not return a truthy value within timeout. + + Returns: + The result of the function if any. + """ + deadline = time.time() + timeout + is_awaitable = inspect.iscoroutinefunction(func) + while time.time() < deadline: + if is_awaitable: + if result := await func(): + return result + else: + if result := func(): + return result + await asyncio.sleep(check_interval) + + # final check before raising TimeoutError. + if is_awaitable: + if result := await func(): + return result + else: + if result := func(): + return result + raise TimeoutError() diff --git a/tests/integration/test_agent_k8s.py b/tests/integration/test_agent_k8s.py new file mode 100644 index 0000000..1111cfb --- /dev/null +++ b/tests/integration/test_agent_k8s.py @@ -0,0 +1,60 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Integration tests for jenkins-agent-k8s-operator charm with k8s server.""" + + +import logging + +import jenkinsapi.jenkins +import kubernetes +from juju.application import Application +from juju.model import Model +from juju.unit import Unit + +from .helpers import wait_for + +logger = logging.getLogger() + + +async def test_agent_recover( + kube_core_client: kubernetes.client.CoreV1Api, + model: Model, + application: Application, + jenkins_k8s_server: Application, + jenkins_client: jenkinsapi.jenkins.Jenkins, +): + """ + arrange: given a jenkins-agent-k8s charm that is related to jenkins-k8s charm. + act: when a pod is removed (restarted by kubernetes by default). + assert: the agent automatically re-registers itself. + """ + await model.relate(f"{application.name}:agent", f"{jenkins_k8s_server.name}:agent") + await model.wait_for_idle( + apps=[application.name, jenkins_k8s_server.name], wait_for_active=True + ) + agent_unit: Unit = next(iter(application.units)) + pod_name = agent_unit.name.replace("/", "-") + node: jenkinsapi.node.Node = jenkins_client.get_node(pod_name) + assert node.is_online(), "Node not online." + + kube_core_client.delete_namespaced_pod(name=pod_name, namespace=model.name) + await wait_for(lambda: not node.is_online(), timeout=60 * 10, check_interval=5) + + def containers_ready() -> bool: + """Check if all containers are ready. + + Returns: + True if containers are all ready. + """ + pod_status: kubernetes.client.V1PodStatus = kube_core_client.read_namespaced_pod_status( + name=pod_name, namespace=model.name + ).status + container_statuses: list[ + kubernetes.client.V1ContainerStatus + ] = pod_status.container_statuses + return all(status.ready for status in container_statuses) + + await wait_for(containers_ready, timeout=60 * 10) + await wait_for(node.is_online, timeout=60 * 10) + assert node.is_online(), "Node not online." diff --git a/tests/integration/test_agent.py b/tests/integration/test_agent_machine.py similarity index 87% rename from tests/integration/test_agent.py rename to tests/integration/test_agent_machine.py index 177635b..a2042ba 100644 --- a/tests/integration/test_agent.py +++ b/tests/integration/test_agent_machine.py @@ -1,7 +1,7 @@ # Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. -"""Integration tests for jenkins-agent-k8s-operator charm.""" +"""Integration tests for jenkins-agent-k8s-operator charm with machine server.""" import logging @@ -15,7 +15,7 @@ async def test_agent_relation( jenkins_machine_server: Application, application: Application, - jenkins_client: jenkinsapi.jenkins.Jenkins, + machine_jenkins_client: jenkinsapi.jenkins.Jenkins, num_agents: int, ): """ @@ -39,7 +39,7 @@ async def test_agent_relation( ) await model.wait_for_idle(status="active", timeout=1200) - nodes = jenkins_client.get_nodes() + nodes = machine_jenkins_client.get_nodes() assert all(node.is_online() for node in nodes.values()) # One of the nodes is the server node. assert len(nodes.values()) == num_agents + 1 diff --git a/tests/unit/test_agent.py b/tests/unit/test_agent.py index 60c05a0..f909595 100644 --- a/tests/unit/test_agent.py +++ b/tests/unit/test_agent.py @@ -262,7 +262,7 @@ def test_agent_relation_changed_download_jenkins_agent_fail( harness.begin() jenkins_charm = typing.cast(JenkinsAgentCharm, harness.charm) - with pytest.raises(RuntimeError) as exc: + with pytest.raises(server.AgentJarDownloadError) as exc: if relation == state.AGENT_RELATION: jenkins_charm.agent_observer._on_agent_relation_changed(mock_event) else: diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 0f9ce72..5c34924 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -6,12 +6,13 @@ # Need access to protected functions for testing # pylint:disable=protected-access +import secrets import typing -import unittest.mock +from unittest.mock import MagicMock import ops -import ops.testing import pytest +from ops.testing import Harness import server import state @@ -21,7 +22,7 @@ def test___init___invalid_state( - harness: ops.testing.Harness, monkeypatch: pytest.MonkeyPatch, raise_exception: typing.Callable + harness: Harness, monkeypatch: pytest.MonkeyPatch, raise_exception: typing.Callable ): """ arrange: given a monkeypatched State.from_charm that raises an InvalidState Error. @@ -42,7 +43,7 @@ def test___init___invalid_state( assert jenkins_charm.unit.status.message == invalid_state_message -def test__register_agent_from_config_container_not_ready(harness: ops.testing.Harness): +def test__register_agent_from_config_container_not_ready(harness: Harness): """ arrange: given a charm with a workload container that is not ready yet. act: when _register_agent_from_config is called. @@ -50,7 +51,7 @@ def test__register_agent_from_config_container_not_ready(harness: ops.testing.Ha """ harness.set_can_connect("jenkins-k8s-agent", False) harness.begin() - mock_event = unittest.mock.MagicMock(spec=ops.HookEvent) + mock_event = MagicMock(spec=ops.HookEvent) jenkins_charm = typing.cast(JenkinsAgentCharm, harness.charm) jenkins_charm._on_config_changed(mock_event) @@ -58,7 +59,7 @@ def test__register_agent_from_config_container_not_ready(harness: ops.testing.Ha mock_event.defer.assert_called_once() -def test__register_agent_from_config_no_config_state(harness: ops.testing.Harness): +def test__register_agent_from_config_no_config_state(harness: Harness): """ arrange: given a charm with no configured state nor relation. act: when _register_agent_from_config is called. @@ -66,7 +67,7 @@ def test__register_agent_from_config_no_config_state(harness: ops.testing.Harnes """ harness.set_can_connect("jenkins-k8s-agent", True) harness.begin() - mock_event = unittest.mock.MagicMock(spec=ops.HookEvent) + mock_event = MagicMock(spec=ops.HookEvent) jenkins_charm = typing.cast(JenkinsAgentCharm, harness.charm) jenkins_charm._on_config_changed(mock_event) @@ -75,7 +76,7 @@ def test__register_agent_from_config_no_config_state(harness: ops.testing.Harnes assert jenkins_charm.unit.status.message == "Waiting for config/relation." -def test__register_agent_from_config_use_relation(harness: ops.testing.Harness): +def test__register_agent_from_config_use_relation(harness: Harness): """ arrange: given a charm with an agent relation but no configured state. act: when _register_agent_from_config is called. @@ -84,7 +85,7 @@ def test__register_agent_from_config_use_relation(harness: ops.testing.Harness): harness.set_can_connect("jenkins-k8s-agent", True) harness.add_relation(state.AGENT_RELATION, "jenkins") harness.begin() - mock_event = unittest.mock.MagicMock(spec=ops.HookEvent) + mock_event = MagicMock(spec=ops.HookEvent) jenkins_charm = typing.cast(JenkinsAgentCharm, harness.charm) jenkins_charm._on_config_changed(mock_event) @@ -95,7 +96,7 @@ def test__register_agent_from_config_use_relation(harness: ops.testing.Harness): def test__register_agent_from_config_download_agent_error( monkeypatch: pytest.MonkeyPatch, raise_exception: typing.Callable, - harness: ops.testing.Harness, + harness: Harness, config: typing.Dict[str, str], ): """ @@ -111,18 +112,18 @@ def test__register_agent_from_config_download_agent_error( harness.set_can_connect("jenkins-k8s-agent", True) harness.update_config(config) harness.begin() - mock_event = unittest.mock.MagicMock(spec=ops.HookEvent) + mock_event = MagicMock(spec=ops.HookEvent) jenkins_charm = typing.cast(JenkinsAgentCharm, harness.charm) - with pytest.raises(RuntimeError) as exc: + with pytest.raises(server.AgentJarDownloadError) as exc: jenkins_charm._on_config_changed(mock_event) assert exc.value == "Failed to download Agent JAR executable." def test__register_agent_from_config_no_valid_credentials( monkeypatch: pytest.MonkeyPatch, - harness: ops.testing.Harness, + harness: Harness, config: typing.Dict[str, str], ): """ @@ -135,7 +136,7 @@ def test__register_agent_from_config_no_valid_credentials( harness.set_can_connect("jenkins-k8s-agent", True) harness.update_config(config) harness.begin() - mock_event = unittest.mock.MagicMock(spec=ops.HookEvent) + mock_event = MagicMock(spec=ops.HookEvent) jenkins_charm = typing.cast(JenkinsAgentCharm, harness.charm) jenkins_charm._on_config_changed(mock_event) @@ -145,7 +146,7 @@ def test__register_agent_from_config_no_valid_credentials( def test__register_agent_from_config_fallback_relation_slave( - harness: ops.testing.Harness, + harness: Harness, ): """ arrange: given a charm with reset config values and a slave relation. @@ -157,7 +158,7 @@ def test__register_agent_from_config_fallback_relation_slave( harness.add_relation(state.SLAVE_RELATION, "jenkins") harness.begin() - mock_event = unittest.mock.MagicMock(spec=ops.HookEvent) + mock_event = MagicMock(spec=ops.HookEvent) jenkins_charm = typing.cast(JenkinsAgentCharm, harness.charm) jenkins_charm._on_config_changed(mock_event) @@ -166,7 +167,7 @@ def test__register_agent_from_config_fallback_relation_slave( def test__register_agent_from_config_fallback_relation_agent( - harness: ops.testing.Harness, + harness: Harness, ): """ arrange: given a charm with reset config values and a agent relation. @@ -178,7 +179,7 @@ def test__register_agent_from_config_fallback_relation_agent( harness.add_relation(state.AGENT_RELATION, "jenkins") harness.begin() - mock_event = unittest.mock.MagicMock(spec=ops.HookEvent) + mock_event = MagicMock(spec=ops.HookEvent) jenkins_charm = typing.cast(JenkinsAgentCharm, harness.charm) jenkins_charm._on_config_changed(mock_event) @@ -188,7 +189,7 @@ def test__register_agent_from_config_fallback_relation_agent( def test__register_agent_from_config( monkeypatch: pytest.MonkeyPatch, - harness: ops.testing.Harness, + harness: Harness, config: typing.Dict[str, str], ): """ @@ -201,7 +202,7 @@ def test__register_agent_from_config( harness.set_can_connect("jenkins-k8s-agent", True) harness.update_config(config) harness.begin() - mock_event = unittest.mock.MagicMock(spec=ops.ConfigChangedEvent) + mock_event = MagicMock(spec=ops.ConfigChangedEvent) jenkins_charm = typing.cast(JenkinsAgentCharm, harness.charm) jenkins_charm._on_config_changed(mock_event) @@ -210,7 +211,7 @@ def test__register_agent_from_config( def test__on_upgrade_charm( - monkeypatch: pytest.MonkeyPatch, harness: ops.testing.Harness, config: typing.Dict[str, str] + monkeypatch: pytest.MonkeyPatch, harness: Harness, config: typing.Dict[str, str] ): """ arrange: given a charm with monkeypatched server functions that returns passing values. @@ -222,9 +223,77 @@ def test__on_upgrade_charm( harness.set_can_connect("jenkins-k8s-agent", True) harness.update_config(config) harness.begin() - mock_event = unittest.mock.MagicMock(spec=ops.UpgradeCharmEvent) + mock_event = MagicMock(spec=ops.UpgradeCharmEvent) jenkins_charm = typing.cast(JenkinsAgentCharm, harness.charm) jenkins_charm._on_upgrade_charm(mock_event) assert jenkins_charm.unit.status.name == ACTIVE_STATUS_NAME + + +def test__on_jenkins_k8s_agent_pebble_ready_container_not_ready( + harness: Harness, monkeypatch: pytest.MonkeyPatch +): + """ + arrange: given a charm container that is not yet connectable. + act: when _on_jenkins_k8s_agent_pebble_ready is called. + assert: the charm is not started. + """ + harness.begin() + charm = typing.cast(JenkinsAgentCharm, harness.charm) + monkeypatch.setattr( + server, + "download_jenkins_agent", + (mock_download_func := MagicMock(spec=server.download_jenkins_agent)), + ) + + charm._on_jenkins_k8s_agent_pebble_ready(MagicMock(spec=ops.PebbleReadyEvent)) + + mock_download_func.assert_not_called() + + +def test__on_jenkins_k8s_agent_pebble_ready_agent_download_error( + harness: Harness, monkeypatch: pytest.MonkeyPatch +): + """ + arrange: given a mocked server download that raises an error. + act: when _on_jenkins_k8s_agent_pebble_ready is called. + assert: RuntimeError is raised. + """ + harness.set_can_connect(state.State.jenkins_agent_service_name, True) + harness.begin() + charm = typing.cast(JenkinsAgentCharm, harness.charm) + charm.state.agent_relation_credentials = server.Credentials( + address="test", secret=secrets.token_hex(16) + ) + monkeypatch.setattr( + server, + "download_jenkins_agent", + MagicMock(spec=server.download_jenkins_agent, side_effect=[server.AgentJarDownloadError]), + ) + + with pytest.raises(server.AgentJarDownloadError): + charm._on_jenkins_k8s_agent_pebble_ready(MagicMock(spec=ops.PebbleReadyEvent)) + + +def test__on_jenkins_k8s_agent_pebble_ready(harness: Harness, monkeypatch: pytest.MonkeyPatch): + """ + arrange: given a mocked server functions. + act: when _on_jenkins_k8s_agent_pebble_ready is called. + assert: the charm is in ActiveStatus. + """ + harness.set_can_connect(state.State.jenkins_agent_service_name, True) + harness.begin() + charm = typing.cast(JenkinsAgentCharm, harness.charm) + charm.state.agent_relation_credentials = server.Credentials( + address="test", secret=secrets.token_hex(16) + ) + monkeypatch.setattr( + server, + "download_jenkins_agent", + MagicMock(spec=server.download_jenkins_agent), + ) + + charm._on_jenkins_k8s_agent_pebble_ready(MagicMock(spec=ops.PebbleReadyEvent)) + + assert charm.unit.status.name == ACTIVE_STATUS_NAME