diff --git a/maas-agent/lib/charms/grafana_agent/v0/cos_agent.py b/maas-agent/lib/charms/grafana_agent/v0/cos_agent.py index cc4da25..1ea79a6 100644 --- a/maas-agent/lib/charms/grafana_agent/v0/cos_agent.py +++ b/maas-agent/lib/charms/grafana_agent/v0/cos_agent.py @@ -22,7 +22,6 @@ Using the `COSAgentProvider` object only requires instantiating it, typically in the `__init__` method of your charm (the one which sends telemetry). -The constructor of `COSAgentProvider` has only one required and ten optional parameters: ```python def __init__( @@ -253,7 +252,7 @@ class _MetricsEndpointDict(TypedDict): LIBID = "dc15fa84cef84ce58155fb84f6c6213a" LIBAPI = 0 -LIBPATCH = 11 +LIBPATCH = 12 PYDEPS = ["cosl", "pydantic"] diff --git a/maas-agent/lib/charms/operator_libs_linux/v2/snap.py b/maas-agent/lib/charms/operator_libs_linux/v2/snap.py index 9d09a78..d84b7d8 100644 --- a/maas-agent/lib/charms/operator_libs_linux/v2/snap.py +++ b/maas-agent/lib/charms/operator_libs_linux/v2/snap.py @@ -64,6 +64,7 @@ import socket import subprocess import sys +import time import urllib.error import urllib.parse import urllib.request @@ -83,7 +84,7 @@ # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 7 +LIBPATCH = 8 # Regex to locate 7-bit C1 ANSI sequences @@ -332,7 +333,7 @@ def get(self, key: Optional[str], *, typed: bool = False) -> Any: return self._snap("get", [key]).strip() - def set(self, config: Dict[str, Any], *, typed: bool = False) -> str: + def set(self, config: Dict[str, Any], *, typed: bool = False) -> None: """Set a snap configuration value. Args: @@ -340,11 +341,9 @@ def set(self, config: Dict[str, Any], *, typed: bool = False) -> str: typed: set to True to convert all values in the config into typed values while configuring the snap (set with typed=True). Default is not to convert. """ - if typed: - kv = [f"{key}={json.dumps(val)}" for key, val in config.items()] - return self._snap("set", ["-t"] + kv) - - return self._snap("set", [f"{key}={val}" for key, val in config.items()]) + if not typed: + config = {k: str(v) for k, v in config.items()} + self._snap_client._put_snap_conf(self._name, config) def unset(self, key) -> str: """Unset a snap configuration value. @@ -770,7 +769,33 @@ def _request( headers["Content-Type"] = "application/json" response = self._request_raw(method, path, query, headers, data) - return json.loads(response.read().decode())["result"] + response = json.loads(response.read().decode()) + if response["type"] == "async": + return self._wait(response["change"]) + return response["result"] + + def _wait(self, change_id: str, timeout=300) -> JSONType: + """Wait for an async change to complete. + + The poll time is 100 milliseconds, the same as in snap clients. + """ + deadline = time.time() + timeout + while True: + if time.time() > deadline: + raise TimeoutError(f"timeout waiting for snap change {change_id}") + response = self._request("GET", f"changes/{change_id}") + status = response["status"] + if status == "Done": + return response.get("data") + if status == "Doing": + time.sleep(0.1) + continue + if status == "Wait": + logger.warning("snap change %s succeeded with status 'Wait'", change_id) + return response.get("data") + raise SnapError( + f"snap change {response.get('kind')!r} id {change_id} failed with status {status}" + ) def _request_raw( self, @@ -818,6 +843,10 @@ def get_installed_snap_apps(self, name: str) -> List: """Query the snap server for apps belonging to a named, currently installed snap.""" return self._request("GET", "apps", {"names": name, "select": "service"}) + def _put_snap_conf(self, name: str, conf: Dict[str, Any]): + """Set the configuration details for an installed snap.""" + return self._request("PUT", f"snaps/{name}/conf", body=conf) + class SnapCache(Mapping): """An abstraction to represent installed/available packages. diff --git a/maas-agent/pyproject.toml b/maas-agent/pyproject.toml index 7801665..ff58f10 100644 --- a/maas-agent/pyproject.toml +++ b/maas-agent/pyproject.toml @@ -1,47 +1,54 @@ -# Testing tools configuration -[tool.coverage.run] -branch = true +# Linting tools configuration -[tool.coverage.report] -show_missing = true +[tool.ruff] +target-version = "py38" +line-length = 99 +extend-exclude = [ "*.egg_info", "__pycache__" ] + +lint.select = [ + "C", + "D", + "E", + "F", + "F401", + "I", + "I001", + "N", + "RUF", + "UP", + "W", +] +lint.ignore = [ "D107", "E501" ] +lint.extend-ignore = [ + "D203", + "D204", + "D213", + "D215", + "D400", + "D404", + "D406", + "D407", + "D408", + "D409", + "D413", +] +lint.per-file-ignores = { "tests/*" = [ "D100", "D101", "D102", "D103", "D104" ] } +lint.mccabe.max-complexity = 10 + +[tool.codespell] +skip = "build,lib,venv,icon.svg,.tox,.git,.mypy_cache,.ruff_cache,.coverage" [tool.pytest.ini_options] minversion = "6.0" log_cli_level = "INFO" # Formatting tools configuration -[tool.black] -line-length = 99 -target-version = ["py38"] - -# Linting tools configuration -[tool.ruff] -line-length = 99 -extend-exclude = ["__pycache__", "*.egg_info"] - -[tool.ruff.lint] -select = ["E", "W", "F", "C", "N", "D", "I001"] -extend-ignore = [ - "D203", - "D204", - "D213", - "D215", - "D400", - "D404", - "D406", - "D407", - "D408", - "D409", - "D413", -] -ignore = ["E501", "D107"] -per-file-ignores = { "tests/*" = ["D100", "D101", "D102", "D103", "D104"] } -[tool.ruff.lint.mccabe] -max-complexity = 10 +[tool.coverage.run] +branch = true -[tool.codespell] -skip = "build,lib,venv,icon.svg,.tox,.git,.mypy_cache,.ruff_cache,.coverage" +[tool.coverage.report] +show_missing = true [tool.pyright] -include = ["src/**.py"] +include = [ "src/**.py" ] diff --git a/maas-agent/tests/unit/test_helper.py b/maas-agent/tests/unit/test_helper.py index 5faa535..9853d49 100644 --- a/maas-agent/tests/unit/test_helper.py +++ b/maas-agent/tests/unit/test_helper.py @@ -12,7 +12,6 @@ class TestHelperSnapCache(unittest.TestCase): - def _setup_snap( self, mock_snap, diff --git a/maas-agent/tox.ini b/maas-agent/tox.ini index ce4f042..bec0e44 100644 --- a/maas-agent/tox.ini +++ b/maas-agent/tox.ini @@ -1,105 +1,109 @@ -# Copyright 2024 Canonical -# See LICENSE file for licensing details. - [tox] -no_package = True -skip_missing_interpreters = True -env_list = format, lint, static-{charm,lib}, unit, scenario -min_version = 4.0.0 +requires = + tox>=4.2 +env_list = + format + lint + static-{charm, lib} + unit + scenario +no_package = true +skip_missing_interpreters = true basepython = py38 -[vars] -src_path = {tox_root}/src -tests_path = {tox_root}/tests -lib_path = {tox_root}/lib/charms/maas_region/v0/ -all_path = {[vars]src_path} {[vars]tests_path} {[vars]lib_path} - [testenv] -set_env = - PYTHONPATH = {tox_root}/lib:{[vars]src_path} - PYTHONBREAKPOINT=pdb.set_trace - PY_COLORS=1 pass_env = - PYTHONPATH CHARM_BUILD_DIR MODEL_SETTINGS + PYTHONPATH +set_env = + PYTHONBREAKPOINT = pdb.set_trace + PYTHONPATH = {tox_root}/lib:{[vars]src_path} + PY_COLORS = 1 [testenv:format] description = Apply coding style standards to code deps = - black + pyproject-fmt ruff + tox-ini-fmt commands = - black {[vars]all_path} + ruff format {[vars]all_path} ruff check --fix {[vars]all_path} + - pyproject-fmt pyproject.toml + - tox-ini-fmt tox.ini [testenv:lint] description = Check code against coding style standards deps = - black - ruff codespell + pyproject-fmt + ruff commands = - # if this charm owns a lib, uncomment "lib_path" variable - # and uncomment the following line - # codespell {[vars]lib_path} codespell {tox_root} ruff check {[vars]all_path} - black --check --diff {[vars]all_path} + pyproject-fmt --check pyproject.toml [testenv:unit] description = Run unit tests deps = - pytest - coverage[toml] -r {tox_root}/requirements.txt + coverage[toml] + pytest commands = coverage run --source={[vars]src_path} \ - -m pytest \ - --tb native \ - -v \ - -s \ - {posargs} \ - {[vars]tests_path}/unit + -m pytest \ + --tb native \ + -v \ + -s \ + {posargs} \ + {[vars]tests_path}/unit coverage report +[testenv:scenario] +description = Run scenario tests +deps = + -r {tox_root}/requirements.txt + cosl + ops-scenario + pytest +commands = + pytest -v -s --tb native {posargs} --log-cli-level=INFO {[vars]tests_path}/scenario + [testenv:static-{charm,lib}] description = Run static analysis checks deps = pyright typing-extensions charm: -r{toxinidir}/requirements.txt - lib: ops + integration: {[testenv:integration]deps} lib: jinja2 + lib: ops unit: {[testenv:unit]deps} - integration: {[testenv:integration]deps} commands = charm: pyright --pythonversion 3.8 {[vars]src_path} {posargs} lib: pyright --pythonversion 3.8 {[vars]lib_path} {posargs} lib: /usr/bin/env sh -c 'for m in $(git diff main --name-only --line-prefix=`git rev-parse --show-toplevel`/ {[vars]lib_path}); do if ! git diff main $m | grep -q "+LIBPATCH\|+LIBAPI"; then echo "You forgot to bump the version on $m!"; exit 1; fi; done' -allowlist_externals = /usr/bin/env +allowlist_externals = + /usr/bin/env [testenv:integration] description = Run integration tests deps = - pytest + -r {tox_root}/requirements.txt juju + pytest pytest-operator - -r {tox_root}/requirements.txt commands = pytest -v \ - -s \ - --tb native \ - --log-cli-level=INFO \ - {posargs} \ - {[vars]tests_path}/integration + -s \ + --tb native \ + --log-cli-level=INFO \ + {posargs} \ + {[vars]tests_path}/integration -[testenv:scenario] -description = Run scenario tests -deps = - pytest - cosl - ops-scenario - -r {tox_root}/requirements.txt -commands = - pytest -v -s --tb native {posargs} --log-cli-level=INFO {[vars]tests_path}/scenario +[vars] +src_path = {tox_root}/src +tests_path = {tox_root}/tests +lib_path = {tox_root}/lib/charms/maas_region/v0/ +all_path = {[vars]src_path} {[vars]tests_path} {[vars]lib_path} diff --git a/maas-region/.charm_tracing_buffer.raw b/maas-region/.charm_tracing_buffer.raw new file mode 100644 index 0000000..8d4c32d Binary files /dev/null and b/maas-region/.charm_tracing_buffer.raw differ diff --git a/maas-region/charmcraft.yaml b/maas-region/charmcraft.yaml index 35df7d4..17b0e24 100644 --- a/maas-region/charmcraft.yaml +++ b/maas-region/charmcraft.yaml @@ -159,3 +159,7 @@ config: default: "" description: CA Certificates chain in PEM format type: string + enable_prometheus_metrics: + default: true + description: Whether to enable Prometheus metrics for MAAS + type: boolean diff --git a/maas-region/lib/charms/grafana_agent/v0/cos_agent.py b/maas-region/lib/charms/grafana_agent/v0/cos_agent.py index cc4da25..1ea79a6 100644 --- a/maas-region/lib/charms/grafana_agent/v0/cos_agent.py +++ b/maas-region/lib/charms/grafana_agent/v0/cos_agent.py @@ -22,7 +22,6 @@ Using the `COSAgentProvider` object only requires instantiating it, typically in the `__init__` method of your charm (the one which sends telemetry). -The constructor of `COSAgentProvider` has only one required and ten optional parameters: ```python def __init__( @@ -253,7 +252,7 @@ class _MetricsEndpointDict(TypedDict): LIBID = "dc15fa84cef84ce58155fb84f6c6213a" LIBAPI = 0 -LIBPATCH = 11 +LIBPATCH = 12 PYDEPS = ["cosl", "pydantic"] diff --git a/maas-region/lib/charms/operator_libs_linux/v2/snap.py b/maas-region/lib/charms/operator_libs_linux/v2/snap.py index 9d09a78..d84b7d8 100644 --- a/maas-region/lib/charms/operator_libs_linux/v2/snap.py +++ b/maas-region/lib/charms/operator_libs_linux/v2/snap.py @@ -64,6 +64,7 @@ import socket import subprocess import sys +import time import urllib.error import urllib.parse import urllib.request @@ -83,7 +84,7 @@ # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 7 +LIBPATCH = 8 # Regex to locate 7-bit C1 ANSI sequences @@ -332,7 +333,7 @@ def get(self, key: Optional[str], *, typed: bool = False) -> Any: return self._snap("get", [key]).strip() - def set(self, config: Dict[str, Any], *, typed: bool = False) -> str: + def set(self, config: Dict[str, Any], *, typed: bool = False) -> None: """Set a snap configuration value. Args: @@ -340,11 +341,9 @@ def set(self, config: Dict[str, Any], *, typed: bool = False) -> str: typed: set to True to convert all values in the config into typed values while configuring the snap (set with typed=True). Default is not to convert. """ - if typed: - kv = [f"{key}={json.dumps(val)}" for key, val in config.items()] - return self._snap("set", ["-t"] + kv) - - return self._snap("set", [f"{key}={val}" for key, val in config.items()]) + if not typed: + config = {k: str(v) for k, v in config.items()} + self._snap_client._put_snap_conf(self._name, config) def unset(self, key) -> str: """Unset a snap configuration value. @@ -770,7 +769,33 @@ def _request( headers["Content-Type"] = "application/json" response = self._request_raw(method, path, query, headers, data) - return json.loads(response.read().decode())["result"] + response = json.loads(response.read().decode()) + if response["type"] == "async": + return self._wait(response["change"]) + return response["result"] + + def _wait(self, change_id: str, timeout=300) -> JSONType: + """Wait for an async change to complete. + + The poll time is 100 milliseconds, the same as in snap clients. + """ + deadline = time.time() + timeout + while True: + if time.time() > deadline: + raise TimeoutError(f"timeout waiting for snap change {change_id}") + response = self._request("GET", f"changes/{change_id}") + status = response["status"] + if status == "Done": + return response.get("data") + if status == "Doing": + time.sleep(0.1) + continue + if status == "Wait": + logger.warning("snap change %s succeeded with status 'Wait'", change_id) + return response.get("data") + raise SnapError( + f"snap change {response.get('kind')!r} id {change_id} failed with status {status}" + ) def _request_raw( self, @@ -818,6 +843,10 @@ def get_installed_snap_apps(self, name: str) -> List: """Query the snap server for apps belonging to a named, currently installed snap.""" return self._request("GET", "apps", {"names": name, "select": "service"}) + def _put_snap_conf(self, name: str, conf: Dict[str, Any]): + """Set the configuration details for an installed snap.""" + return self._request("PUT", f"snaps/{name}/conf", body=conf) + class SnapCache(Mapping): """An abstraction to represent installed/available packages. diff --git a/maas-region/pyproject.toml b/maas-region/pyproject.toml index 7801665..ff58f10 100644 --- a/maas-region/pyproject.toml +++ b/maas-region/pyproject.toml @@ -1,47 +1,54 @@ -# Testing tools configuration -[tool.coverage.run] -branch = true +# Linting tools configuration -[tool.coverage.report] -show_missing = true +[tool.ruff] +target-version = "py38" +line-length = 99 +extend-exclude = [ "*.egg_info", "__pycache__" ] + +lint.select = [ + "C", + "D", + "E", + "F", + "F401", + "I", + "I001", + "N", + "RUF", + "UP", + "W", +] +lint.ignore = [ "D107", "E501" ] +lint.extend-ignore = [ + "D203", + "D204", + "D213", + "D215", + "D400", + "D404", + "D406", + "D407", + "D408", + "D409", + "D413", +] +lint.per-file-ignores = { "tests/*" = [ "D100", "D101", "D102", "D103", "D104" ] } +lint.mccabe.max-complexity = 10 + +[tool.codespell] +skip = "build,lib,venv,icon.svg,.tox,.git,.mypy_cache,.ruff_cache,.coverage" [tool.pytest.ini_options] minversion = "6.0" log_cli_level = "INFO" # Formatting tools configuration -[tool.black] -line-length = 99 -target-version = ["py38"] - -# Linting tools configuration -[tool.ruff] -line-length = 99 -extend-exclude = ["__pycache__", "*.egg_info"] - -[tool.ruff.lint] -select = ["E", "W", "F", "C", "N", "D", "I001"] -extend-ignore = [ - "D203", - "D204", - "D213", - "D215", - "D400", - "D404", - "D406", - "D407", - "D408", - "D409", - "D413", -] -ignore = ["E501", "D107"] -per-file-ignores = { "tests/*" = ["D100", "D101", "D102", "D103", "D104"] } -[tool.ruff.lint.mccabe] -max-complexity = 10 +[tool.coverage.run] +branch = true -[tool.codespell] -skip = "build,lib,venv,icon.svg,.tox,.git,.mypy_cache,.ruff_cache,.coverage" +[tool.coverage.report] +show_missing = true [tool.pyright] -include = ["src/**.py"] +include = [ "src/**.py" ] diff --git a/maas-region/src/charm.py b/maas-region/src/charm.py index d6591ae..a4cd7af 100755 --- a/maas-region/src/charm.py +++ b/maas-region/src/charm.py @@ -6,9 +6,11 @@ import json import logging +import random import socket +import string import subprocess -from typing import Any, List, Union +from typing import Any, Dict, List, Union import ops import yaml @@ -18,6 +20,7 @@ from charms.operator_libs_linux.v2.snap import SnapError from charms.tempo_coordinator_k8s.v0.charm_tracing import trace_charm from charms.tempo_coordinator_k8s.v0.tracing import TracingEndpointRequirer, charm_tracing_config +from ops.model import SecretNotFoundError from helper import MaasHelper @@ -55,6 +58,9 @@ *[ops.Port("tcp", p) for p in range(5280, 5284 + 1)], # Temporal ] +MAAS_ADMIN_SECRET_LABEL = "maas-admin" +MAAS_ADMIN_SECRET_KEY = "maas-admin-secret-uri" + @trace_charm( tracing_endpoint="charm_tracing_endpoint", @@ -68,11 +74,14 @@ class MaasRegionCharm(ops.CharmBase): """Charm the application.""" - _TLS_MODES = [ - "disabled", - "termination", - "passthrough", - ] # no TLS, termination at HA Proxy, passthrough to MAAS + _TLS_MODES = frozenset( + [ + "disabled", + "termination", + "passthrough", + ] + ) # no TLS, termination at HA Proxy, passthrough to MAAS + _INTERNAL_ADMIN_USER = "maas-admin-internal" def __init__(self, *args): super().__init__(*args) @@ -174,7 +183,7 @@ def enrollment_token(self) -> Union[str, None]: return MaasHelper.get_maas_secret() @property - def bind_address(self) -> Union[str, None]: + def bind_address(self) -> str: """Get Unit bind address. Returns: @@ -182,7 +191,8 @@ def bind_address(self) -> Union[str, None]: """ if bind := self.model.get_binding("juju-info"): return str(bind.network.bind_address) - return None + else: + raise ops.model.ModelError("Bind address not set in the model") @property def maas_api_url(self) -> str: @@ -195,9 +205,7 @@ def maas_api_url(self) -> str: unit = next(iter(relation.units), None) if unit and (addr := relation.data[unit].get("public-address")): return f"http://{addr}:{MAAS_PROXY_PORT}/MAAS" - if bind := self.bind_address: - return f"http://{bind}:{MAAS_HTTP_PORT}/MAAS" - return "" + return f"http://{self.bind_address}:{MAAS_HTTP_PORT}/MAAS" @property def maas_id(self) -> Union[str, None]: @@ -261,6 +269,33 @@ def _setup_network(self) -> bool: return False return True + def _create_or_get_internal_admin(self) -> Dict[str, str]: + """Create an internal admin user if one does not already exist. + + Store the credentials in a secret, and return the credentials. + If one exists, just return the credentials for the account. + + Returns: + dict[str, str]: username and password of the admin user + + Raises: + CalledProcessError: failed to create the user + """ + try: + secret = self.model.get_secret(label=MAAS_ADMIN_SECRET_LABEL) + return secret.get_content() + except SecretNotFoundError: + password = "".join( + random.SystemRandom().choice(string.ascii_letters + string.digits) + for _ in range(15) + ) + content = {"username": self._INTERNAL_ADMIN_USER, "password": password} + + MaasHelper.create_admin_user(content["username"], password, "", None) + secret = self.app.add_secret(content, label=MAAS_ADMIN_SECRET_LABEL) + self.set_peer_data(self.app, MAAS_ADMIN_SECRET_KEY, secret.id) + return content + def _initialize_maas(self) -> bool: try: MaasHelper.setup_region( @@ -269,6 +304,12 @@ def _initialize_maas(self) -> bool: # check maas_api_url existence in case MAAS isn't ready yet if self.maas_api_url and self.unit.is_leader(): self._update_tls_config() + credentials = self._create_or_get_internal_admin() + MaasHelper.set_prometheus_metrics( + credentials["username"], + self.bind_address, + self.config["enable_prometheus_metrics"], # type: ignore + ) return True except subprocess.CalledProcessError: return False @@ -345,6 +386,12 @@ def _update_tls_config(self) -> None: elif tls_enabled and self.config["tls_mode"] in ["disabled", "termination"]: MaasHelper.disable_tls() + def _update_prometheus_config(self, enable: bool) -> None: + if secret_uri := self.get_peer_data(self.app, MAAS_ADMIN_SECRET_KEY): + secret = self.model.get_secret(id=secret_uri) + username = secret.get_content()["username"] + MaasHelper.set_prometheus_metrics(username, self.bind_address, enable) + def _on_start(self, _event: ops.StartEvent) -> None: """Handle the MAAS controller startup. @@ -494,6 +541,17 @@ def _on_maas_cluster_changed(self, event: ops.RelationEvent) -> None: if self.unit.is_leader() and not self._publish_tokens(): event.defer() return + try: + creds = self._create_or_get_internal_admin() + MaasHelper.set_prometheus_metrics( + creds["username"], + self.bind_address, + self.config["enable_prometheus_metrics"], # type: ignore + ) + except subprocess.CalledProcessError: + # If above failed, it's likely because things aren't ready yet. + # we will try again + pass if cur_mode := MaasHelper.get_maas_mode(): if self.get_operational_mode() != cur_mode: self._initialize_maas() @@ -695,6 +753,7 @@ def _on_config_changed(self, event: ops.ConfigChangedEvent): self._update_ha_proxy() if self.unit.is_leader(): self._update_tls_config() + self._update_prometheus_config(self.config["enable_prometheus_metrics"]) # type: ignore if __name__ == "__main__": # pragma: nocover diff --git a/maas-region/src/helper.py b/maas-region/src/helper.py index f4a22fc..781934f 100644 --- a/maas-region/src/helper.py +++ b/maas-region/src/helper.py @@ -212,6 +212,41 @@ def setup_region(maas_url: str, dsn: str, mode: str) -> None: ] subprocess.check_call(cmd) + @staticmethod + def set_prometheus_metrics(admin_username: str, maas_ip: str, enable: bool) -> None: + """Enable or disable prometheus metrics for MAAS. + + Args: + admin_username (str): The admin username for MAAS + maas_ip (str): IP address of the MAAS API + enable (bool): True to enable, False to disable + + Raises: + CalledProcessError: failed to set prometheus_metrics setting + """ + apikey = ( + subprocess.check_output(["/snap/bin/maas", "apikey", f"--username={admin_username}"]) + .decode() + .replace("\n", "") + ) + login_cmd = [ + "/snap/bin/maas", + "login", + admin_username, + f"http://{maas_ip}:5240/MAAS/api/2.0/", + apikey, + ] + subprocess.check_call(login_cmd) + set_cmd = [ + "/snap/bin/maas", + admin_username, + "maas", + "set-config", + "name=prometheus_enabled", + f"value={enable}", + ] + subprocess.check_call(set_cmd) + @staticmethod def is_tls_enabled() -> Union[bool, None]: """Check whether MAAS currently has TLS enabled. diff --git a/maas-region/tests/unit/test_charm.py b/maas-region/tests/unit/test_charm.py index 5b972f5..04ecae2 100644 --- a/maas-region/tests/unit/test_charm.py +++ b/maas-region/tests/unit/test_charm.py @@ -90,7 +90,6 @@ def test_refresh_invalid_channel(self, mock_helper): class TestDBRelation(unittest.TestCase): - def setUp(self): self.harness = ops.testing.Harness(MaasRegionCharm) self.harness.add_network("10.0.0.10") @@ -117,9 +116,28 @@ def test_database_connected(self, mock_helper): "region", ) + @patch("charm.MaasHelper", autospec=True) + def test_database_connected_creates_admin(self, mock_helper): + mock_helper.set_prometheus_metrics.return_value = None + mock_helper.create_admin_user.return_value = None + self.harness.set_leader(True) + self.harness.begin() + db_rel = self.harness.add_relation(MAAS_DB_NAME, "postgresql") + self.harness.update_relation_data( + db_rel, + "postgresql", + { + "endpoints": "30.0.0.1:5432", + "read-only-endpoints": "30.0.0.2:5432", + "username": "test_maas_db", + "password": "my_secret", + }, + ) + credentials = self.harness.model.get_secret(label="maas-admin").get_content() + self.assertEqual(credentials["username"], "maas-admin-internal") + class TestClusterUpdates(unittest.TestCase): - def setUp(self): self.harness = ops.testing.Harness(MaasRegionCharm) self.harness.add_network("10.0.0.10") @@ -238,6 +256,23 @@ def test_on_maas_cluster_changed_new_agent(self, mock_helper): self.assertEqual(data["regions"], f'["{socket.getfqdn()}"]') self.assertIn("maas_secret_id", data) # codespell:ignore + @patch("charm.MaasHelper", autospec=True) + def test_on_maas_cluster_changed_prometheus_enabled(self, mock_helper): + mock_helper.get_maas_mode.return_value = "region" + mock_helper.get_maas_secret.return_value = "very-secret" + mock_helper.create_admin_user.return_value = None + self.harness.set_leader(True) + self.harness.begin() + remote_app = "maas-agent" + self.harness.add_relation( + maas.DEFAULT_ENDPOINT_NAME, + remote_app, + unit_data={"unit": f"{remote_app}/0", "url": "some_url"}, + ) + mock_helper.set_prometheus_metrics.assert_called_with( + "maas-admin-internal", "10.0.0.10", True + ) + @patch( "charm.MaasRegionCharm.connection_string", new_callable=PropertyMock(return_value="postgres://"), @@ -319,9 +354,33 @@ def test_on_maas_cluster_changed_remove_agent_same_machine(self, mock_helper, _m "region", ) + @patch("charm.MaasHelper", autospec=True) + def test_config_change_prometheus_updated(self, mock_helper): + mock_helper.get_installed_version.return_value = "mock-ver" + mock_helper.get_installed_channel.return_value = MAAS_SNAP_CHANNEL + mock_helper.set_prometheus_metrics.return_value = None + mock_helper.create_admin_user.return_value = None + self.harness.set_leader(True) + self.harness.begin_with_initial_hooks() + # make admin secret be set + db_rel = self.harness.add_relation(MAAS_DB_NAME, "postgresql") + self.harness.update_relation_data( + db_rel, + "postgresql", + { + "endpoints": "30.0.0.1:5432", + "read-only-endpoints": "30.0.0.2:5432", + "username": "test_maas_db", + "password": "my_secret", + }, + ) + self.harness.update_config({"enable_prometheus_metrics": False}) + mock_helper.set_prometheus_metrics.assert_called_with( + "maas-admin-internal", "10.0.0.10", False + ) + class TestCharmActions(unittest.TestCase): - def setUp(self): self.harness = ops.testing.Harness(MaasRegionCharm) self.harness.add_network("10.0.0.10") diff --git a/maas-region/tests/unit/test_helper.py b/maas-region/tests/unit/test_helper.py index c6ddd32..335ca78 100644 --- a/maas-region/tests/unit/test_helper.py +++ b/maas-region/tests/unit/test_helper.py @@ -12,7 +12,6 @@ class TestHelperSnapCache(unittest.TestCase): - def _setup_snap( self, mock_snap, @@ -117,7 +116,6 @@ def test_set_not_running(self, mock_snap): class TestHelperFiles(unittest.TestCase): - @patch("pathlib.Path.open", new_callable=lambda: mock_open(read_data="maas-id\n")) def test_get_maas_id(self, _): self.assertEqual(MaasHelper.get_maas_id(), "maas-id") diff --git a/maas-region/tox.ini b/maas-region/tox.ini index ce4f042..bec0e44 100644 --- a/maas-region/tox.ini +++ b/maas-region/tox.ini @@ -1,105 +1,109 @@ -# Copyright 2024 Canonical -# See LICENSE file for licensing details. - [tox] -no_package = True -skip_missing_interpreters = True -env_list = format, lint, static-{charm,lib}, unit, scenario -min_version = 4.0.0 +requires = + tox>=4.2 +env_list = + format + lint + static-{charm, lib} + unit + scenario +no_package = true +skip_missing_interpreters = true basepython = py38 -[vars] -src_path = {tox_root}/src -tests_path = {tox_root}/tests -lib_path = {tox_root}/lib/charms/maas_region/v0/ -all_path = {[vars]src_path} {[vars]tests_path} {[vars]lib_path} - [testenv] -set_env = - PYTHONPATH = {tox_root}/lib:{[vars]src_path} - PYTHONBREAKPOINT=pdb.set_trace - PY_COLORS=1 pass_env = - PYTHONPATH CHARM_BUILD_DIR MODEL_SETTINGS + PYTHONPATH +set_env = + PYTHONBREAKPOINT = pdb.set_trace + PYTHONPATH = {tox_root}/lib:{[vars]src_path} + PY_COLORS = 1 [testenv:format] description = Apply coding style standards to code deps = - black + pyproject-fmt ruff + tox-ini-fmt commands = - black {[vars]all_path} + ruff format {[vars]all_path} ruff check --fix {[vars]all_path} + - pyproject-fmt pyproject.toml + - tox-ini-fmt tox.ini [testenv:lint] description = Check code against coding style standards deps = - black - ruff codespell + pyproject-fmt + ruff commands = - # if this charm owns a lib, uncomment "lib_path" variable - # and uncomment the following line - # codespell {[vars]lib_path} codespell {tox_root} ruff check {[vars]all_path} - black --check --diff {[vars]all_path} + pyproject-fmt --check pyproject.toml [testenv:unit] description = Run unit tests deps = - pytest - coverage[toml] -r {tox_root}/requirements.txt + coverage[toml] + pytest commands = coverage run --source={[vars]src_path} \ - -m pytest \ - --tb native \ - -v \ - -s \ - {posargs} \ - {[vars]tests_path}/unit + -m pytest \ + --tb native \ + -v \ + -s \ + {posargs} \ + {[vars]tests_path}/unit coverage report +[testenv:scenario] +description = Run scenario tests +deps = + -r {tox_root}/requirements.txt + cosl + ops-scenario + pytest +commands = + pytest -v -s --tb native {posargs} --log-cli-level=INFO {[vars]tests_path}/scenario + [testenv:static-{charm,lib}] description = Run static analysis checks deps = pyright typing-extensions charm: -r{toxinidir}/requirements.txt - lib: ops + integration: {[testenv:integration]deps} lib: jinja2 + lib: ops unit: {[testenv:unit]deps} - integration: {[testenv:integration]deps} commands = charm: pyright --pythonversion 3.8 {[vars]src_path} {posargs} lib: pyright --pythonversion 3.8 {[vars]lib_path} {posargs} lib: /usr/bin/env sh -c 'for m in $(git diff main --name-only --line-prefix=`git rev-parse --show-toplevel`/ {[vars]lib_path}); do if ! git diff main $m | grep -q "+LIBPATCH\|+LIBAPI"; then echo "You forgot to bump the version on $m!"; exit 1; fi; done' -allowlist_externals = /usr/bin/env +allowlist_externals = + /usr/bin/env [testenv:integration] description = Run integration tests deps = - pytest + -r {tox_root}/requirements.txt juju + pytest pytest-operator - -r {tox_root}/requirements.txt commands = pytest -v \ - -s \ - --tb native \ - --log-cli-level=INFO \ - {posargs} \ - {[vars]tests_path}/integration + -s \ + --tb native \ + --log-cli-level=INFO \ + {posargs} \ + {[vars]tests_path}/integration -[testenv:scenario] -description = Run scenario tests -deps = - pytest - cosl - ops-scenario - -r {tox_root}/requirements.txt -commands = - pytest -v -s --tb native {posargs} --log-cli-level=INFO {[vars]tests_path}/scenario +[vars] +src_path = {tox_root}/src +tests_path = {tox_root}/tests +lib_path = {tox_root}/lib/charms/maas_region/v0/ +all_path = {[vars]src_path} {[vars]tests_path} {[vars]lib_path}