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