From ccaeddd709c6abdd4053aadb6a9342a033f0f934 Mon Sep 17 00:00:00 2001 From: charlie4284 Date: Tue, 18 Aug 2026 06:10:40 +0000 Subject: [PATCH 1/2] feat(garm): expose runner instance cleanup APIs --- charms/garm/src/garm_api.py | 44 +++++++++++++++++++++++++ charms/garm/tests/unit/test_garm_api.py | 32 ++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/charms/garm/src/garm_api.py b/charms/garm/src/garm_api.py index d321864b..3f98ef49 100644 --- a/charms/garm/src/garm_api.py +++ b/charms/garm/src/garm_api.py @@ -14,6 +14,7 @@ from garm_client.api.controller_info_api import ControllerInfoApi from garm_client.api.credentials_api import CredentialsApi from garm_client.api.first_run_api import FirstRunApi +from garm_client.api.instances_api import InstancesApi from garm_client.api.login_api import LoginApi from garm_client.api.organizations_api import OrganizationsApi from garm_client.api.providers_api import ProvidersApi @@ -29,6 +30,7 @@ from garm_client.models.create_scale_set_params import CreateScaleSetParams from garm_client.models.create_template_params import CreateTemplateParams from garm_client.models.forge_credentials import ForgeCredentials +from garm_client.models.instance import Instance from garm_client.models.new_user_params import NewUserParams from garm_client.models.organization import Organization from garm_client.models.password_login_params import PasswordLoginParams @@ -529,6 +531,48 @@ def list_scalesets(self) -> list[ScaleSet]: except urllib3.exceptions.HTTPError as exc: raise GarmConnectionError(f"GARM connection error: {exc}") from exc + def list_scale_set_instances(self, scaleset_id: int) -> list[Instance]: + """List runner instances belonging to a scaleset.""" + with self._api_client() as client: + try: + return ( + InstancesApi(api_client=client).list_scale_set_instances( + scaleset_id=str(scaleset_id), + _request_timeout=_REQUEST_TIMEOUT, + ) + or [] + ) + except ApiException as exc: + raise GarmApiError( + f"Failed to list instances for scaleset {scaleset_id} " + f"({exc.status}): {exc.body}" + ) from exc + except urllib3.exceptions.HTTPError as exc: + raise GarmConnectionError(f"GARM connection error: {exc}") from exc + + def delete_instance( + self, + instance_name: str, + *, + force_remove: bool = False, + bypass_gh_unauthorized: bool = False, + ) -> None: + """Request deletion of a GARM runner instance.""" + with self._api_client() as client: + try: + InstancesApi(api_client=client).delete_instance( + instance_name=instance_name, + force_remove=force_remove, + bypass_gh_unauthorized=bypass_gh_unauthorized, + _request_timeout=_REQUEST_TIMEOUT, + ) + except ApiException as exc: + raise GarmApiError( + f"Failed to delete runner {instance_name} ({exc.status}): {exc.body}" + ) from exc + except urllib3.exceptions.HTTPError as exc: + raise GarmConnectionError(f"GARM connection error: {exc}") from exc + def find_org_id(self, org_name: str) -> str | None: """Find a GARM organization's UUID by name. diff --git a/charms/garm/tests/unit/test_garm_api.py b/charms/garm/tests/unit/test_garm_api.py index 09df38c2..362fc349 100644 --- a/charms/garm/tests/unit/test_garm_api.py +++ b/charms/garm/tests/unit/test_garm_api.py @@ -254,6 +254,38 @@ def test_list_scalesets(api_response, expected_names): assert [ss.name for ss in result] == expected_names +def test_list_scale_set_instances_converts_id_to_string(): + """The generated client requires the scaleset ID as a string.""" + client = GarmAuthenticatedClient(BASE_URL, "token") + instance = Instance(name="runner-1", status="running", scale_set_id=42) + with _stub_api_client(client): + with patch("garm_api.InstancesApi") as MockApi: + MockApi.return_value.list_scale_set_instances.return_value = [instance] + result = client.list_scale_set_instances(42) + + MockApi.return_value.list_scale_set_instances.assert_called_once_with( + scaleset_id="42", _request_timeout=30 + ) + assert result == [instance] + + +def test_delete_instance_passes_force_and_bypass_flags(): + """Runner deletion exposes GARM's force and authorization-bypass flags.""" + client = GarmAuthenticatedClient(BASE_URL, "token") + with _stub_api_client(client): + with patch("garm_api.InstancesApi") as MockApi: + client.delete_instance( + "runner-1", force_remove=True, bypass_gh_unauthorized=True + ) + + MockApi.return_value.delete_instance.assert_called_once_with( + instance_name="runner-1", + force_remove=True, + bypass_gh_unauthorized=True, + _request_timeout=30, + ) + + @pytest.mark.parametrize( "target, registered, expected", [ From 776e36cd7d23ab960f04795bad80e3aa31634480 Mon Sep 17 00:00:00 2001 From: charlie4284 Date: Wed, 19 Aug 2026 03:51:29 +0000 Subject: [PATCH 2/2] test(garm): use arrange act assert test docs --- charms/garm/tests/unit/test_garm_api.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/charms/garm/tests/unit/test_garm_api.py b/charms/garm/tests/unit/test_garm_api.py index 362fc349..f7086bc9 100644 --- a/charms/garm/tests/unit/test_garm_api.py +++ b/charms/garm/tests/unit/test_garm_api.py @@ -255,7 +255,11 @@ def test_list_scalesets(api_response, expected_names): def test_list_scale_set_instances_converts_id_to_string(): - """The generated client requires the scaleset ID as a string.""" + """ + arrange: An authenticated client and a generated InstancesApi response. + act: List instances for scaleset ID 42. + assert: The wrapper converts the integer ID to the generated client's required string. + """ client = GarmAuthenticatedClient(BASE_URL, "token") instance = Instance(name="runner-1", status="running", scale_set_id=42) with _stub_api_client(client): @@ -270,7 +274,11 @@ def test_list_scale_set_instances_converts_id_to_string(): def test_delete_instance_passes_force_and_bypass_flags(): - """Runner deletion exposes GARM's force and authorization-bypass flags.""" + """ + arrange: An authenticated client and a generated InstancesApi stub. + act: Delete a runner with both cleanup flags enabled. + assert: The wrapper forwards the runner name, flags, and request timeout. + """ client = GarmAuthenticatedClient(BASE_URL, "token") with _stub_api_client(client): with patch("garm_api.InstancesApi") as MockApi: