diff --git a/charmcraft.yaml b/charmcraft.yaml index be52eb4..9794721 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -210,6 +210,13 @@ config: Ref: https://grafana.com/docs/agent/latest/static/configuration/flags/#report-information-usage type: boolean default: true + log_level: + description: | + Grafana Agent server log level (only log messages with the given severity + or above). Must be one of: [debug, info, warn, error]. + If not set, the Grafana Agent default (info) will be used. + type: string + default: info path_exclude: description: > Glob for a set of log files present in `/var/log` that should be ignored by Grafana Agent. diff --git a/src/grafana_agent.py b/src/grafana_agent.py index cc12387..0c0dfba 100644 --- a/src/grafana_agent.py +++ b/src/grafana_agent.py @@ -12,7 +12,7 @@ import socket from collections import namedtuple from dataclasses import dataclass -from typing import Any, Callable, Dict, List, Optional, Set, Union +from typing import Any, Callable, Dict, List, Optional, Set, Union, cast import yaml from charms.certificate_transfer_interface.v0.certificate_transfer import ( @@ -72,6 +72,7 @@ class CompoundStatus: # None = good; do not use ActiveStatus here. update_config: Optional[Union[BlockedStatus, WaitingStatus]] = None validation_error: Optional[BlockedStatus] = None + config_error: Optional[BlockedStatus] = None class GrafanaAgentCharm(CharmBase): @@ -150,6 +151,7 @@ def __init__(self, *args): for rules in [self.loki_rules_paths, self.dashboard_paths]: if not os.path.isdir(rules.dest): + rules.src.mkdir(parents=True, exist_ok=True) shutil.copytree(rules.src, rules.dest, dirs_exist_ok=True) self._remote_write = PrometheusRemoteWriteConsumer( @@ -518,6 +520,10 @@ def _update_status(self, *_): self.unit.status = self.status.validation_error return + if self.status.config_error: + self.unit.status = self.status.config_error + return + # Put charm in blocked status if all incoming relations are missing active_relations = {k for k, v in self.model.relations.items() if v} if not set(self.mandatory_relation_pairs.keys()).intersection(active_relations): @@ -750,7 +756,7 @@ def _server_config(self) -> dict: Returns: The dict representing the config """ - server_config: Dict[str, Any] = {"log_level": "info"} + server_config: Dict[str, Any] = {"log_level": self.log_level} if self.cert.enabled: server_config["http_tls_config"] = self.tls_config server_config["grpc_tls_config"] = self.tls_config @@ -1066,6 +1072,21 @@ def _instance_name(self) -> str: return socket.getfqdn() + @property + def log_level(self) -> str: + """The log level configured for the charm.""" + # Valid upstream log levels in server_config + # https://grafana.com/docs/agent/latest/static/configuration/server-config/#server_config + allowed_log_levels = ["debug", "info", "warn", "error"] + log_level = cast(str, self.config.get("log_level")).lower() + + if log_level not in allowed_log_levels: + message = "log_level must be one of {}".format(allowed_log_levels) + self.status.config_error = BlockedStatus(message) + logging.warning(message) + log_level = "info" + return log_level + def _reload_config(self, attempts: int = 10) -> None: """Reload the config file. diff --git a/tests/scenario/conftest.py b/tests/scenario/conftest.py index 0ded13b..b5831f8 100644 --- a/tests/scenario/conftest.py +++ b/tests/scenario/conftest.py @@ -1,19 +1,48 @@ -import shutil -from pathlib import Path, PosixPath +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. +from unittest.mock import PropertyMock, patch import pytest +from charms.tempo_coordinator_k8s.v0.charm_tracing import charm_tracing_disabled -from tests.scenario.helpers import CHARM_ROOT +@pytest.fixture +def placeholder_cfg_path(tmp_path): + return tmp_path / "foo.yaml" -class Vroot(PosixPath): - def clean(self) -> None: - shutil.rmtree(self) - shutil.copytree(CHARM_ROOT / "src", self / "src") +@pytest.fixture() +def mock_config_path(placeholder_cfg_path): + with patch("grafana_agent.CONFIG_PATH", placeholder_cfg_path): + yield -@pytest.fixture -def vroot(tmp_path) -> Path: - vroot = Vroot(str(tmp_path.absolute())) - vroot.clean() - return vroot + +@pytest.fixture(autouse=True) +def mock_snap(): + """Mock the charm's snap property so we don't access the host.""" + with patch("charm.GrafanaAgentMachineCharm.snap", new_callable=PropertyMock): + yield + + +@pytest.fixture(autouse=True) +def mock_refresh(): + """Mock the refresh call so we don't access the host.""" + with patch("snap_management._install_snap", new_callable=PropertyMock): + yield + + +CONFIG_MATRIX = [ + {"classic_snap": True}, + {"classic_snap": False}, +] + + +@pytest.fixture(params=CONFIG_MATRIX) +def charm_config(request): + return request.param + + +@pytest.fixture(autouse=True) +def mock_charm_tracing(): + with charm_tracing_disabled(): + yield diff --git a/tests/scenario/helpers.py b/tests/scenario/helpers.py index 61d61dd..14bb852 100644 --- a/tests/scenario/helpers.py +++ b/tests/scenario/helpers.py @@ -1,12 +1,15 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. -from pathlib import Path - -import yaml - -CHARM_ROOT = Path(__file__).parent.parent.parent - - -def get_charm_meta(charm_type) -> dict: - raw_meta = (CHARM_ROOT / "charmcraft").with_suffix(".yaml").read_text() - return yaml.safe_load(raw_meta) +from unittest.mock import MagicMock + + +def set_run_out(mock_run, returncode: int = 0, stdout: str = "", stderr: str = ""): + mock_stdout = MagicMock() + mock_stdout.configure_mock( + **{ + "returncode": returncode, + "stdout.decode.return_value": stdout, + "stderr.decode.return_value": stderr, + } + ) + mock_run.return_value = mock_stdout diff --git a/tests/scenario/test_machine_charm/test_alert_labels.py b/tests/scenario/test_alert_labels.py similarity index 88% rename from tests/scenario/test_machine_charm/test_alert_labels.py rename to tests/scenario/test_alert_labels.py index 945722a..bfeda06 100644 --- a/tests/scenario/test_machine_charm/test_alert_labels.py +++ b/tests/scenario/test_alert_labels.py @@ -4,7 +4,7 @@ import json import pytest -from scenario import Context, PeerRelation, Relation, State, SubordinateRelation +from ops.testing import Context, PeerRelation, Relation, State, SubordinateRelation import charm @@ -15,7 +15,7 @@ def use_mock_config_path(mock_config_path): yield -def test_metrics_alert_rule_labels(vroot, charm_config): +def test_metrics_alert_rule_labels(charm_config): """Check that metrics alert rules are labeled with principal topology.""" cos_agent_primary_data = { "config": json.dumps( @@ -97,9 +97,8 @@ def test_metrics_alert_rule_labels(vroot, charm_config): ) remote_write_relation = Relation("send-remote-write", remote_app_name="prometheus") - context = Context( + ctx = Context( charm_type=charm.GrafanaAgentMachineCharm, - charm_root=vroot, ) state = State( leader=True, @@ -112,11 +111,13 @@ def test_metrics_alert_rule_labels(vroot, charm_config): config=charm_config, ) - state_0 = context.run(event=cos_agent_primary_relation.changed_event, state=state) - state_1 = context.run(event=cos_agent_subordinate_relation.changed_event, state=state_0) - state_2 = context.run(event=remote_write_relation.joined_event, state=state_1) + state_0 = ctx.run(ctx.on.relation_changed(relation=cos_agent_primary_relation), state) + state_1 = ctx.run(ctx.on.relation_changed(relation=cos_agent_subordinate_relation), state_0) + state_2 = ctx.run(ctx.on.relation_joined(relation=remote_write_relation), state_1) - alert_rules = json.loads(state_2.relations[2].local_app_data["alert_rules"]) + alert_rules = json.loads( + state_2.get_relation(remote_write_relation.id).local_app_data["alert_rules"] + ) for group in alert_rules["groups"]: for rule in group["rules"]: if "grafana-agent_alertgroup_alerts" in group["name"]: diff --git a/tests/scenario/test_config.py b/tests/scenario/test_config.py new file mode 100644 index 0000000..a0af969 --- /dev/null +++ b/tests/scenario/test_config.py @@ -0,0 +1,50 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. +from unittest.mock import patch + +import pytest +import yaml +from ops import BlockedStatus +from ops.testing import Context, State + +import charm + + +@pytest.fixture(autouse=True) +def patch_all(placeholder_cfg_path): + with patch("grafana_agent.CONFIG_PATH", placeholder_cfg_path): + yield + + +@pytest.mark.parametrize("log_level", ("debug", "info", "warn", "error")) +def test_valid_config_log_level(placeholder_cfg_path, log_level): + """Asserts that all valid log_levels set the correct config.""" + # GIVEN a GrafanaAgentMachineCharm + with patch("charm.GrafanaAgentMachineCharm.is_ready", True): + ctx = Context(charm_type=charm.GrafanaAgentMachineCharm) + # WHEN the config option for log_level is set to a VALID option + ctx.run(ctx.on.start(), State(config={"log_level": log_level})) + + # THEN the config file has the correct server:log_level field + yaml_cfg = yaml.safe_load(placeholder_cfg_path.read_text()) + assert yaml_cfg["server"]["log_level"] == log_level + + +@patch("charm.GrafanaAgentMachineCharm.is_ready", True) +def test_invalid_config_log_level(placeholder_cfg_path): + """Asserts that an invalid log_level sets Blocked status.""" + # GIVEN a GrafanaAgentMachineCharm + ctx = Context(charm_type=charm.GrafanaAgentMachineCharm) + with ctx(ctx.on.start(), State(config={"log_level": "foo"})) as mgr: + # WHEN the config option for log_level is set to an invalid option + mgr.run() + # THEN a warning Juju debug-log is created + assert any( + log.level == "WARNING" and "log_level must be one of" in log.message + for log in ctx.juju_log + ) + # AND the charm goes into blocked status + assert isinstance(mgr.charm.unit.status, BlockedStatus) + # AND the config file defaults the server:log_level field to "info" + yaml_cfg = yaml.safe_load(placeholder_cfg_path.read_text()) + assert yaml_cfg["server"]["log_level"] == "info" diff --git a/tests/scenario/test_machine_charm/test_cos_agent_e2e.py b/tests/scenario/test_cos_agent_e2e.py similarity index 76% rename from tests/scenario/test_machine_charm/test_cos_agent_e2e.py rename to tests/scenario/test_cos_agent_e2e.py index abf53fc..6639312 100644 --- a/tests/scenario/test_machine_charm/test_cos_agent_e2e.py +++ b/tests/scenario/test_cos_agent_e2e.py @@ -1,9 +1,6 @@ # Copyright 2021 Canonical Ltd. # See LICENSE file for licensing details. import json -import os -import tempfile -from pathlib import Path from unittest.mock import MagicMock, PropertyMock, patch import pytest @@ -14,13 +11,7 @@ ) from ops.charm import CharmBase from ops.framework import Framework -from scenario import Context, PeerRelation, State, SubordinateRelation - - -@pytest.fixture -def placeholder_cfg_path(tmp_path): - return tmp_path / "foo.yaml" - +from ops.testing import Context, PeerRelation, State, SubordinateRelation PROVIDER_NAME = "mock-principal" PROM_RULE = """alert: HostCpuHighIowait @@ -59,28 +50,6 @@ def patch_all(placeholder_cfg_path): yield -@pytest.fixture(autouse=True) -def vroot(placeholder_cfg_path): - with tempfile.TemporaryDirectory() as vroot: - vroot = Path(vroot) - promroot = vroot / "src/prometheus_alert_rules" - lokiroot = vroot / "src/loki_alert_rules" - grafroot = vroot / "src/grafana_dashboards" - - promroot.mkdir(parents=True) - lokiroot.mkdir(parents=True) - grafroot.mkdir(parents=True) - - (promroot / "prom.rule").write_text(PROM_RULE) - (lokiroot / "loki.rule").write_text(LOKI_RULE) - (grafroot / "grafana_dashboard.json").write_text(GRAFANA_DASH) - - old_cwd = os.getcwd() - os.chdir(str(vroot)) - yield vroot - os.chdir(old_cwd) - - @pytest.fixture(autouse=True) def snap_is_installed(): with patch( @@ -140,22 +109,26 @@ def __init__(self, framework: Framework): @pytest.fixture -def provider_ctx(provider_charm, vroot): - return Context(charm_type=provider_charm, meta=provider_charm.META, charm_root=vroot) +def provider_ctx(provider_charm): + return Context(charm_type=provider_charm, meta=provider_charm.META) @pytest.fixture -def requirer_ctx(requirer_charm, vroot): - return Context(charm_type=requirer_charm, meta=requirer_charm.META, charm_root=vroot) +def requirer_ctx(requirer_charm): + return Context(charm_type=requirer_charm, meta=requirer_charm.META) def test_cos_agent_changed_no_remote_data(provider_ctx): cos_agent = SubordinateRelation("cos-agent") + state_out = provider_ctx.run( - cos_agent.changed_event(remote_unit_id=1), State(relations=[cos_agent]) + provider_ctx.on.relation_changed(relation=cos_agent, remote_unit=1), + State(relations=[cos_agent]), ) - config = json.loads(state_out.relations[0].local_unit_data[CosAgentPeersUnitData.KEY]) + config = json.loads( + state_out.get_relation(cos_agent.id).local_unit_data[CosAgentPeersUnitData.KEY] + ) assert config["metrics_alert_rules"] == {} assert config["log_alert_rules"] == {} assert len(config["dashboards"]) == 1 @@ -187,14 +160,15 @@ def test_subordinate_update(requirer_ctx): remote_unit_data={"config": json.dumps(config)}, ) state_out1 = requirer_ctx.run( - cos_agent1.changed_event(remote_unit_id=0), State(relations=[cos_agent1, peer]) + requirer_ctx.on.relation_changed(relation=cos_agent1, remote_unit=0), + State(relations=[cos_agent1, peer]), ) peer_out = state_out1.get_relations("peers")[0] peer_out_data = json.loads( peer_out.local_unit_data[f"{CosAgentPeersUnitData.KEY}-mock-principal/0"] ) assert peer_out_data["unit_name"] == f"{PROVIDER_NAME}/0" - assert peer_out_data["relation_id"] == str(cos_agent1.relation_id) + assert peer_out_data["relation_id"] == str(cos_agent1.id) assert peer_out_data["relation_name"] == cos_agent1.endpoint # passthrough as-is @@ -210,20 +184,20 @@ def test_subordinate_update(requirer_ctx): assert "http://localhost:4318" in urls -def test_cos_agent_wrong_rel_data(vroot, snap_is_installed, provider_ctx): +def test_cos_agent_wrong_rel_data(snap_is_installed, provider_ctx): # Step 1: principal charm is deployed and ends in "unknown" state provider_ctx.charm_spec.charm_type._log_slots = ( "charmed:frogs" # Set wrong type, must be a list ) cos_agent_rel = SubordinateRelation("cos-agent") state = State(relations=[cos_agent_rel]) - state_out = provider_ctx.run(cos_agent_rel.changed_event(remote_unit_id=1), state=state) - assert state_out.unit_status.name == "unknown" - found = False - for log in provider_ctx.juju_log: - if "ERROR" in log[0] and "Invalid relation data provided:" in log[1]: - found = True - break + state_out = provider_ctx.run( + provider_ctx.on.relation_changed(relation=cos_agent_rel, remote_unit=1), state + ) + assert state_out.unit_status.name == "unknown" - assert found is True + assert any( + log.level == "ERROR" and "Invalid relation data provided:" in log.message + for log in provider_ctx.juju_log + ) diff --git a/tests/scenario/test_machine_charm/conftest.py b/tests/scenario/test_machine_charm/conftest.py deleted file mode 100644 index b5831f8..0000000 --- a/tests/scenario/test_machine_charm/conftest.py +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright 2022 Canonical Ltd. -# See LICENSE file for licensing details. -from unittest.mock import PropertyMock, patch - -import pytest -from charms.tempo_coordinator_k8s.v0.charm_tracing import charm_tracing_disabled - - -@pytest.fixture -def placeholder_cfg_path(tmp_path): - return tmp_path / "foo.yaml" - - -@pytest.fixture() -def mock_config_path(placeholder_cfg_path): - with patch("grafana_agent.CONFIG_PATH", placeholder_cfg_path): - yield - - -@pytest.fixture(autouse=True) -def mock_snap(): - """Mock the charm's snap property so we don't access the host.""" - with patch("charm.GrafanaAgentMachineCharm.snap", new_callable=PropertyMock): - yield - - -@pytest.fixture(autouse=True) -def mock_refresh(): - """Mock the refresh call so we don't access the host.""" - with patch("snap_management._install_snap", new_callable=PropertyMock): - yield - - -CONFIG_MATRIX = [ - {"classic_snap": True}, - {"classic_snap": False}, -] - - -@pytest.fixture(params=CONFIG_MATRIX) -def charm_config(request): - return request.param - - -@pytest.fixture(autouse=True) -def mock_charm_tracing(): - with charm_tracing_disabled(): - yield diff --git a/tests/scenario/test_machine_charm/helpers.py b/tests/scenario/test_machine_charm/helpers.py deleted file mode 100644 index 14bb852..0000000 --- a/tests/scenario/test_machine_charm/helpers.py +++ /dev/null @@ -1,15 +0,0 @@ -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. -from unittest.mock import MagicMock - - -def set_run_out(mock_run, returncode: int = 0, stdout: str = "", stderr: str = ""): - mock_stdout = MagicMock() - mock_stdout.configure_mock( - **{ - "returncode": returncode, - "stdout.decode.return_value": stdout, - "stderr.decode.return_value": stderr, - } - ) - mock_run.return_value = mock_stdout diff --git a/tests/scenario/test_machine_charm/test_models.py b/tests/scenario/test_models.py similarity index 100% rename from tests/scenario/test_machine_charm/test_models.py rename to tests/scenario/test_models.py diff --git a/tests/scenario/test_machine_charm/test_multiple_subordinates.py b/tests/scenario/test_multiple_subordinates.py similarity index 72% rename from tests/scenario/test_machine_charm/test_multiple_subordinates.py rename to tests/scenario/test_multiple_subordinates.py index cf1e0c5..b738e12 100644 --- a/tests/scenario/test_machine_charm/test_multiple_subordinates.py +++ b/tests/scenario/test_multiple_subordinates.py @@ -4,7 +4,7 @@ import json import pytest -from scenario import Context, PeerRelation, State, SubordinateRelation +from ops.testing import Context, PeerRelation, State, SubordinateRelation import charm @@ -15,14 +15,7 @@ def use_mock_config_path(mock_config_path): yield -def test_juju_info_and_cos_agent(vroot, charm_config): - def post_event(charm: charm.GrafanaAgentMachineCharm): - assert len(charm._cos.dashboards) == 1 - assert len(charm._cos.snap_log_endpoints) == 1 - assert not charm._cos.logs_alerts - assert not charm._cos.metrics_alerts - assert len(charm._cos.metrics_jobs) == 1 - +def test_juju_info_and_cos_agent(charm_config): cos_agent_data = { "config": json.dumps( { @@ -45,9 +38,8 @@ def post_event(charm: charm.GrafanaAgentMachineCharm): "cos-agent", remote_app_name="hardware-observer", remote_unit_data=cos_agent_data ) - context = Context( + ctx = Context( charm_type=charm.GrafanaAgentMachineCharm, - charm_root=vroot, ) state = State( relations=[ @@ -57,17 +49,17 @@ def post_event(charm: charm.GrafanaAgentMachineCharm): ], config=charm_config, ) - context.run(event=cos_agent_relation.changed_event, state=state, post_event=post_event) + with ctx(ctx.on.relation_changed(cos_agent_relation), state) as mgr: + mgr.run() + assert len(mgr.charm._cos.dashboards) == 1 + assert len(mgr.charm._cos.snap_log_endpoints) == 1 + assert not mgr.charm._cos.logs_alerts + assert not mgr.charm._cos.metrics_alerts + assert len(mgr.charm._cos.metrics_jobs) == 1 -def test_two_cos_agent_relations(vroot, charm_config): - def post_event(charm: charm.GrafanaAgentMachineCharm): - assert len(charm._cos.dashboards) == 2 - assert len(charm._cos.snap_log_endpoints) == 2 - assert not charm._cos.logs_alerts - assert not charm._cos.metrics_alerts - assert len(charm._cos.metrics_jobs) == 2 +def test_two_cos_agent_relations(charm_config): cos_agent_primary_data = { "config": json.dumps( { @@ -111,9 +103,8 @@ def post_event(charm: charm.GrafanaAgentMachineCharm): "cos-agent", remote_app_name="subordinate", remote_unit_data=cos_agent_subordinate_data ) - context = Context( + ctx = Context( charm_type=charm.GrafanaAgentMachineCharm, - charm_root=vroot, ) state = State( relations=[ @@ -123,8 +114,13 @@ def post_event(charm: charm.GrafanaAgentMachineCharm): ], config=charm_config, ) - out_state = context.run(event=cos_agent_primary_relation.changed_event, state=state) - vroot.clean() - context.run( - event=cos_agent_subordinate_relation.changed_event, state=out_state, post_event=post_event - ) + out_state = ctx.run(ctx.on.relation_changed(relation=cos_agent_primary_relation), state) + + with ctx(ctx.on.relation_changed(relation=cos_agent_subordinate_relation), out_state) as mgr: + mgr.run() + + assert len(mgr.charm._cos.dashboards) == 2 + assert len(mgr.charm._cos.snap_log_endpoints) == 2 + assert not mgr.charm._cos.logs_alerts + assert not mgr.charm._cos.metrics_alerts + assert len(mgr.charm._cos.metrics_jobs) == 2 diff --git a/tests/scenario/test_machine_charm/test_peer_relation.py b/tests/scenario/test_peer_relation.py similarity index 81% rename from tests/scenario/test_machine_charm/test_peer_relation.py rename to tests/scenario/test_peer_relation.py index c415004..e2993ea 100644 --- a/tests/scenario/test_machine_charm/test_peer_relation.py +++ b/tests/scenario/test_peer_relation.py @@ -15,7 +15,7 @@ from cosl import GrafanaDashboard from ops.charm import CharmBase from ops.framework import Framework -from scenario import Context, PeerRelation, State, SubordinateRelation +from ops.testing import Context, PeerRelation, State, SubordinateRelation def encode_as_dashboard(dct: dict): @@ -72,17 +72,13 @@ def _on_cosagent_data_changed(self, _): def test_no_dashboards(): - state = State() - - def post_event(charm: MyRequirerCharm): - assert not charm.cosagent.dashboards - ctx = Context( charm_type=MyRequirerCharm, meta=MyRequirerCharm.META, ) - state = State() - ctx.run(state=state, event="update-status", post_event=post_event) + with ctx(ctx.on.update_status(), State()) as mgr: + mgr.run() + assert not mgr.charm.cosagent.dashboards def test_no_dashboards_peer(): @@ -90,14 +86,13 @@ def test_no_dashboards_peer(): state = State(relations=[peer_relation]) - def post_event(charm: MyRequirerCharm): - assert not charm.cosagent.dashboards - ctx = Context( charm_type=MyRequirerCharm, meta=MyRequirerCharm.META, ) - ctx.run(state=state, event="update-status", post_event=post_event) + with ctx(ctx.on.update_status(), state) as mgr: + mgr.run() + assert not mgr.charm.cosagent.dashboards def test_no_dashboards_peer_cosagent(): @@ -108,14 +103,13 @@ def test_no_dashboards_peer_cosagent(): state = State(relations=[peer_relation, cos_agent]) - def post_event(charm: MyRequirerCharm): - assert not charm.cosagent.dashboards - ctx = Context( charm_type=MyRequirerCharm, meta=MyRequirerCharm.META, ) - ctx.run(state=state, event=cos_agent.changed_event(remote_unit_id=0), post_event=post_event) + with ctx(ctx.on.relation_changed(relation=cos_agent, remote_unit=0), state) as mgr: + mgr.run() + assert not mgr.charm.cosagent.dashboards @pytest.mark.parametrize("leader", (True, False)) @@ -141,19 +135,15 @@ def test_cosagent_to_peer_data_flow_dashboards(leader): state = State(relations=[peer_relation, cos_agent], leader=leader) - def post_event(charm: MyRequirerCharm): - assert charm.cosagent.dashboards - ctx = Context( charm_type=MyRequirerCharm, meta=MyRequirerCharm.META, ) - state_out = ctx.run( - state=state, event=cos_agent.changed_event(remote_unit_id=0), post_event=post_event - ) + with ctx(ctx.on.relation_changed(relation=cos_agent, remote_unit=0), state) as mgr: + out = mgr.run() + assert mgr.charm.cosagent.dashboards - peer_relation_out = next(filter(lambda r: r.endpoint == "peers", state_out.relations)) - print(peer_relation_out.local_unit_data) + peer_relation_out = next(filter(lambda r: r.endpoint == "peers", out.relations)) peer_data = peer_relation_out.local_unit_data[f"{CosAgentPeersUnitData.KEY}-primary/0"] assert json.loads(peer_data)["dashboards"] == [encode_as_dashboard(raw_dashboard_1)] @@ -219,39 +209,29 @@ def test_cosagent_to_peer_data_flow_relation(leader): ], ) - def pre_event(charm: MyRequirerCharm): - dashboards = charm.cosagent.dashboards - assert len(dashboards) == 1 + ctx = Context( + charm_type=MyRequirerCharm, + meta=MyRequirerCharm.META, + ) - dash = dashboards[0] - assert dash["title"] == "title" - assert dash["content"] == raw_dashboard_1 + with ctx(ctx.on.relation_changed(relation=cos_agent_2, remote_unit=0), state) as mgr: + dashboards = mgr.charm.cosagent.dashboards + dash_0 = dashboards[0] + assert len(dashboards) == 1 + assert dash_0["title"] == "title" + assert dash_0["content"] == raw_dashboard_1 - def post_event(charm: MyRequirerCharm): - dashboards = charm.cosagent.dashboards - assert len(dashboards) == 2 + out = mgr.run() + dashboards = mgr.charm.cosagent.dashboards other_dash, dash = dashboards + assert len(dashboards) == 2 assert dash["title"] == "title" assert dash["content"] == raw_dashboard_1 - assert other_dash["title"] == "other_title" assert other_dash["content"] == raw_dashboard_2 - ctx = Context( - charm_type=MyRequirerCharm, - meta=MyRequirerCharm.META, - ) - state_out = ctx.run( - state=state, - event=cos_agent_2.changed_event(remote_unit_id=0), - pre_event=pre_event, - post_event=post_event, - ) - - peer_relation_out: PeerRelation = next( - filter(lambda r: r.endpoint == "peers", state_out.relations) - ) + peer_relation_out: PeerRelation = next(filter(lambda r: r.endpoint == "peers", out.relations)) # the dashboard we just received via cos-agent is now in our local peer databag peer_data_local = peer_relation_out.local_unit_data[ f"{CosAgentPeersUnitData.KEY}-other_primary/0" @@ -331,45 +311,34 @@ def test_cosagent_to_peer_data_app_vs_unit(leader): ], ) - def pre_event(charm: MyRequirerCharm): + ctx = Context( + charm_type=MyRequirerCharm, + meta=MyRequirerCharm.META, + ) + + with ctx(ctx.on.relation_changed(relation=cos_agent_2, remote_unit=0), state) as mgr: # verify that before the event is processed, the charm correctly gathers only 1 dashboard - dashboards = charm.cosagent.dashboards + dashboards = mgr.charm.cosagent.dashboards + dash_0 = dashboards[0] assert len(dashboards) == 1 + assert dash_0["title"] == "title" + assert dash_0["content"] == raw_dashboard_1 - dash = dashboards[0] - assert dash["title"] == "title" - assert dash["content"] == raw_dashboard_1 + out = mgr.run() - def post_event(charm: MyRequirerCharm): # after the event is processed, the charm has copied its primary's 'cos-agent' data into # its 'peers' peer databag, therefore there are now two dashboards. # The source of the dashboards is peer data. - - dashboards = charm.cosagent.dashboards + dashboards = mgr.charm.cosagent.dashboards + dash_0 = dashboards[0] + dash_1 = dashboards[1] assert len(dashboards) == 2 + assert dash_0["title"] == "other_title" + assert dash_0["content"] == raw_dashboard_2 + assert dash_1["title"] == "title" + assert dash_1["content"] == raw_dashboard_1 - dash = dashboards[0] - assert dash["title"] == "other_title" - assert dash["content"] == raw_dashboard_2 - - dash = dashboards[1] - assert dash["title"] == "title" - assert dash["content"] == raw_dashboard_1 - - ctx = Context( - charm_type=MyRequirerCharm, - meta=MyRequirerCharm.META, - ) - state_out = ctx.run( - state=state, - event=cos_agent_2.changed_event(remote_unit_id=0), - pre_event=pre_event, - post_event=post_event, - ) - - peer_relation_out: PeerRelation = next( - filter(lambda r: r.endpoint == "peers", state_out.relations) - ) + peer_relation_out: PeerRelation = next(filter(lambda r: r.endpoint == "peers", out.relations)) my_databag_peer_data = peer_relation_out.local_unit_data[ f"{CosAgentPeersUnitData.KEY}-other_primary/0" ] diff --git a/tests/scenario/test_machine_charm/test_relation_priority.py b/tests/scenario/test_relation_priority.py similarity index 54% rename from tests/scenario/test_machine_charm/test_relation_priority.py rename to tests/scenario/test_relation_priority.py index bb1907b..4051645 100644 --- a/tests/scenario/test_machine_charm/test_relation_priority.py +++ b/tests/scenario/test_relation_priority.py @@ -1,23 +1,14 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. import json -from pathlib import Path from unittest.mock import patch import pytest from cosl import GrafanaDashboard -from scenario import Context, PeerRelation, State, SubordinateRelation +from ops.testing import Context, PeerRelation, State, SubordinateRelation import charm -from tests.scenario.test_machine_charm.helpers import set_run_out - - -def trigger(evt: str, state: State, vroot: Path = None, **kwargs): - context = Context( - charm_type=charm.GrafanaAgentMachineCharm, - charm_root=vroot, - ) - return context.run(event=evt, state=state, **kwargs) +from tests.scenario.helpers import set_run_out @pytest.fixture @@ -32,52 +23,46 @@ def patch_all(placeholder_cfg_path): @patch("charm.subprocess.run") -def test_no_relations(mock_run, vroot, charm_config): - def post_event(charm: charm.GrafanaAgentMachineCharm): - assert not charm._cos.dashboards - assert not charm._cos.logs_alerts - assert not charm._cos.metrics_alerts - assert not charm._cos.metrics_jobs - assert not charm._cos.snap_log_endpoints - +def test_no_relations(mock_run, charm_config): set_run_out(mock_run, 0) - trigger("start", State(config=charm_config), post_event=post_event, vroot=vroot) + state = State(config=charm_config) + ctx = Context( + charm_type=charm.GrafanaAgentMachineCharm, + ) + with ctx(ctx.on.start(), state) as mgr: + mgr.run() + assert not mgr.charm._cos.dashboards + assert not mgr.charm._cos.logs_alerts + assert not mgr.charm._cos.metrics_alerts + assert not mgr.charm._cos.metrics_jobs + assert not mgr.charm._cos.snap_log_endpoints @patch("charm.subprocess.run") -def test_juju_info_relation(mock_run, vroot, charm_config): - def post_event(charm: charm.GrafanaAgentMachineCharm): - assert not charm._cos.dashboards - assert not charm._cos.logs_alerts - assert not charm._cos.metrics_alerts - assert not charm._cos.metrics_jobs - assert not charm._cos.snap_log_endpoints - +def test_juju_info_relation(mock_run, charm_config): set_run_out(mock_run, 0) - trigger( - "start", - State( - relations=[ - SubordinateRelation( - "juju-info", remote_unit_data={"config": json.dumps({"subordinate": True})} - ) - ], - config=charm_config, - ), - post_event=post_event, - vroot=vroot, + state = State( + relations=[ + SubordinateRelation( + "juju-info", remote_unit_data={"config": json.dumps({"subordinate": True})} + ) + ], + config=charm_config, ) + ctx = Context( + charm_type=charm.GrafanaAgentMachineCharm, + ) + with ctx(ctx.on.start(), state) as mgr: + mgr.run() + assert not mgr.charm._cos.dashboards + assert not mgr.charm._cos.logs_alerts + assert not mgr.charm._cos.metrics_alerts + assert not mgr.charm._cos.metrics_jobs + assert not mgr.charm._cos.snap_log_endpoints @patch("charm.subprocess.run") -def test_cos_machine_relation(mock_run, vroot, charm_config): - def post_event(charm: charm.GrafanaAgentMachineCharm): - assert charm._cos.dashboards - assert charm._cos.snap_log_endpoints - assert not charm._cos.logs_alerts - assert not charm._cos.metrics_alerts - assert charm._cos.metrics_jobs - +def test_cos_machine_relation(mock_run, charm_config): set_run_out(mock_run, 0) cos_agent_data = { @@ -109,33 +94,32 @@ def post_event(charm: charm.GrafanaAgentMachineCharm): } ) } - trigger( - "start", - State( - relations=[ - SubordinateRelation( - "cos-agent", - remote_app_name="mock-principal", - remote_unit_data=cos_agent_data, - ), - PeerRelation("peers", peers_data={1: peer_data}), - ], - config=charm_config, - ), - post_event=post_event, - vroot=vroot, + + state = State( + relations=[ + SubordinateRelation( + "cos-agent", + remote_app_name="mock-principal", + remote_unit_data=cos_agent_data, + ), + PeerRelation("peers", peers_data={1: peer_data}), + ], + config=charm_config, + ) + ctx = Context( + charm_type=charm.GrafanaAgentMachineCharm, ) + with ctx(ctx.on.start(), state) as mgr: + mgr.run() + assert mgr.charm._cos.dashboards + assert mgr.charm._cos.snap_log_endpoints + assert not mgr.charm._cos.logs_alerts + assert not mgr.charm._cos.metrics_alerts + assert mgr.charm._cos.metrics_jobs @patch("charm.subprocess.run") -def test_both_relations(mock_run, vroot, charm_config): - def post_event(charm: charm.GrafanaAgentMachineCharm): - assert charm._cos.dashboards - assert charm._cos.snap_log_endpoints - assert not charm._cos.logs_alerts - assert not charm._cos.metrics_alerts - assert charm._cos.metrics_jobs - +def test_both_relations(mock_run, charm_config): set_run_out(mock_run, 0) cos_agent_data = { @@ -168,10 +152,6 @@ def post_event(charm: charm.GrafanaAgentMachineCharm): ) } - context = Context( - charm_type=charm.GrafanaAgentMachineCharm, - charm_root=vroot, - ) state = State( relations=[ SubordinateRelation( @@ -184,4 +164,13 @@ def post_event(charm: charm.GrafanaAgentMachineCharm): ], config=charm_config, ) - context.run(event="start", state=state, post_event=post_event) + ctx = Context( + charm_type=charm.GrafanaAgentMachineCharm, + ) + with ctx(ctx.on.start(), state) as mgr: + mgr.run() + assert mgr.charm._cos.dashboards + assert mgr.charm._cos.snap_log_endpoints + assert not mgr.charm._cos.logs_alerts + assert not mgr.charm._cos.metrics_alerts + assert mgr.charm._cos.metrics_jobs diff --git a/tests/scenario/test_machine_charm/test_scrape_configs.py b/tests/scenario/test_scrape_configs.py similarity index 78% rename from tests/scenario/test_machine_charm/test_scrape_configs.py rename to tests/scenario/test_scrape_configs.py index 075ab64..71de5da 100644 --- a/tests/scenario/test_machine_charm/test_scrape_configs.py +++ b/tests/scenario/test_scrape_configs.py @@ -2,26 +2,17 @@ # Copyright 2022 Canonical Ltd. # See LICENSE file for licensing details. -import inspect import json -import tempfile import uuid -from pathlib import Path from unittest.mock import patch import pytest import yaml from charms.grafana_agent.v0.cos_agent import CosAgentProviderUnitData -from scenario import Context, Model, PeerRelation, Relation, State, SubordinateRelation +from ops.testing import Context, Model, PeerRelation, Relation, State, SubordinateRelation import charm -machine_meta = yaml.safe_load( - ( - Path(inspect.getfile(charm.GrafanaAgentMachineCharm)).parent.parent / "charmcraft.yaml" - ).read_text() -) - @pytest.fixture(autouse=True) def patch_all(placeholder_cfg_path): @@ -57,12 +48,6 @@ def mock_write(_, path, text): "cos-agent", remote_app_name="principal", remote_unit_data={data.KEY: data.json()} ) - vroot = tempfile.TemporaryDirectory() - vroot_path = Path(vroot.name) - vroot_path.joinpath("src", "loki_alert_rules").mkdir(parents=True) - vroot_path.joinpath("src", "prometheus_alert_rules").mkdir(parents=True) - vroot_path.joinpath("src", "grafana_dashboards").mkdir(parents=True) - my_uuid = str(uuid.uuid4()) with patch("charms.operator_libs_linux.v2.snap.SnapCache"), patch( @@ -76,8 +61,6 @@ def mock_write(_, path, text): ctx = Context( charm_type=charm.GrafanaAgentMachineCharm, - meta=machine_meta, - charm_root=vroot.name, ) ctx.run(state=state, event=cos_relation.changed_event) diff --git a/tests/scenario/test_setup_statuses.py b/tests/scenario/test_setup_statuses.py deleted file mode 100644 index 93d923c..0000000 --- a/tests/scenario/test_setup_statuses.py +++ /dev/null @@ -1,91 +0,0 @@ -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. -import dataclasses -from typing import Type -from unittest.mock import PropertyMock, patch - -import pytest -from ops import UnknownStatus, WaitingStatus -from ops.testing import CharmType -from scenario import Context, State - -import charm -import grafana_agent -from tests.scenario.helpers import get_charm_meta - - -@pytest.fixture(params=["lxd"]) -def substrate(request): - return request.param - - -@pytest.fixture -def charm_type(substrate) -> Type[CharmType]: - return {"lxd": charm.GrafanaAgentMachineCharm}[substrate] - - -@pytest.fixture -def mock_cfg_path(tmp_path): - return tmp_path / "foo.yaml" - - -@dataclasses.dataclass -class _MockProc: - returncode: int = 0 - stdout = "" - - -def _subp_run_mock(*a, **kw): - return _MockProc(0) - - -@pytest.fixture(autouse=True) -def patch_all(substrate, mock_cfg_path): - grafana_agent.CONFIG_PATH = mock_cfg_path - with patch("subprocess.run", _subp_run_mock): - yield - - -@pytest.fixture(autouse=True) -def mock_snap(): - """Mock the charm's snap property so we don't access the host.""" - with patch( - "charm.GrafanaAgentMachineCharm.snap", new_callable=PropertyMock - ) as mocked_property: - mock_snap = mocked_property.return_value - # Mock the .present property of the snap object so the start event doesn't try to configure - # anything - mock_snap.present = False - yield - - -@patch("charm.SnapManifest._get_system_arch", return_value="amd64") -def test_install(_mock_manifest_get_system_arch, charm_type, substrate, vroot): - context = Context( - charm_type, - meta=get_charm_meta(charm_type), - charm_root=vroot, - ) - out = context.run("install", State()) - - if substrate == "lxd": - assert out.unit_status == ("maintenance", "Installing grafana-agent snap") - - else: - assert out.unit_status == ("unknown", "") - - -def test_start(charm_type, substrate, vroot): - context = Context( - charm_type, - meta=get_charm_meta(charm_type), - charm_root=vroot, - ) - out = context.run("start", State()) - - if substrate == "lxd": - assert not grafana_agent.CONFIG_PATH.exists(), "config file written on start" - assert out.unit_status == WaitingStatus("waiting for agent to start") - - else: - assert out.unit_status == UnknownStatus() diff --git a/tests/scenario/test_start_statuses.py b/tests/scenario/test_start_statuses.py deleted file mode 100644 index c3235aa..0000000 --- a/tests/scenario/test_start_statuses.py +++ /dev/null @@ -1,84 +0,0 @@ -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. -import dataclasses -import inspect -from pathlib import Path -from unittest.mock import patch - -import pytest -import yaml -from scenario import Context, State - -import charm - -CHARM_ROOT = Path(__file__).parent.parent.parent - - -@pytest.fixture -def placeholder_cfg_path(tmp_path): - return tmp_path / "foo.yaml" - - -@dataclasses.dataclass -class _MockProc: - returncode: int = 0 - stdout: str = "" - - -def _subp_run_mock(*a, **kw): - return _MockProc(0) - - -@pytest.fixture(autouse=True) -def patch_all(placeholder_cfg_path): - with patch("subprocess.run", _subp_run_mock), patch( - "grafana_agent.CONFIG_PATH", placeholder_cfg_path - ): - yield - - -@pytest.fixture -def charm_meta() -> dict: - charm_source_path = Path(inspect.getfile(charm.GrafanaAgentMachineCharm)) - charm_root = charm_source_path.parent.parent - - raw_meta = (charm_root / "charmcraft").with_suffix(".yaml").read_text() - return yaml.safe_load(raw_meta) - - -def test_install(charm_meta, vroot): - ctx = Context( - charm_type=charm.GrafanaAgentMachineCharm, - meta=charm_meta, - charm_root=vroot, - ) - out = ctx.run(state=State(), event="install") - - assert out.unit_status == ("maintenance", "Installing grafana-agent snap") - - -def test_start_not_ready(charm_meta, vroot, placeholder_cfg_path): - with patch("charm.GrafanaAgentMachineCharm.is_ready", False): - ctx = Context( - charm_type=charm.GrafanaAgentMachineCharm, - meta=charm_meta, - charm_root=vroot, - ) - with ctx.manager(state=State(), event="start") as mgr: - assert not mgr.charm.is_ready - assert mgr.output.unit_status == ("waiting", "waiting for agent to start") - - -def test_start(charm_meta, vroot, placeholder_cfg_path): - with patch("charm.GrafanaAgentMachineCharm.is_ready", True): - ctx = Context( - charm_type=charm.GrafanaAgentMachineCharm, - meta=charm_meta, - charm_root=vroot, - ) - out = ctx.run(state=State(), event="start") - - written_cfg = placeholder_cfg_path.read_text() - assert written_cfg # check nonempty - - assert out.unit_status.name == "blocked" diff --git a/tests/scenario/test_machine_charm/test_tracing_configuration.py b/tests/scenario/test_tracing_configuration.py similarity index 87% rename from tests/scenario/test_machine_charm/test_tracing_configuration.py rename to tests/scenario/test_tracing_configuration.py index a6f05a5..af45151 100644 --- a/tests/scenario/test_machine_charm/test_tracing_configuration.py +++ b/tests/scenario/test_tracing_configuration.py @@ -5,7 +5,7 @@ import yaml from charms.grafana_agent.v0.cos_agent import ReceiverProtocol from charms.tempo_coordinator_k8s.v0.tracing import ReceiverProtocol as TracingReceiverProtocol -from scenario import Context, Relation, State, SubordinateRelation +from ops.testing import Context, Relation, State, SubordinateRelation from charm import GrafanaAgentMachineCharm from lib.charms.grafana_agent.v0.cos_agent import ( @@ -21,18 +21,17 @@ def test_cos_agent_receiver_protocols_match_with_tracing(): @pytest.mark.parametrize("protocol", get_args(TracingReceiverProtocol)) def test_always_enable_config_variables_are_generated_for_tracing_protocols( - protocol, vroot, mock_config_path, charm_config + protocol, mock_config_path, charm_config ): - context = Context( + ctx = Context( charm_type=GrafanaAgentMachineCharm, - charm_root=vroot, ) state = State( config={f"always_enable_{protocol}": True, **charm_config}, leader=True, relations=[], ) - with context.manager("config-changed", state) as mgr: + with ctx(ctx.on.config_changed(), state) as mgr: charm: GrafanaAgentMachineCharm = mgr.charm assert protocol in charm.requested_tracing_protocols @@ -52,12 +51,11 @@ def test_always_enable_config_variables_are_generated_for_tracing_protocols( ), ) def test_tracing_sampling_config_is_present( - vroot, placeholder_cfg_path, mock_config_path, sampling_config + placeholder_cfg_path, mock_config_path, sampling_config ): # GIVEN a tracing relation over the tracing-provider endpoint and one over tracing - context = Context( + ctx = Context( charm_type=GrafanaAgentMachineCharm, - charm_root=vroot, ) tracing_provider = SubordinateRelation( "cos-agent", @@ -86,7 +84,7 @@ def test_tracing_sampling_config_is_present( state = State(leader=True, relations=[tracing, tracing_provider], config=sampling_config) # WHEN we process any setup event for the relation with patch("charm.GrafanaAgentMachineCharm.is_ready", True): - context.run("config_changed", state) + ctx.run(ctx.on.config_changed(), state) yml = yaml.safe_load(placeholder_cfg_path.read_text()) diff --git a/tox.ini b/tox.ini index 695744d..a30244b 100644 --- a/tox.ini +++ b/tox.ini @@ -70,9 +70,9 @@ deps = -r{toxinidir}/requirements.txt pytest cosl - ops-scenario~=6.1 + ops[testing] commands = - pytest -vv --tb native --log-cli-level=INFO -s {posargs} {[vars]tst_path}/scenario/test_machine_charm + pytest -vv --tb native --log-cli-level=INFO -s {posargs} {[vars]tst_path}/scenario [testenv:integration] description = Run integration tests