Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
be2c50d
feat(garm): expose runner config options via garm-configurator relati…
cbartz Jul 1, 2026
16402c6
Fix GARM scaleset reconciliation
cbartz Jul 6, 2026
7dd69fd
refactor(garm): dedupe template API and simplify scaleset reconciler
cbartz Jul 6, 2026
37e218c
style(garm): remove shebang from non-executable runner_template module
cbartz Jul 6, 2026
3672a0e
refactor(garm-configurator): validate RunnerConfig with pydantic vali…
cbartz Jul 6, 2026
d700c8b
fix(garm): render runner-install template to match production templates
cbartz Jul 6, 2026
db70f0c
docs(garm): make runner_template comments self-contained
cbartz Jul 6, 2026
36aa6b5
test(garm): cover all six runner options in integration test
cbartz Jul 6, 2026
8549252
refactor(garm): move RunnerConfig into charm_state.py
cbartz Jul 6, 2026
1c7e8c3
Align garm test docs with AAA style
cbartz Jul 6, 2026
adf0067
Merge branch 'main' into feat/configurator-app-config-ISD-5728-rebase
cbartz Jul 6, 2026
ed85f8f
refactor(garm): extract reconcile helpers for the complexity gate
cbartz Jul 7, 2026
cd75155
refactor(garm): validate runner options in charm_state, render via Ji…
cbartz Jul 7, 2026
e2fdf34
Merge branch 'main' into feat/configurator-app-config-ISD-5728-rebase
cbartz Jul 7, 2026
282f788
fix(garm-configurator): annotate IPvAnyNetwork TypeAdapter for mypy
cbartz Jul 7, 2026
0b7ca45
test(garm): delete scalesets before org in integration cleanup
cbartz Jul 7, 2026
387d4c8
test(garm): disable scalesets before deleting them in integration cle…
cbartz Jul 7, 2026
b155a49
docs(garm): move teardown rationale from docstrings into technical co…
cbartz Jul 7, 2026
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
24 changes: 24 additions & 0 deletions charms/garm-configurator/charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,30 @@ config:
description: |
A Python dict-like string containing key-value pairs of script name
to bash script to be run prior to runner installation.
dockerhub-mirror:
type: string
description: |
Optional Docker registry mirror URL for runners. Must be a valid http(s) address.
runner-http-proxy:
type: string
description: |
Optional HTTP proxy that aproxy forwards to. Must be a valid http(s) URL.
aproxy-exclude-addresses:
type: string
description: |
Optional comma-separated IPv4 addresses/CIDRs to exclude from aproxy forwarding.
aproxy-redirect-ports:
type: string
description: |
Optional comma-separated ports or port ranges (e.g. 80,443,8000-9000) to forward to aproxy.
otel-collector-endpoint:
type: string
description: |
Optional OTEL exporter address for the otel-collector to connect to. Must be a valid http(s) URL.
pre-job-script:
type: string
description: |
Optional bash script appended to the end of the runner pre-job script.

requires:
image:
Expand Down
7 changes: 7 additions & 0 deletions charms/garm-configurator/src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ def _configure_garm_relation(self, state: CharmState) -> None:
return

pre_install = state.scaleset_config.pre_install_scripts
rc = state.runner_config
basic_data: dict[str, str] = {
"name": state.scaleset_config.name,
"provider_name": state.provider_config.provider_name,
Expand All @@ -159,6 +160,12 @@ def _configure_garm_relation(self, state: CharmState) -> None:
"pre_install_scripts": json.dumps({"pre_install.sh": pre_install})
if pre_install
else "",
"dockerhub_mirror": rc.dockerhub_mirror or "",
"runner_http_proxy": rc.runner_http_proxy or "",
"aproxy_exclude_addresses": rc.aproxy_exclude_addresses or "",
"aproxy_redirect_ports": rc.aproxy_redirect_ports or "",
"otel_collector_endpoint": rc.otel_collector_endpoint or "",
"pre_job_script": rc.pre_job_script or "",
}
if state.scaleset_config.org:
basic_data["org"] = state.scaleset_config.org
Expand Down
174 changes: 173 additions & 1 deletion charms/garm-configurator/src/charm_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@
"""State of the GARM configurator charm."""

import ops
from pydantic import BaseModel
from pydantic import (
BaseModel,
HttpUrl,
IPvAnyNetwork,
TypeAdapter,
ValidationError,
ValidationInfo,
field_validator,
model_validator,
)

OPENSTACK_AUTH_URL_CONFIG_NAME = "openstack-auth-url"
OPENSTACK_USERNAME_CONFIG_NAME = "openstack-username"
Expand All @@ -30,6 +39,16 @@
SCALESET_RUNNER_GROUP_CONFIG_NAME = "runner-group"
SCALESET_PRE_INSTALL_SCRIPTS_CONFIG_NAME = "pre-install-scripts"

DOCKERHUB_MIRROR_CONFIG_NAME = "dockerhub-mirror"
RUNNER_HTTP_PROXY_CONFIG_NAME = "runner-http-proxy"
APROXY_EXCLUDE_ADDRESSES_CONFIG_NAME = "aproxy-exclude-addresses"
APROXY_REDIRECT_PORTS_CONFIG_NAME = "aproxy-redirect-ports"
OTEL_COLLECTOR_ENDPOINT_CONFIG_NAME = "otel-collector-endpoint"
PRE_JOB_SCRIPT_CONFIG_NAME = "pre-job-script"

_HTTP_URL_ADAPTER = TypeAdapter(HttpUrl)
_IP_NETWORK_ADAPTER: TypeAdapter[IPvAnyNetwork] = TypeAdapter(IPvAnyNetwork)

IMAGE_RELATION_NAME = "image"
GARM_RELATION_NAME = "garm-configurator"

Expand Down Expand Up @@ -296,13 +315,161 @@ def from_charm(cls, charm: ops.CharmBase) -> "ScalesetConfig":
)


class RunnerConfig(BaseModel):
"""Optional runner-level configuration forwarded to the GARM scaleset.

Attributes:
dockerhub_mirror: Optional Docker registry mirror URL.
runner_http_proxy: HTTP proxy address for aproxy to forward to.
aproxy_exclude_addresses: Comma-separated IPs/CIDRs excluded from aproxy forwarding.
aproxy_redirect_ports: Comma-separated ports or N-M ranges forwarded to aproxy.
otel_collector_endpoint: OTEL exporter address for the otel-collector.
pre_job_script: Bash snippet appended to the runner pre-job script.
"""

dockerhub_mirror: str | None = None
runner_http_proxy: str | None = None
aproxy_exclude_addresses: str | None = None
aproxy_redirect_ports: str | None = None
otel_collector_endpoint: str | None = None
pre_job_script: str | None = None

@model_validator(mode="before")
@classmethod
def _strip_to_none(cls, data: object) -> object:
"""Strip whitespace and treat empty/whitespace-only values as unset."""
if not isinstance(data, dict):
return data
return {
key: (str(value).strip() or None) if value is not None else None
for key, value in data.items()
}

@field_validator("dockerhub_mirror", "runner_http_proxy", "otel_collector_endpoint")
@classmethod
def _validate_http_url(cls, value: str | None, info: ValidationInfo) -> str | None:
"""Require a set option to be a well-formed http(s) URL."""
if value is None:
return None
# Field names are the snake_case form of the kebab-case config option.
msg = f"{(info.field_name or '').replace('_', '-')} must be a valid http(s) URL"
# Reject whitespace: the value is rendered into scripts and env files,
# where a newline could inject extra lines.
if any(char.isspace() for char in value):
raise ValueError(msg)
try:
parsed = _HTTP_URL_ADAPTER.validate_python(value)
except ValidationError as exc:
raise ValueError(msg) from exc
if parsed.port == 0:
raise ValueError(msg)
return value

@field_validator("aproxy_exclude_addresses")
@classmethod
def _validate_ipv4_list(cls, value: str | None) -> str | None:
"""Require a comma-separated list of IPv4 addresses/CIDRs; normalise spacing."""
if value is None:
return None
tokens = [token.strip() for token in value.split(",")]
for token in tokens:
# The values are rendered into an nft IPv4 (table ip) ruleset, so a
# hostname or IPv6 address would validate here but fail at runtime.
try:
network = _IP_NETWORK_ADAPTER.validate_python(token)
except ValidationError as exc:
raise ValueError(
f"{APROXY_EXCLUDE_ADDRESSES_CONFIG_NAME} must be a comma-separated list "
f"of IPv4 addresses or CIDRs; got invalid token: {token!r}"
) from exc
if network.version != 4:
raise ValueError(
f"{APROXY_EXCLUDE_ADDRESSES_CONFIG_NAME} only supports IPv4 addresses or "
f"CIDRs (the aproxy nft ruleset is IPv4-only); got: {token!r}"
)
return ",".join(tokens)

@field_validator("aproxy_redirect_ports")
@classmethod
def _validate_port_list(cls, value: str | None) -> str | None:
"""Require a comma-separated list of ports or N-M ranges in 1..65535."""
if value is None:
return None
msg = (
f"{APROXY_REDIRECT_PORTS_CONFIG_NAME} must be a comma-separated list of "
"ports or N-M ranges in 1..65535"
)
tokens = [token.strip() for token in value.split(",")]
for token in tokens:
bounds = token.split("-")
if len(bounds) > 2:
raise ValueError(msg)
try:
ports = [int(part) for part in bounds]
except ValueError as exc:
raise ValueError(msg) from exc
if not all(1 <= port <= 65535 for port in ports) or ports != sorted(ports):
raise ValueError(msg)
return ",".join(tokens)

@model_validator(mode="after")
def _aproxy_requires_proxy(self) -> "RunnerConfig":
"""The aproxy options only take effect alongside a proxy.

The runner template renders the aproxy block solely when runner-http-proxy
is set, so reject these options on their own rather than letting them no-op.
"""
if (self.aproxy_exclude_addresses or self.aproxy_redirect_ports) and (
not self.runner_http_proxy
):
raise ValueError(
f"{APROXY_EXCLUDE_ADDRESSES_CONFIG_NAME} and {APROXY_REDIRECT_PORTS_CONFIG_NAME} "
f"require {RUNNER_HTTP_PROXY_CONFIG_NAME} to be set"
)
return self

@classmethod
def from_charm(cls, charm: ops.CharmBase) -> "RunnerConfig":
"""Build and validate the runner config from charm configuration.

All options are optional; unset, empty, or whitespace-only values become None.

Args:
charm: The charm instance.

Raises:
CharmConfigInvalidError: If a set option fails validation.

Returns:
The parsed runner configuration.
"""

def config_str(name: str) -> str | None:
"""Return a string config value, or None when unset."""
value = charm.config.get(name)
return None if value is None else str(value)

try:
return cls(
dockerhub_mirror=config_str(DOCKERHUB_MIRROR_CONFIG_NAME),
runner_http_proxy=config_str(RUNNER_HTTP_PROXY_CONFIG_NAME),
aproxy_exclude_addresses=config_str(APROXY_EXCLUDE_ADDRESSES_CONFIG_NAME),
aproxy_redirect_ports=config_str(APROXY_REDIRECT_PORTS_CONFIG_NAME),
otel_collector_endpoint=config_str(OTEL_COLLECTOR_ENDPOINT_CONFIG_NAME),
pre_job_script=config_str(PRE_JOB_SCRIPT_CONFIG_NAME),
)
except ValidationError as exc:
raise CharmConfigInvalidError(str(exc)) from exc


class CharmState:
"""The charm state.

Attributes:
provider_config: OpenStack provider configuration.
github_app_config: GitHub App configuration.
scaleset_config: Scaleset configuration.
runner_config: Optional runner-level configuration.
image_id: OpenStack image UUID received from the image builder relation, or None.
"""

Expand All @@ -312,6 +479,7 @@ def __init__(
provider_config: ProviderConfig,
github_app_config: GithubAppConfig,
scaleset_config: ScalesetConfig,
runner_config: RunnerConfig,
image_id: str | None,
) -> None:
"""Initialize the charm state.
Expand All @@ -320,11 +488,13 @@ def __init__(
provider_config: The OpenStack provider configuration.
github_app_config: The GitHub App configuration.
scaleset_config: The scaleset configuration.
runner_config: The optional runner configuration.
image_id: The OpenStack image UUID from the image builder relation.
"""
self.provider_config = provider_config
self.github_app_config = github_app_config
self.scaleset_config = scaleset_config
self.runner_config = runner_config
self.image_id = image_id

@classmethod
Expand All @@ -343,11 +513,13 @@ def from_charm(cls, charm: ops.CharmBase) -> "CharmState":
provider_config = ProviderConfig.from_charm(charm)
github_app_config = GithubAppConfig.from_charm(charm)
scaleset_config = ScalesetConfig.from_charm(charm)
runner_config = RunnerConfig.from_charm(charm)
image_id = _get_image_id_from_relation(charm)
return cls(
provider_config=provider_config,
github_app_config=github_app_config,
scaleset_config=scaleset_config,
runner_config=runner_config,
image_id=image_id,
)

Expand Down
Loading
Loading