From 6c6b039b52991578945bb157c66a5fbc4360df0e Mon Sep 17 00:00:00 2001 From: florentianayuwono Date: Fri, 22 May 2026 19:42:14 +0700 Subject: [PATCH 1/8] chore: scaffold garm-configurator charm config files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- charms/garm-configurator/charmcraft.yaml | 16 ++++++++ charms/garm-configurator/pyproject.toml | 41 +++++++++++++++++++ charms/garm-configurator/requirements.txt | 2 + charms/garm-configurator/tox.toml | 48 +++++++++++++++++++++++ 4 files changed, 107 insertions(+) create mode 100644 charms/garm-configurator/charmcraft.yaml create mode 100644 charms/garm-configurator/pyproject.toml create mode 100644 charms/garm-configurator/requirements.txt create mode 100644 charms/garm-configurator/tox.toml diff --git a/charms/garm-configurator/charmcraft.yaml b/charms/garm-configurator/charmcraft.yaml new file mode 100644 index 00000000..9d992f14 --- /dev/null +++ b/charms/garm-configurator/charmcraft.yaml @@ -0,0 +1,16 @@ +# 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. diff --git a/charms/garm-configurator/pyproject.toml b/charms/garm-configurator/pyproject.toml new file mode 100644 index 00000000..660ac999 --- /dev/null +++ b/charms/garm-configurator/pyproject.toml @@ -0,0 +1,41 @@ +# 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" + +# Linting tools configuration +[tool.ruff] +line-length = 99 +lint.select = ["E", "W", "F", "C", "N", "D", "I001"] +lint.ignore = [ + "D105", + "D107", + "D203", + "D204", + "D213", + "D215", + "D400", + "D404", + "D406", + "D407", + "D408", + "D409", + "D413", +] +extend-exclude = ["__pycache__", "*.egg_info"] +lint.per-file-ignores = {"tests/*" = ["D100","D101","D102","D103","D104"]} + +[tool.ruff.lint.mccabe] +max-complexity = 10 + +[tool.codespell] +skip = "build,lib,venv,icon.svg,.tox,.git,.mypy_cache,.ruff_cache,.coverage" + +[tool.pyright] +include = ["src/**.py"] diff --git a/charms/garm-configurator/requirements.txt b/charms/garm-configurator/requirements.txt new file mode 100644 index 00000000..62d35293 --- /dev/null +++ b/charms/garm-configurator/requirements.txt @@ -0,0 +1,2 @@ +--only-binary=pluggy +ops==3.7.0 diff --git a/charms/garm-configurator/tox.toml b/charms/garm-configurator/tox.toml new file mode 100644 index 00000000..b6c20550 --- /dev/null +++ b/charms/garm-configurator/tox.toml @@ -0,0 +1,48 @@ +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 = ["pyright", "-r requirements.txt"] +commands = [["pyright"]] + +[env.unit] +description = "Run unit tests" +deps = ["pytest", "coverage[toml]", "-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"]] From 5e3df05171e50317ed4135c60cc6e094ada22e6d Mon Sep 17 00:00:00 2001 From: florentianayuwono Date: Fri, 22 May 2026 19:53:50 +0700 Subject: [PATCH 2/8] feat: implement garm-configurator charm scaffold Charm deploys and reaches Active state with no relations or config. Uses ops-scenario for unit testing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- charms/garm-configurator/src/charm.py | 37 ++++++++++++++++ .../tests/unit/test_charm.py | 42 +++++++++++++++++++ charms/garm-configurator/tox.toml | 2 +- 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 charms/garm-configurator/src/charm.py create mode 100644 charms/garm-configurator/tests/unit/test_charm.py diff --git a/charms/garm-configurator/src/charm.py b/charms/garm-configurator/src/charm.py new file mode 100644 index 00000000..1667a003 --- /dev/null +++ b/charms/garm-configurator/src/charm.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Charm entrypoint for the GARM configurator charm.""" + +import logging +import typing + +import ops + +logger = logging.getLogger(__name__) + + +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 index b6c20550..94988e4a 100644 --- a/charms/garm-configurator/tox.toml +++ b/charms/garm-configurator/tox.toml @@ -26,7 +26,7 @@ commands = [["pyright"]] [env.unit] description = "Run unit tests" -deps = ["pytest", "coverage[toml]", "-r requirements.txt"] +deps = ["pytest", "coverage[toml]", "ops-scenario==8.7.0", "-r requirements.txt"] commands = [ [ "coverage", From 3a5f5191dc053916aab03cd9d9c092302bd2238c Mon Sep 17 00:00:00 2001 From: florentianayuwono Date: Fri, 22 May 2026 19:54:15 +0700 Subject: [PATCH 3/8] test: add garm-configurator integration test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../integration/test_garm_configurator.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 charms/tests/integration/test_garm_configurator.py diff --git a/charms/tests/integration/test_garm_configurator.py b/charms/tests/integration/test_garm_configurator.py new file mode 100644 index 00000000..00350bb9 --- /dev/null +++ b/charms/tests/integration/test_garm_configurator.py @@ -0,0 +1,57 @@ +# 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 | None: + """Return the path to the built garm-configurator charm file.""" + charm = pytestconfig.getoption(CHARM_FILE_PARAM) + if not charm: + return None + if len(charm) > 1: + configurator_charms = [f for f in charm if "configurator" in f] + 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) From 6d1a8ac6445f85ca96bb907b4eab482228f00322 Mon Sep 17 00:00:00 2001 From: florentianayuwono Date: Fri, 22 May 2026 19:55:08 +0700 Subject: [PATCH 4/8] chore: add garm-configurator to tox and CI lint/unit matrix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/charms_lint_and_unit.yaml | 1 + tox.ini | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) 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/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 From 05b2295edbf887bc471df0377bde6a5519a79a06 Mon Sep 17 00:00:00 2001 From: florentianayuwono Date: Fri, 22 May 2026 20:34:22 +0700 Subject: [PATCH 5/8] fix: add parts section, switch pyright to mypy - Add missing parts section to charmcraft.yaml (fixes charmcraft validation error requiring at least 1 part) - Replace pyright with mypy in pyproject.toml and tox.toml, matching platform-engineering-charm-template config - Keep ubuntu@24.04 base; ubuntu@26.04 is not yet in charmcraft's supported bases (22.04, 24.04, 24.10, 25.04) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- charms/garm-configurator/charmcraft.yaml | 5 +++++ charms/garm-configurator/pyproject.toml | 12 ++++++++++-- charms/garm-configurator/tox.toml | 4 ++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/charms/garm-configurator/charmcraft.yaml b/charms/garm-configurator/charmcraft.yaml index 9d992f14..f0a3924d 100644 --- a/charms/garm-configurator/charmcraft.yaml +++ b/charms/garm-configurator/charmcraft.yaml @@ -14,3 +14,8 @@ 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 index 660ac999..8ef60b4b 100644 --- a/charms/garm-configurator/pyproject.toml +++ b/charms/garm-configurator/pyproject.toml @@ -37,5 +37,13 @@ max-complexity = 10 [tool.codespell] skip = "build,lib,venv,icon.svg,.tox,.git,.mypy_cache,.ruff_cache,.coverage" -[tool.pyright] -include = ["src/**.py"] +[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.*" diff --git a/charms/garm-configurator/tox.toml b/charms/garm-configurator/tox.toml index 94988e4a..74d56c78 100644 --- a/charms/garm-configurator/tox.toml +++ b/charms/garm-configurator/tox.toml @@ -21,8 +21,8 @@ commands = [["ruff", "check", "--select", "C90", "src"]] [env.static] description = "Run static type checks" -deps = ["pyright", "-r requirements.txt"] -commands = [["pyright"]] +deps = ["mypy", "-r requirements.txt"] +commands = [["mypy", "src"]] [env.unit] description = "Run unit tests" From fda474d87d9a10b45c1e5af3d330fbe61e364cfb Mon Sep 17 00:00:00 2001 From: florentianayuwono Date: Fri, 22 May 2026 20:40:39 +0700 Subject: [PATCH 6/8] chore: remove unnecessary --only-binary=pluggy from requirements.txt Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- charms/garm-configurator/requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/charms/garm-configurator/requirements.txt b/charms/garm-configurator/requirements.txt index 62d35293..9a660bd9 100644 --- a/charms/garm-configurator/requirements.txt +++ b/charms/garm-configurator/requirements.txt @@ -1,2 +1 @@ ---only-binary=pluggy ops==3.7.0 From d44e7bf3715b38283d9dc9735e461ad8cce7ae16 Mon Sep 17 00:00:00 2001 From: florentianayuwono Date: Fri, 22 May 2026 20:50:56 +0700 Subject: [PATCH 7/8] chore: align pyproject.toml with platform-engineering-charm-template - Expand ruff lint.select to include A, B, CPY, I, RUF, S, SIM, TC, UP - Align lint.ignore with template - Add lint.flake8-copyright, lint.pydocstyle.convention, target-version - Add pythonpath to pytest options - Expand codespell skip list - Add [tool.bandit] config and bandit to static tox env Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- charms/garm-configurator/pyproject.toml | 53 +++++++++++++++++++++---- charms/garm-configurator/tox.toml | 7 +++- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/charms/garm-configurator/pyproject.toml b/charms/garm-configurator/pyproject.toml index 8ef60b4b..a75d0fa1 100644 --- a/charms/garm-configurator/pyproject.toml +++ b/charms/garm-configurator/pyproject.toml @@ -1,3 +1,6 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + # Testing tools configuration [tool.coverage.run] branch = true @@ -8,16 +11,34 @@ 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 -lint.select = ["E", "W", "F", "C", "N", "D", "I001"] + +# 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 = [ - "D105", + "B904", "D107", "D203", "D204", + "D205", "D213", "D215", "D400", @@ -27,15 +48,25 @@ lint.ignore = [ "D408", "D409", "D413", + "E501", + "S105", + "S603", + "TC002", + "TC006", + "UP006", + "UP007", + "UP035", + "UP045", ] -extend-exclude = ["__pycache__", "*.egg_info"] -lint.per-file-ignores = {"tests/*" = ["D100","D101","D102","D103","D104"]} - -[tool.ruff.lint.mccabe] -max-complexity = 10 +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" +skip = "build,lib,venv,icon.svg,.tox,.git,.mypy_cache,.ruff_cache,.coverage,htmlcov,uv.lock,grafana_dashboards" [tool.mypy] check_untyped_defs = true @@ -47,3 +78,9 @@ 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/tox.toml b/charms/garm-configurator/tox.toml index 74d56c78..9f02ec56 100644 --- a/charms/garm-configurator/tox.toml +++ b/charms/garm-configurator/tox.toml @@ -21,8 +21,11 @@ commands = [["ruff", "check", "--select", "C90", "src"]] [env.static] description = "Run static type checks" -deps = ["mypy", "-r requirements.txt"] -commands = [["mypy", "src"]] +deps = ["mypy", "bandit[toml]", "-r requirements.txt"] +commands = [ + ["mypy", "src"], + ["bandit", "-c", "pyproject.toml", "-r", "src"], +] [env.unit] description = "Run unit tests" From 0eebbcddc6a24cd2336134f252ff6090c5cd08a8 Mon Sep 17 00:00:00 2001 From: florentianayuwono <76247368+florentianayuwono@users.noreply.github.com> Date: Fri, 22 May 2026 21:15:11 +0700 Subject: [PATCH 8/8] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- charms/garm-configurator/src/charm.py | 3 --- charms/tests/integration/test_garm_configurator.py | 10 ++++++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/charms/garm-configurator/src/charm.py b/charms/garm-configurator/src/charm.py index 1667a003..8a269a87 100644 --- a/charms/garm-configurator/src/charm.py +++ b/charms/garm-configurator/src/charm.py @@ -4,13 +4,10 @@ """Charm entrypoint for the GARM configurator charm.""" -import logging import typing import ops -logger = logging.getLogger(__name__) - class GarmConfiguratorCharm(ops.CharmBase): """GARM configurator charm.""" diff --git a/charms/tests/integration/test_garm_configurator.py b/charms/tests/integration/test_garm_configurator.py index 00350bb9..c6b847fe 100644 --- a/charms/tests/integration/test_garm_configurator.py +++ b/charms/tests/integration/test_garm_configurator.py @@ -14,13 +14,19 @@ @pytest.fixture(name="garm_configurator_charm_file", scope="module") -def garm_configurator_charm_file_fixture(pytestconfig: pytest.Config) -> str | None: +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: - return None + 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]