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
44 changes: 44 additions & 0 deletions charms/garm-configurator/charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,50 @@ config:
description: |
Juju secret containing the GitHub App private key (PEM format).
The secret content must have a "value" key with the private key string.
name:
type: string
description: |
The name of the scaleset.
flavor:
type: string
description: |
The resource flavor to use for runners in the scaleset.
os-arch:
type: string
description: |
The CPU architecture for runners in the scaleset.
min-idle-runner:
type: int
default: 0
description: |
Minimum number of idle runners in the scaleset.
max-runner:
type: int
default: 0
description: |
Maximum number of runners in the scaleset.
labels:
type: string
description: |
Comma-separated list of labels to add to runners.
repo:
type: string
description: |
Repository to register runners to. Mutually exclusive with org.
org:
Comment thread
yhaliaw marked this conversation as resolved.
type: string
description: |
Organization to register runners to. Mutually exclusive with repo.
runner-group:
type: string
default: default
description: |
Runner group for org registration. Ignored when repo is set.
pre-install-scripts:
type: string
description: |
A Python dict-like string containing key-value pairs of script name
to bash script to be run prior to runner installation.

parts:
charm:
Expand Down
115 changes: 115 additions & 0 deletions charms/garm-configurator/src/charm_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@
GITHUB_APP_INSTALLATION_ID_CONFIG_NAME = "github-app-installation-id"
GITHUB_APP_PRIVATE_KEY_CONFIG_NAME = "github-app-private-key" # nosec

SCALESET_NAME_CONFIG_NAME = "name"
SCALESET_FLAVOR_CONFIG_NAME = "flavor"
SCALESET_OS_ARCH_CONFIG_NAME = "os-arch"
SCALESET_MIN_IDLE_RUNNER_CONFIG_NAME = "min-idle-runner"
SCALESET_MAX_RUNNER_CONFIG_NAME = "max-runner"
SCALESET_LABELS_CONFIG_NAME = "labels"
SCALESET_REPO_CONFIG_NAME = "repo"
SCALESET_ORG_CONFIG_NAME = "org"
SCALESET_RUNNER_GROUP_CONFIG_NAME = "runner-group"
SCALESET_PRE_INSTALL_SCRIPTS_CONFIG_NAME = "pre-install-scripts"


class CharmConfigInvalidError(Exception):
"""Raised when charm configuration is invalid.
Expand Down Expand Up @@ -176,28 +187,130 @@ def from_charm(cls, charm: ops.CharmBase) -> "GithubAppConfig":
)


class ScalesetConfig(BaseModel):
"""Scaleset configuration.

Attributes:
name: The name of the scaleset.
flavor: The resource flavor for runners.
os_arch: The CPU architecture for runners.
min_idle_runner: Minimum number of idle runners.
max_runner: Maximum number of runners.
labels: Comma-separated list of labels for runners.
repo: Repository to register runners to.
org: Organization to register runners to.
runner_group: Runner group for org registration.
pre_install_scripts: Script name to bash script pairs for pre-installation.
"""

name: str
flavor: str
os_arch: str
min_idle_runner: int
max_runner: int
labels: str = ""
repo: str | None = None
org: str | None = None
runner_group: str = "default"
pre_install_scripts: str | None = None

@classmethod
def from_charm(cls, charm: ops.CharmBase) -> "ScalesetConfig":
"""Initialize the scaleset config from charm.

Args:
charm: The charm instance.

Raises:
CharmConfigInvalidError: If any configuration is missing or invalid.

