Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/charms_lint_and_unit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ jobs:
charm:
- charms/planner-operator
- charms/webhook-gateway-operator
- charms/garm-configurator
steps:
- uses: actions/checkout@v6

Expand Down
21 changes: 21 additions & 0 deletions charms/garm-configurator/charmcraft.yaml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
florentianayuwono marked this conversation as resolved.
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
86 changes: 86 additions & 0 deletions charms/garm-configurator/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]
1 change: 1 addition & 0 deletions charms/garm-configurator/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ops==3.7.0
34 changes: 34 additions & 0 deletions charms/garm-configurator/src/charm.py
Original file line number Diff line number Diff line change
@@ -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)
42 changes: 42 additions & 0 deletions charms/garm-configurator/tests/unit/test_charm.py
Original file line number Diff line number Diff line change
@@ -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")
51 changes: 51 additions & 0 deletions charms/garm-configurator/tox.toml
Original file line number Diff line number Diff line change
@@ -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"],
Comment thread
florentianayuwono marked this conversation as resolved.
["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"]]
63 changes: 63 additions & 0 deletions charms/tests/integration/test_garm_configurator.py
Original file line number Diff line number Diff line change
@@ -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)
16 changes: 16 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading