diff --git a/.github/workflows/charms_lint_and_unit.yaml b/.github/workflows/charms_lint_and_unit.yaml index 31bf04b6..e3400e65 100644 --- a/.github/workflows/charms_lint_and_unit.yaml +++ b/.github/workflows/charms_lint_and_unit.yaml @@ -35,6 +35,7 @@ jobs: charm: - charms/planner-operator - charms/webhook-gateway-operator + - charms/garm-configurator steps: - uses: actions/checkout@v6 diff --git a/charms/garm-configurator/charmcraft.yaml b/charms/garm-configurator/charmcraft.yaml new file mode 100644 index 00000000..f0a3924d --- /dev/null +++ b/charms/garm-configurator/charmcraft.yaml @@ -0,0 +1,21 @@ +# This file configures Charmcraft. +# See https://juju.is/docs/sdk/charmcraft-config for guidance. + +name: garm-configurator + +type: charm + +base: ubuntu@24.04 +platforms: + amd64: + +summary: Configuration broker charm for GARM scalesets. + +description: | + Holds configuration for a single GARM scaleset and provider combination, + sharing it with the GARM charm via Juju integrations. + +parts: + charm: + source: . + plugin: charm diff --git a/charms/garm-configurator/pyproject.toml b/charms/garm-configurator/pyproject.toml new file mode 100644 index 00000000..a75d0fa1 --- /dev/null +++ b/charms/garm-configurator/pyproject.toml @@ -0,0 +1,86 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +# Testing tools configuration +[tool.coverage.run] +branch = true + +[tool.coverage.report] +show_missing = true + +[tool.pytest.ini_options] +minversion = "6.0" +log_cli_level = "INFO" +pythonpath = ["lib", "src"] + +# Linting tools configuration +[tool.ruff] +target-version = "py310" +line-length = 99 + +# enable ruff linters: +# S flake8-bandit +# B flake8-bugbear +# A flake8-builtins +# CPY flake8-copyright +# SIM flake8-simplify +# TC flake8-type-checking +# I isort +# N pep8-naming +# D pydocstyle +# F Pyflakes +# UP pyupgrade +# RUF Ruff-specific rules +# E/W pycodestyle +lint.select = ["A", "B", "C", "CPY", "D", "E", "F", "I", "N", "RUF", "S", "SIM", "TC", "UP", "W"] +lint.ignore = [ + "B904", + "D107", + "D203", + "D204", + "D205", + "D213", + "D215", + "D400", + "D404", + "D406", + "D407", + "D408", + "D409", + "D413", + "E501", + "S105", + "S603", + "TC002", + "TC006", + "UP006", + "UP007", + "UP035", + "UP045", +] +lint.per-file-ignores."tests/*" = ["B011", "D100", "D101", "D102", "D103", "D104", "D212", "D415", "D417", "S"] +lint.flake8-copyright.author = "Canonical Ltd." +lint.flake8-copyright.min-file-size = 1 +lint.flake8-copyright.notice-rgx = "Copyright\\s\\d{4}([-,]\\d{4})*\\s+" +lint.mccabe.max-complexity = 10 +lint.pydocstyle.convention = "google" + +[tool.codespell] +skip = "build,lib,venv,icon.svg,.tox,.git,.mypy_cache,.ruff_cache,.coverage,htmlcov,uv.lock,grafana_dashboards" + +[tool.mypy] +check_untyped_defs = true +disallow_untyped_defs = true +explicit_package_bases = true +ignore_missing_imports = true +namespace_packages = true + +[[tool.mypy.overrides]] +disallow_untyped_defs = false +module = "tests.*" + +[tool.bandit] +exclude_dirs = ["/venv/"] + +[tool.bandit.assert_used] +skips = ["*/*test.py", "*/test_*.py", "*tests/*.py"] diff --git a/charms/garm-configurator/requirements.txt b/charms/garm-configurator/requirements.txt new file mode 100644 index 00000000..9a660bd9 --- /dev/null +++ b/charms/garm-configurator/requirements.txt @@ -0,0 +1 @@ +ops==3.7.0 diff --git a/charms/garm-configurator/src/charm.py b/charms/garm-configurator/src/charm.py new file mode 100644 index 00000000..8a269a87 --- /dev/null +++ b/charms/garm-configurator/src/charm.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Charm entrypoint for the GARM configurator charm.""" + +import typing + +import ops + + +class GarmConfiguratorCharm(ops.CharmBase): + """GARM configurator charm.""" + + def __init__(self, *args: typing.Any) -> None: + """Initialize the instance. + + Args: + args: passthrough to CharmBase. + """ + super().__init__(*args) + self.framework.observe(self.on.collect_unit_status, self._on_collect_unit_status) + + def _on_collect_unit_status(self, event: ops.CollectStatusEvent) -> None: + """Handle collect-unit-status event. + + Args: + event: The collect status event. + """ + event.add_status(ops.ActiveStatus("Ready")) + + +if __name__ == "__main__": + ops.main(GarmConfiguratorCharm) diff --git a/charms/garm-configurator/tests/unit/test_charm.py b/charms/garm-configurator/tests/unit/test_charm.py new file mode 100644 index 00000000..b1dc203c --- /dev/null +++ b/charms/garm-configurator/tests/unit/test_charm.py @@ -0,0 +1,42 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Unit tests for GarmConfiguratorCharm.""" + +import ops +from scenario import Context, State + +from charm import GarmConfiguratorCharm + + +def test_charm_reaches_active_on_install(): + """ + arrange: A fresh charm context. + act: Run the install event. + assert: Unit status is Active. + """ + ctx = Context(GarmConfiguratorCharm) + out = ctx.run(ctx.on.install(), State()) + assert out.unit_status == ops.ActiveStatus("Ready") + + +def test_charm_reaches_active_on_config_changed(): + """ + arrange: A fresh charm context. + act: Run the config-changed event. + assert: Unit status is Active. + """ + ctx = Context(GarmConfiguratorCharm) + out = ctx.run(ctx.on.config_changed(), State()) + assert out.unit_status == ops.ActiveStatus("Ready") + + +def test_charm_collect_unit_status_emits_active(): + """ + arrange: A fresh charm context. + act: Run the collect-unit-status event directly. + assert: Unit status is Active. + """ + ctx = Context(GarmConfiguratorCharm) + out = ctx.run(ctx.on.collect_unit_status(), State()) + assert out.unit_status == ops.ActiveStatus("Ready") diff --git a/charms/garm-configurator/tox.toml b/charms/garm-configurator/tox.toml new file mode 100644 index 00000000..9f02ec56 --- /dev/null +++ b/charms/garm-configurator/tox.toml @@ -0,0 +1,51 @@ +env_list = ["lint", "complexity", "static", "unit", "coverage-report"] + +[env_run_base] +package = "skip" +set_env = { PYTHONPATH = "{tox_root}/lib:{tox_root}/src:{tox_root}/tests", PYTHONBREAKPOINT = "pdb.set_trace", PY_COLORS = "1" } +pass_env = ["PYTHONPATH", "CHARM_BUILD_DIR", "MODEL_SETTINGS"] + +[env.lint] +description = "Check code against coding style standards" +deps = ["ruff", "codespell"] +commands = [ + ["codespell", "{tox_root}"], + ["ruff", "check", "src"], + ["ruff", "format", "--check", "--diff", "src"], +] + +[env.complexity] +description = "Check cyclomatic complexity" +deps = ["ruff"] +commands = [["ruff", "check", "--select", "C90", "src"]] + +[env.static] +description = "Run static type checks" +deps = ["mypy", "bandit[toml]", "-r requirements.txt"] +commands = [ + ["mypy", "src"], + ["bandit", "-c", "pyproject.toml", "-r", "src"], +] + +[env.unit] +description = "Run unit tests" +deps = ["pytest", "coverage[toml]", "ops-scenario==8.7.0", "-r requirements.txt"] +commands = [ + [ + "coverage", + "run", + "--source=src", + "-m", + "pytest", + "--tb", + "native", + "-v", + "-s", + { replace = "posargs", default = ["tests/unit"], extend = true }, + ], +] + +[env.coverage-report] +description = "Report coverage results" +deps = ["coverage[toml]"] +commands = [["coverage", "report"]] diff --git a/charms/tests/integration/test_garm_configurator.py b/charms/tests/integration/test_garm_configurator.py new file mode 100644 index 00000000..c6b847fe --- /dev/null +++ b/charms/tests/integration/test_garm_configurator.py @@ -0,0 +1,63 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Integration tests for the garm-configurator charm.""" + +import logging + +import jubilant +import pytest + +from tests.conftest import CHARM_FILE_PARAM + +logger = logging.getLogger(__name__) + + +@pytest.fixture(name="garm_configurator_charm_file", scope="module") +def garm_configurator_charm_file_fixture(pytestconfig: pytest.Config) -> str: + """Return the path to the built garm-configurator charm file.""" + charm = pytestconfig.getoption(CHARM_FILE_PARAM) + if not charm: + pytest.skip( + f"missing required {CHARM_FILE_PARAM} option for garm-configurator integration tests" + ) + if len(charm) > 1: + configurator_charms = [f for f in charm if "configurator" in f] + if not configurator_charms: + pytest.skip( + f"no garm-configurator charm file found in {CHARM_FILE_PARAM} option" + ) + return configurator_charms[0] + return charm[0] + + +@pytest.fixture(name="garm_configurator_app", scope="module") +def deploy_garm_configurator_app_fixture( + juju: jubilant.Juju, + garm_configurator_charm_file: str, +) -> str: + """Deploy the garm-configurator application standalone with no relations. + + Returns the application name once the app reaches Active. + """ + app_name = "garm-configurator" + juju.deploy(charm=garm_configurator_charm_file, app=app_name) + juju.wait( + lambda status: jubilant.all_active(status, app_name), + timeout=5 * 60, + delay=10, + ) + return app_name + + +def test_garm_configurator_deploys_active( + juju: jubilant.Juju, + garm_configurator_app: str, +) -> None: + """ + arrange: garm-configurator charm deployed with no relations wired. + act: Check the application status. + assert: Application is in Active state. + """ + status = juju.status() + assert jubilant.all_active(status, garm_configurator_app) diff --git a/tox.ini b/tox.ini index c8e7294b..3dbd3666 100644 --- a/tox.ini +++ b/tox.ini @@ -55,6 +55,22 @@ commands = {[vars]tests_path}/integration/test_planner.py \ {posargs} +[testenv:garm-configurator-integration] +pass_env = + PYTEST_ADDOPTS +description = Run garm-configurator charm integration tests +deps = + pytest + pytest-operator + -r {[vars]tests_path}/integration/requirements.txt +commands = + pytest -v \ + -s \ + --tb native \ + --log-cli-level=INFO \ + {[vars]tests_path}/integration/test_garm_configurator.py \ + {posargs} + [testenv:charms-integration] pass_env = PYTEST_ADDOPTS