Skip to content

Commit

Permalink
Add config option for log_level (#217)
Browse files Browse the repository at this point in the history
* Add config option for log_level
* Migrate to ops.testing (Scenario v7)
* Merge the test_machine_charm dir with scenario dir root
* Set BlockedStatus on invalid config
* Add status.config_error
  • Loading branch information
MichaelThamm authored Dec 10, 2024
1 parent c0382b7 commit 5ea3a57
Show file tree
Hide file tree
Showing 18 changed files with 310 additions and 528 deletions.
7 changes: 7 additions & 0 deletions charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 23 additions & 2 deletions src/grafana_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
53 changes: 41 additions & 12 deletions tests/scenario/conftest.py
Original file line number Diff line number Diff line change
@@ -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
23 changes: 13 additions & 10 deletions tests/scenario/helpers.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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"]:
Expand Down
50 changes: 50 additions & 0 deletions tests/scenario/test_config.py
Original file line number Diff line number Diff line change
@@ -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"
Loading

0 comments on commit 5ea3a57

Please sign in to comment.