Returns:
The parsed scaleset configuration.
"""
required_string_configs = (
SCALESET_NAME_CONFIG_NAME,
SCALESET_FLAVOR_CONFIG_NAME,
SCALESET_OS_ARCH_CONFIG_NAME,
)
for key in required_string_configs:
value = charm.config.get(key)
if not value or not str(value).strip():
raise CharmConfigInvalidError(f"Missing required configuration: {key}")

min_idle_runner = int(charm.config.get(SCALESET_MIN_IDLE_RUNNER_CONFIG_NAME, 0))
if min_idle_runner < 0:
raise CharmConfigInvalidError(
f"{SCALESET_MIN_IDLE_RUNNER_CONFIG_NAME} must be non-negative"
)

max_runner = int(charm.config.get(SCALESET_MAX_RUNNER_CONFIG_NAME, 0))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add a check that max >= min?

if max_runner < 0:
raise CharmConfigInvalidError(
f"{SCALESET_MAX_RUNNER_CONFIG_NAME} must be non-negative"
)

repo = charm.config.get(SCALESET_REPO_CONFIG_NAME)
repo = str(repo).strip() if repo else None
org = charm.config.get(SCALESET_ORG_CONFIG_NAME)
org = str(org).strip() if org else None
runner_group = str(charm.config.get(SCALESET_RUNNER_GROUP_CONFIG_NAME, "default")).strip()

if repo and org:
raise CharmConfigInvalidError(
f"{SCALESET_REPO_CONFIG_NAME} and {SCALESET_ORG_CONFIG_NAME} "
f"are mutually exclusive"
)
if not repo and not org:
raise CharmConfigInvalidError(
f"At least one of {SCALESET_REPO_CONFIG_NAME} or "
f"{SCALESET_ORG_CONFIG_NAME} must be provided"
)

labels = charm.config.get(SCALESET_LABELS_CONFIG_NAME)
labels = str(labels).strip() if labels else ""
pre_install_scripts = charm.config.get(SCALESET_PRE_INSTALL_SCRIPTS_CONFIG_NAME)
pre_install_scripts = str(pre_install_scripts) if pre_install_scripts else None

return cls(
name=str(charm.config.get(SCALESET_NAME_CONFIG_NAME)).strip(),
flavor=str(charm.config.get(SCALESET_FLAVOR_CONFIG_NAME)).strip(),
os_arch=str(charm.config.get(SCALESET_OS_ARCH_CONFIG_NAME)).strip(),
min_idle_runner=min_idle_runner,
max_runner=max_runner,
labels=labels,
repo=repo,
org=org,
runner_group=runner_group,
pre_install_scripts=pre_install_scripts,
)


class CharmState:
"""The charm state.

Attributes:
provider_config: OpenStack provider configuration.
github_app_config: GitHub App configuration.
scaleset_config: Scaleset configuration.
"""

def __init__(
self,
*,
provider_config: ProviderConfig,
github_app_config: GithubAppConfig,
scaleset_config: ScalesetConfig,
) -> None:
"""Initialize the charm state.

