diff --git a/charms/garm-configurator/charmcraft.yaml b/charms/garm-configurator/charmcraft.yaml index 6f40b771..0608476b 100644 --- a/charms/garm-configurator/charmcraft.yaml +++ b/charms/garm-configurator/charmcraft.yaml @@ -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: + 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: diff --git a/charms/garm-configurator/src/charm_state.py b/charms/garm-configurator/src/charm_state.py index 3d3be78a..b05c6bcb 100644 --- a/charms/garm-configurator/src/charm_state.py +++ b/charms/garm-configurator/src/charm_state.py @@ -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. @@ -176,12 +187,111 @@ 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)) + 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__( @@ -189,15 +299,18 @@ def __init__( *, 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": @@ -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, ) diff --git a/charms/garm-configurator/tests/unit/test_charm.py b/charms/garm-configurator/tests/unit/test_charm.py index f83144f5..ef71e6b6 100644 --- a/charms/garm-configurator/tests/unit/test_charm.py +++ b/charms/garm-configurator/tests/unit/test_charm.py @@ -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", } @@ -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): @@ -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") diff --git a/charms/tests/integration/test_garm_configurator.py b/charms/tests/integration/test_garm_configurator.py index cbcfc20b..2afc96c0 100644 --- a/charms/tests/integration/test_garm_configurator.py +++ b/charms/tests/integration/test_garm_configurator.py @@ -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( diff --git a/docs/changelog.md b/docs/changelog.md index 3b76131b..9e4211e0 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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.