Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2d471a1
chore: add garm_configurator_v0 relation endpoints to both charms
florentianayuwono Jun 15, 2026
37b616a
feat(garm): add ScalesetReconciler with diff-and-apply logic
florentianayuwono Jun 16, 2026
6494b19
feat(garm): add GarmClient REST API wrapper (garm_api.py)
florentianayuwono Jun 16, 2026
9671594
fix(garm): narrow 409 check in first_run to avoid false positives
florentianayuwono Jun 16, 2026
b1e952e
feat(garm-configurator): write scaleset data to garm-configurator rel…
florentianayuwono Jun 16, 2026
371f5df
feat(garm-configurator): add provider_name and credentials_name to Ch…
florentianayuwono Jun 16, 2026
88a6983
feat(garm): add admin secrets and scaleset reconcile via garm-configu…
florentianayuwono Jun 16, 2026
5d3df8e
test(garm): add integration tests for scaleset create/update/delete/d…
florentianayuwono Jun 16, 2026
d6028e3
fix(garm): preserve scalesets on missing provider/credential; use JSO…
florentianayuwono Jun 16, 2026
dad0f08
style: fix import sort and blank line lint issues
florentianayuwono Jun 16, 2026
4167386
feat(garm): apply configurator runner options via per-scaleset GARM t…
cbartz Jun 16, 2026
01a33bc
fix(garm): revert scaleset to default template when runner options cl…
cbartz Jun 16, 2026
911be83
fix(garm): keep existing custom template when system template is missing
cbartz Jun 16, 2026
baa9e71
fix(garm): cache template data and restrict aproxy excludes to IPv4
cbartz Jun 16, 2026
0725d51
fix(garm): login before first-run and filter template listing
cbartz Jun 16, 2026
aaf74b3
fix(garm-configurator): reject aproxy options without a proxy; dedupe…
cbartz Jun 16, 2026
b521d0f
fix(garm): defensively sanitise aproxy values before rendering nft rules
cbartz Jun 16, 2026
84b4a66
fix(garm): drop out-of-range and inverted aproxy ports
cbartz Jun 16, 2026
7429e8a
fix(garm): reject whitespace URLs, sanitise otel env, drop unused param
cbartz Jun 16, 2026
983da3f
fix(garm): guard hooks on readiness, lazy template listing, safe here…
cbartz Jun 16, 2026
0887312
fix(garm-configurator): reject URLs with invalid ports
cbartz Jun 16, 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
28 changes: 28 additions & 0 deletions charms/garm-configurator/charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,34 @@ 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: |
HTTP proxy that aproxy forwards to. Must be a valid http(s) URL.
aproxy-exclude-addresses:
type: string
description: |
Comma-separated IPv4 addresses/CIDRs to exclude from aproxy forwarding.
aproxy-redirect-ports:
type: string
description: |
Comma-separated ports or port ranges (e.g. 80,443,8000-9000) to forward to aproxy.
otel-collector-endpoint:
type: string
description: |
The OTEL exporter address for the otel-collector to connect to. Must be a valid http(s) URL.
pre-job-script:
type: string
description: |
A bash script appended to the end of the runner pre-job script.

provides:
garm-configurator:
interface: garm_configurator_v0

requires:
image:
Expand Down
35 changes: 35 additions & 0 deletions charms/garm-configurator/src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@

"""Charm entrypoint for the GARM configurator charm."""

import json
import typing

import ops

from charm_state import IMAGE_RELATION_NAME, CharmConfigInvalidError, CharmState

GARM_CONFIGURATOR_RELATION_NAME = "garm-configurator"


class GarmConfiguratorCharm(ops.CharmBase):
"""GARM configurator charm."""
Expand All @@ -24,6 +27,7 @@ def __init__(self, *args: typing.Any) -> None:
for event in [
self.on.config_changed,
self.on.secret_changed,
self.on[GARM_CONFIGURATOR_RELATION_NAME].relation_changed,
self.on[IMAGE_RELATION_NAME].relation_joined,
self.on[IMAGE_RELATION_NAME].relation_changed,
self.on[IMAGE_RELATION_NAME].relation_broken,
Expand Down Expand Up @@ -58,6 +62,37 @@ def _reconcile(self, event: ops.EventBase) -> None:
}
)

rc = state.runner_config
relation_data = {
"name": state.scaleset_config.name,
"provider_name": state.provider_name,
"credentials_name": state.credentials_name,
"image_id": str(state.image_id),
"flavor": state.scaleset_config.flavor,
"os_arch": state.scaleset_config.os_arch,
"min_idle_runner": str(state.scaleset_config.min_idle_runner),
"max_runner": str(state.scaleset_config.max_runner),
"labels": (
state.scaleset_config.labels
if isinstance(state.scaleset_config.labels, str)
else ",".join(state.scaleset_config.labels)
),
"runner_group": state.scaleset_config.runner_group,
"pre_install_scripts": json.dumps(
{"pre_install.sh": state.scaleset_config.pre_install_scripts}
if state.scaleset_config.pre_install_scripts
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 "",
}
for garm_relation in self.model.relations[GARM_CONFIGURATOR_RELATION_NAME]:
garm_relation.data[self.unit].update(relation_data)

if relation is None:
self.unit.status = ops.WaitingStatus("Waiting for image builder relation")
elif state.image_id is None:
Expand Down
185 changes: 185 additions & 0 deletions charms/garm-configurator/src/charm_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

"""State of the GARM configurator charm."""

import ipaddress
import urllib.parse

import ops
from pydantic import BaseModel

Expand Down Expand Up @@ -30,6 +33,13 @@
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"

IMAGE_RELATION_NAME = "image"


Expand Down Expand Up @@ -292,13 +302,173 @@ def from_charm(cls, charm: ops.CharmBase) -> "ScalesetConfig":
)


def _validate_http_url(config_name: str, value: str) -> None:
"""Raise CharmConfigInvalidError if value is not a valid http(s) URL.

Args:
config_name: Config option name used in the error message.
value: URL string to validate.

Raises:
CharmConfigInvalidError: When the URL has whitespace, a non-http(s) scheme,
or an empty netloc.
"""
# Reject embedded whitespace/control characters: the value is later rendered
# into scripts and env files, where a newline could inject extra lines.
if any(char.isspace() for char in value):
raise CharmConfigInvalidError(f"{config_name} must be a valid http(s) URL")
parsed = urllib.parse.urlparse(value)
try:
# Accessing .port raises ValueError on a non-numeric / out-of-range port,
# which urlparse otherwise accepts silently.
port = parsed.port
except ValueError as exc:
raise CharmConfigInvalidError(f"{config_name} must be a valid http(s) URL") from exc
if parsed.scheme not in ("http", "https") or not parsed.hostname or port == 0:
raise CharmConfigInvalidError(f"{config_name} must be a valid http(s) URL")


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

@classmethod
def from_charm(cls, charm: ops.CharmBase) -> "RunnerConfig":
"""Initialize the runner config from charm, applying best-effort validation.

All options are optional; unset or empty values result in None fields.

Args:
charm: The charm instance.

Raises:
CharmConfigInvalidError: If a set option fails validation.

Returns:
The parsed runner configuration.
"""
url_options = (
DOCKERHUB_MIRROR_CONFIG_NAME,
RUNNER_HTTP_PROXY_CONFIG_NAME,
OTEL_COLLECTOR_ENDPOINT_CONFIG_NAME,
)
url_values: dict[str, str | None] = {}
for config_name in url_options:
raw = charm.config.get(config_name)
value = str(raw).strip() if raw else None
if value:
_validate_http_url(config_name, value)
url_values[config_name] = value or None

raw_exclude = charm.config.get(APROXY_EXCLUDE_ADDRESSES_CONFIG_NAME)
aproxy_exclude_addresses: str | None = None
if raw_exclude:
tokens = [t.strip() for t in str(raw_exclude).split(",")]
# Reject empty tokens (e.g. trailing comma) as they signal a misconfiguration.
for token in tokens:
# Each token must be a valid IPv4 address or CIDR: the values are
# rendered into an nft IPv4 (`table ip`) ruleset, so a hostname or
# IPv6 address would pass validation but fail at runtime (and the
# failure would be swallowed by ``|| true``).
try:
network = ipaddress.ip_network(token, strict=False)
except ValueError as exc:
raise CharmConfigInvalidError(
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 CharmConfigInvalidError(
f"{APROXY_EXCLUDE_ADDRESSES_CONFIG_NAME} only supports IPv4 addresses or "
f"CIDRs (the aproxy nft ruleset is IPv4-only); got: {token!r}"
)
aproxy_exclude_addresses = ",".join(tokens)

raw_ports = charm.config.get(APROXY_REDIRECT_PORTS_CONFIG_NAME)
aproxy_redirect_ports: str | None = None
if raw_ports:
tokens_ports = [t.strip() for t in str(raw_ports).split(",")]
for token in tokens_ports:
_validate_port_token(token)
aproxy_redirect_ports = ",".join(tokens_ports)

# 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 (aproxy_exclude_addresses or aproxy_redirect_ports) and not url_values[
RUNNER_HTTP_PROXY_CONFIG_NAME
]:
raise CharmConfigInvalidError(
f"{APROXY_EXCLUDE_ADDRESSES_CONFIG_NAME} and {APROXY_REDIRECT_PORTS_CONFIG_NAME} "
f"require {RUNNER_HTTP_PROXY_CONFIG_NAME} to be set"
)

raw_script = charm.config.get(PRE_JOB_SCRIPT_CONFIG_NAME)
pre_job_script = str(raw_script).strip() if raw_script else None

return cls(
dockerhub_mirror=url_values[DOCKERHUB_MIRROR_CONFIG_NAME],
runner_http_proxy=url_values[RUNNER_HTTP_PROXY_CONFIG_NAME],
aproxy_exclude_addresses=aproxy_exclude_addresses,
aproxy_redirect_ports=aproxy_redirect_ports,
otel_collector_endpoint=url_values[OTEL_COLLECTOR_ENDPOINT_CONFIG_NAME],
pre_job_script=pre_job_script or None,
)


def _validate_port_token(token: str) -> None:
"""Raise CharmConfigInvalidError if token is not a valid port or N-M range.

Args:
token: A single comma-split token from the aproxy-redirect-ports config.

Raises:
CharmConfigInvalidError: When the token is not a valid port or N<=M range in 1..65535.
"""
error_msg = (
f"{APROXY_REDIRECT_PORTS_CONFIG_NAME} must be a comma-separated list of "
"ports or N-M ranges in 1..65535"
)
if "-" in token:
parts = token.split("-", 1)
try:
low, high = int(parts[0]), int(parts[1])
except (ValueError, IndexError):
raise CharmConfigInvalidError(error_msg)
if not (1 <= low <= 65535 and 1 <= high <= 65535 and low <= high):
raise CharmConfigInvalidError(error_msg)
else:
try:
port = int(token)
except ValueError:
raise CharmConfigInvalidError(error_msg)
if not 1 <= port <= 65535:
raise CharmConfigInvalidError(error_msg)


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 @@ -308,6 +478,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 @@ -316,11 +487,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 @@ -339,14 +512,26 @@ 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,
)

@property
def provider_name(self) -> str:
"""Derived GARM provider name for scalesets."""
return f"openstack-{self.provider_config.project_name}"

@property
def credentials_name(self) -> str:
"""Derived GARM credentials name for scalesets."""
return f"github-app-{self.github_app_config.client_id}"


def _get_image_id_from_relation(charm: ops.CharmBase) -> str | None:
"""Return the OpenStack image UUID from the image builder relation, if available.
Expand Down
Loading