Args:
provider_config: The OpenStack provider configuration.
github_app_config: The GitHub App configuration.
scaleset_config: The scaleset configuration.
"""
self.provider_config = provider_config
self.github_app_config = github_app_config
self.scaleset_config = scaleset_config

@classmethod
def from_charm(cls, charm: ops.CharmBase) -> "CharmState":
Expand All @@ -214,7 +327,9 @@ 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)
return cls(
provider_config=provider_config,
github_app_config=github_app_config,
scaleset_config=scaleset_config,
)
108 changes: 108 additions & 0 deletions charms/garm-configurator/tests/unit/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ def _valid_config(secret: Secret, private_key_secret: Secret) -> dict:
"github-app-client-id": "12345",
"github-app-installation-id": "67890",
"github-app-private-key": private_key_secret.id,
"name": "my-scaleset",
"flavor": "m1.large",
"os-arch": "amd64",
"min-idle-runner": 0,
"max-runner": 5,
"repo": "myorg/myrepo",
}


Expand Down Expand Up @@ -72,6 +78,9 @@ def test_charm_active_with_valid_config():
_MISSING_CONFIG_SENTINEL,
id="missing-github-app-private-key",
),
pytest.param("name", _MISSING_CONFIG_SENTINEL, id="missing-name"),
pytest.param("flavor", "", id="empty-flavor"),
pytest.param("os-arch", " ", id="whitespace-only-os-arch"),
],
)
def test_charm_blocked_missing_or_empty_config(config_key: str, override_value: object):
Expand Down Expand Up @@ -193,3 +202,102 @@ def test_charm_blocked_github_app_private_key_secret_not_found():
assert out.unit_status == ops.BlockedStatus(
"github-app-private-key secret is invalid or missing 'value' key"
)


def test_charm_blocked_negative_min_idle_runner():
"""
arrange: min-idle-runner is negative.
act: Run config-changed.
assert: Unit status is Blocked.
"""
ctx = Context(GarmConfiguratorCharm)
secret = _make_secret()
pk_secret = _make_private_key_secret()
config = _valid_config(secret, pk_secret)
config["min-idle-runner"] = -1
state = State(config=config, secrets=[secret, pk_secret])
out = ctx.run(ctx.on.config_changed(), state)
assert out.unit_status == ops.BlockedStatus("min-idle-runner must be non-negative")


def test_charm_blocked_negative_max_runner():
"""
arrange: max-runner is negative.
act: Run config-changed.
assert: Unit status is Blocked.
"""
ctx = Context(GarmConfiguratorCharm)
secret = _make_secret()
pk_secret = _make_private_key_secret()
config = _valid_config(secret, pk_secret)
config["max-runner"] = -5
state = State(config=config, secrets=[secret, pk_secret])
out = ctx.run(ctx.on.config_changed(), state)
assert out.unit_status == ops.BlockedStatus("max-runner must be non-negative")


def test_charm_blocked_neither_repo_nor_org():
"""
arrange: Neither repo nor org is set.
act: Run config-changed.
assert: Unit status is Blocked.
"""
ctx = Context(GarmConfiguratorCharm)
secret = _make_secret()
pk_secret = _make_private_key_secret()
config = _valid_config(secret, pk_secret)
del config["repo"]
state = State(config=config, secrets=[secret, pk_secret])
out = ctx.run(ctx.on.config_changed(), state)
assert out.unit_status == ops.BlockedStatus("At least one of repo or org must be provided")


def test_charm_blocked_repo_and_org_both_set():
"""
arrange: Both repo and org are set.
act: Run config-changed.
assert: Unit status is Blocked.
"""
ctx = Context(GarmConfiguratorCharm)
secret = _make_secret()
pk_secret = _make_private_key_secret()
config = _valid_config(secret, pk_secret)
config["org"] = "myorg"
state = State(config=config, secrets=[secret, pk_secret])
out = ctx.run(ctx.on.config_changed(), state)
assert out.unit_status == ops.BlockedStatus("repo and org are mutually exclusive")


def test_charm_active_with_org_and_runner_group():
"""
arrange: org and runner-group are set (no repo).
act: Run config-changed.
assert: Unit status is Active.
"""
ctx = Context(GarmConfiguratorCharm)
secret = _make_secret()
pk_secret = _make_private_key_secret()
config = _valid_config(secret, pk_secret)
del config["repo"]
config["org"] = "myorg"
config["runner-group"] = "my-group"
state = State(config=config, secrets=[secret, pk_secret])
out = ctx.run(ctx.on.config_changed(), state)
assert out.unit_status == ops.ActiveStatus("Ready")


def test_charm_active_with_org_only():
"""
arrange: Only org is set (no repo, no runner-group).
act: Run config-changed.
assert: Unit status is Active.
"""
ctx = Context(GarmConfiguratorCharm)
secret = _make_secret()
pk_secret = _make_private_key_secret()
config = _valid_config(secret, pk_secret)
del config["repo"]
config["org"] = "myorg"
state = State(config=config, secrets=[secret, pk_secret])
out = ctx.run(ctx.on.config_changed(), state)
assert out.unit_status == ops.ActiveStatus("Ready")
4 changes: 4 additions & 0 deletions charms/tests/integration/test_garm_configurator.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ def deploy_garm_configurator_app_fixture(
"github-app-client-id": "12345",
"github-app-installation-id": "67890",
"github-app-private-key": private_key_secret_uri,
"name": "test-scaleset",
"flavor": "m1.large",
"os-arch": "amd64",
"repo": "myorg/myrepo",
},
)
juju.wait(
Expand Down
4 changes: 4 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

Each revision is versioned by the date of the revision.

## 2026-06-03

- add GARM Scaleset configurations for the GARM configurator charm.

## 2026-06-02

- add OpenStack and GitHub creds/configs for the GARM configurator charm.
Expand Down
Loading