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..f7086bc9 100644 --- a/charms/garm/tests/unit/test_garm_api.py +++ b/charms/garm/tests/unit/test_garm_api.py @@ -254,6 +254,46 @@ 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(): + """ + 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): + 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(): + """ + 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: + 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", [