diff --git a/src/together/lib/cli/__init__.py b/src/together/lib/cli/__init__.py index 445871f4..ffac00a2 100644 --- a/src/together/lib/cli/__init__.py +++ b/src/together/lib/cli/__init__.py @@ -61,9 +61,11 @@ FINE_TUNING_DOWNLOAD_HELP_EXAMPLES, BETA_CLUSTERS_STORAGE_HELP_EXAMPLES, FILES_RETRIEVE_CONTENT_HELP_EXAMPLES, + BETA_CLUSTERS_REMEDIATIONS_HELP_EXAMPLES, BETA_CLUSTERS_STORAGE_CREATE_HELP_EXAMPLES, BETA_CLUSTERS_STORAGE_UPDATE_HELP_EXAMPLES, BETA_CLUSTERS_GET_CREDENTIALS_HELP_EXAMPLES, + BETA_CLUSTERS_REMEDIATIONS_CREATE_HELP_EXAMPLES, ) from together.lib.cli.utils._help_formatter import help_formatter from together.lib.cli.utils._preparse_tokens import preparse_tokens @@ -486,6 +488,44 @@ async def run_command() -> None: ) storage_app.command((f"{_CLI}.beta.clusters.storage.delete:delete"), help="Delete a storage volume", alias="-d") +### Clusters > Remediations API commands +remediations_app = clusters_app.command( + App( + name="remediations", + help="Manage node remediations", + group="Subcommands", + help_epilogue=BETA_CLUSTERS_REMEDIATIONS_HELP_EXAMPLES, + ) +) +remediations_app.command( + (f"{_CLI}.beta.clusters.remediations.create:create"), + alias="-c", + help="Create a node remediation", + help_epilogue=BETA_CLUSTERS_REMEDIATIONS_CREATE_HELP_EXAMPLES, +) +remediations_app.command( + (f"{_CLI}.beta.clusters.remediations.list:list"), + alias="ls", + help="List node remediations", +) +remediations_app.command( + (f"{_CLI}.beta.clusters.remediations.retrieve:retrieve"), + alias="get", + help="Get remediation details", +) +remediations_app.command( + (f"{_CLI}.beta.clusters.remediations.approve:approve"), + help="Approve a pending remediation", +) +remediations_app.command( + (f"{_CLI}.beta.clusters.remediations.cancel:cancel"), + help="Cancel a pending remediation", +) +remediations_app.command( + (f"{_CLI}.beta.clusters.remediations.reject:reject"), + help="Reject a pending remediation", +) + ### Jig commands jig_app = beta_app.command( App(name="jig", help="Build, deploy, and manage custom containers", help_epilogue=JIG_HELP_EXAMPLES) diff --git a/src/together/lib/cli/api/beta/clusters/remediations/_resolve_remediation.py b/src/together/lib/cli/api/beta/clusters/remediations/_resolve_remediation.py new file mode 100644 index 00000000..55a49e7b --- /dev/null +++ b/src/together/lib/cli/api/beta/clusters/remediations/_resolve_remediation.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import sys + +from together import omit +from together._types import Omit +from together.lib.cli.utils.config import CLIConfigParameter +from together.lib.cli.utils._console import console +from together.types.beta.clusters.remediation import Remediation + + +async def resolve_remediation(config: CLIConfigParameter, remediation_id: str) -> Remediation: + clusters = await config.client.beta.clusters.list() + + for cluster in clusters.clusters: + page_token: str | Omit = omit + while True: + response = await config.client.beta.clusters.remediations.list( + "-", + cluster_id=cluster.cluster_id, + page_size=100, + page_token=page_token, + ) + for remediation in response.remediations: + if remediation.id == remediation_id: + return remediation + + if not response.has_next or not response.next_page_token: + break + page_token = response.next_page_token + + console.print(f"[red]Error:[/red] Remediation not found: {remediation_id}") + sys.exit(1) diff --git a/src/together/lib/cli/api/beta/clusters/remediations/approve.py b/src/together/lib/cli/api/beta/clusters/remediations/approve.py new file mode 100644 index 00000000..eb81a534 --- /dev/null +++ b/src/together/lib/cli/api/beta/clusters/remediations/approve.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Optional, Annotated + +from cyclopts import Parameter + +from together import omit +from together._utils._json import openapi_dumps +from together.lib.cli.utils.config import CLIConfigParameter +from together.lib.cli.utils._console import console +from together.lib.cli.components.loader import show_loading_status +from together.lib.cli.api.beta.clusters.remediations._resolve_remediation import resolve_remediation + + +async def approve( + remediation_id: str, + comment: Annotated[Optional[str], Parameter(help="Comment explaining the approval")] = None, + *, + config: CLIConfigParameter, +) -> None: + """Approve a pending remediation.""" + remediation = await show_loading_status("Finding remediation...", resolve_remediation(config, remediation_id)) + response = await show_loading_status( + "Approving remediation...", + config.client.beta.clusters.remediations.approve( + remediation_id, + cluster_id=remediation.cluster_id, + instance_id=remediation.instance_id, + comment=comment or omit, + ), + ) + + if config.json: + console.print_json(openapi_dumps(response).decode("utf-8")) + return + + console.print(f"[blue]Remediation approved.[/blue] ({response.id})") diff --git a/src/together/lib/cli/api/beta/clusters/remediations/cancel.py b/src/together/lib/cli/api/beta/clusters/remediations/cancel.py new file mode 100644 index 00000000..99adc6bf --- /dev/null +++ b/src/together/lib/cli/api/beta/clusters/remediations/cancel.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from together._utils._json import openapi_dumps +from together.lib.cli.utils.config import CLIConfigParameter +from together.lib.cli.utils._console import console +from together.lib.cli.components.loader import show_loading_status +from together.lib.cli.api.beta.clusters.remediations._resolve_remediation import resolve_remediation + + +async def cancel( + remediation_id: str, + *, + config: CLIConfigParameter, +) -> None: + """Cancel a pending remediation.""" + remediation = await show_loading_status("Finding remediation...", resolve_remediation(config, remediation_id)) + response = await show_loading_status( + "Cancelling remediation...", + config.client.beta.clusters.remediations.cancel( + remediation_id, + cluster_id=remediation.cluster_id, + instance_id=remediation.instance_id, + ), + ) + + if config.json: + console.print_json(openapi_dumps(response).decode("utf-8")) + return + + console.print(f"[blue]Remediation cancelled.[/blue] ({response.id})") diff --git a/src/together/lib/cli/api/beta/clusters/remediations/create.py b/src/together/lib/cli/api/beta/clusters/remediations/create.py new file mode 100644 index 00000000..8a6f2db4 --- /dev/null +++ b/src/together/lib/cli/api/beta/clusters/remediations/create.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from typing import Literal, Optional, Annotated, cast + +from cyclopts import Parameter + +from together import omit +from together._utils._json import openapi_dumps +from together.lib.cli.utils.config import CLIConfigParameter +from together.lib.cli.utils._console import console +from together.lib.cli.components.loader import show_loading_status + +RemediationModeParameter = Annotated[ + Literal[ + "VM_ONLY", + "HOST_AWARE", + "EVICT_WITHOUT_REPLACEMENT", + "REBOOT_VM", + ], + Parameter(help="The type of remediation to perform"), +] + + +async def create( + cluster_id: Annotated[str, Parameter(help="The ID of the cluster")], + instance_id: Annotated[str, Parameter(help="The ID of the node within the cluster to remediate")], + *, + mode: RemediationModeParameter, + remediation_id: Annotated[Optional[str], Parameter(help="Client-specified ID for idempotency")] = None, + reason: Annotated[Optional[str], Parameter(help="Reason for the remediation")] = None, + config: CLIConfigParameter, +) -> None: + """Create a node remediation for an instance.""" + safe_mode = cast( + Literal[ + "REMEDIATION_MODE_VM_ONLY", + "REMEDIATION_MODE_HOST_AWARE", + "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", + "REMEDIATION_MODE_REBOOT_VM", + ], + f"REMEDIATION_MODE_{mode}", + ) + + response = await show_loading_status( + "Creating remediation...", + config.client.beta.clusters.remediations.create( + instance_id, + cluster_id=cluster_id, + mode=safe_mode, + remediation_id=remediation_id or omit, + reason=reason or omit, + ), + ) + + if config.json: + console.print_json(openapi_dumps(response).decode("utf-8")) + return + + console.print(f"[green]√ Remediation created[/green] [dim]({response.id})[/dim]") + console.print(f" Remediations may take some time to complete.\n") + console.print(f" To retrieve the status:") + console.print(f" [dim]-[/dim] [primary]tg beta clusters remediations {response.id}[/primary]") diff --git a/src/together/lib/cli/api/beta/clusters/remediations/list.py b/src/together/lib/cli/api/beta/clusters/remediations/list.py new file mode 100644 index 00000000..22c70231 --- /dev/null +++ b/src/together/lib/cli/api/beta/clusters/remediations/list.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from typing import Optional, Annotated + +from cyclopts import Parameter + +from together import omit +from together._utils._json import openapi_dumps +from together.lib.utils.tools import format_datetime +from together.lib.cli.utils.config import CLIConfigParameter +from together.lib.cli.utils._console import console +from together.lib.cli.components.list import ListTable +from together.lib.cli.components.loader import show_loading_status + + +async def list( + cluster_id: str, + instance_id: Annotated[Optional[str], Parameter(help="Instance ID to list remediations for")] = None, + after: Annotated[Optional[str], Parameter(help="Pagination token from a previous request")] = None, + *, + config: CLIConfigParameter, +) -> None: + """List node remediations for a cluster or instance.""" + response = await show_loading_status( + "Loading remediations...", + config.client.beta.clusters.remediations.list( + instance_id or "-", + cluster_id=cluster_id, + page_token=after or omit, + ), + ) + + if config.json: + console.print_json(openapi_dumps(response).decode("utf-8")) + return + + table = ListTable(title="Cluster Remediations", empty_message="No remediations found for this cluster.") + table.add_column("Created") + table.add_primary_column("Instance", ratio=3) + table.add_column("Mode") + table.add_column("State") + table.add_column("Remediation ID", ratio=3) + + for remediation in response.remediations: + table.add_row( + format_datetime(remediation.create_time) if remediation.create_time else "-", + remediation.instance_id, + remediation.mode.replace("REMEDIATION_MODE_", ""), + _colorize(remediation.state), + remediation.id, + ) + + console.print(table) + if response.has_next and response.next_page_token: + command = f"tg beta clusters remediations ls {cluster_id}" + if instance_id: + command += f" {instance_id}" + console.print("\n[blue dim]To display the next page, run:[/blue dim]") + console.print(f" [dim]-[/dim] [white]{command} --after {response.next_page_token}[/white]") + + +def _colorize(state: str) -> str: + state_colors = { + "PENDING_APPROVAL": "yellow", + "PENDING": "yellow", + "RUNNING": "yellow", + "SUCCEEDED": "green", + "FAILED": "red", + "CANCELLED": "dim", + "AUTO_RESOLVED": "green", + } + color = state_colors[state] if state in state_colors else "white" + return f"[{color}]{state}[/{color}]" diff --git a/src/together/lib/cli/api/beta/clusters/remediations/reject.py b/src/together/lib/cli/api/beta/clusters/remediations/reject.py new file mode 100644 index 00000000..6fab890c --- /dev/null +++ b/src/together/lib/cli/api/beta/clusters/remediations/reject.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Optional, Annotated + +from cyclopts import Parameter + +from together import omit +from together._utils._json import openapi_dumps +from together.lib.cli.utils.config import CLIConfigParameter +from together.lib.cli.utils._console import console +from together.lib.cli.components.loader import show_loading_status +from together.lib.cli.api.beta.clusters.remediations._resolve_remediation import resolve_remediation + + +async def reject( + remediation_id: str, + comment: Annotated[Optional[str], Parameter(help="Comment explaining the rejection")] = None, + *, + config: CLIConfigParameter, +) -> None: + """Reject a pending remediation.""" + remediation = await show_loading_status("Finding remediation...", resolve_remediation(config, remediation_id)) + response = await show_loading_status( + "Rejecting remediation...", + config.client.beta.clusters.remediations.reject( + remediation_id, + cluster_id=remediation.cluster_id, + instance_id=remediation.instance_id, + comment=comment or omit, + ), + ) + + if config.json: + console.print_json(openapi_dumps(response).decode("utf-8")) + return + + console.print(f"[blue]Remediation rejected.[/blue] ({response.id})") diff --git a/src/together/lib/cli/api/beta/clusters/remediations/retrieve.py b/src/together/lib/cli/api/beta/clusters/remediations/retrieve.py new file mode 100644 index 00000000..8143dc45 --- /dev/null +++ b/src/together/lib/cli/api/beta/clusters/remediations/retrieve.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from together._utils._json import openapi_dumps +from together.lib.cli.utils.config import CLIConfigParameter +from together.lib.cli.utils._console import console +from together.lib.cli.components.loader import show_loading_status +from together.lib.cli.components.model_dump import print_model_dump +from together.lib.cli.api.beta.clusters.remediations._resolve_remediation import resolve_remediation + + +async def retrieve( + remediation_id: str, + *, + config: CLIConfigParameter, +) -> None: + """Retrieve remediation details.""" + remediation = await show_loading_status("Finding remediation...", resolve_remediation(config, remediation_id)) + response = await show_loading_status( + "Retrieving remediation...", + config.client.beta.clusters.remediations.retrieve( + remediation_id, + cluster_id=remediation.cluster_id, + instance_id=remediation.instance_id, + ), + ) + + if config.json: + console.print_json(openapi_dumps(response).decode("utf-8")) + return + + print_model_dump(response, show_nulls=False, only_set_fields=True) diff --git a/src/together/lib/cli/components/model_dump.py b/src/together/lib/cli/components/model_dump.py index fe2e2d19..4bef6680 100644 --- a/src/together/lib/cli/components/model_dump.py +++ b/src/together/lib/cli/components/model_dump.py @@ -12,9 +12,25 @@ def print_model_dump( - model: BaseModel, show_nulls: bool = True, expand: bool = True, padding: PaddingDimensions = (0, 1, 0, 0) + model: BaseModel, + show_nulls: bool = True, + expand: bool = True, + padding: PaddingDimensions = (0, 1, 0, 0), + *, + only_set_fields: bool = False, ) -> None: - """Print an entire model with __decent__ formatting.""" + """Print an entire model with __decent__ formatting. + + Args: + model: The response model to render. + show_nulls: When True, include fields whose value is None or empty, displayed as + "n/a". When False, omit those fields entirely. + expand: Passed to the Rich table; when True, the table stretches to the terminal width. + padding: Rich table cell padding as (top, right, bottom, left). + only_set_fields: When True, only include fields present in the API response + (model.model_fields_set). Use this to avoid showing optional fields that were never + sent and still carry a default value. When False, all model fields are shown. + """ def _pretty_print_results( results: Any, show_nulls: bool = True, expand: bool = False, padding: PaddingDimensions = (0, 1, 0, 0) @@ -37,6 +53,8 @@ def _pretty_print_results( table.add_row("-", _pretty_print_results(item)) elif isinstance(results, BaseModel): table.add_row("", _pretty_print_results(results.model_dump(), show_nulls=show_nulls)) + elif isinstance(results, datetime): + table.add_row("", _colorize_value(format_datetime(results))) else: table.add_row("", _colorize_value(results)) return table @@ -70,18 +88,25 @@ def _dump_sorted_model(model: BaseModel) -> dict[str, Any]: - Lists last """ + model_dump = model.model_dump() + + if only_set_fields: + model_dump = {k: v for k, v in model_dump.items() if k in model.model_fields_set} + def _sort_items(key: str, value: Any) -> int: - # Returns a sort key: 0 for ID fields, 1 for primitives, 2 for dicts/objects, 3 for lists + # Returns a sort key: 0 for ID fields, 1 for dates, 2 for primitives, 3 for dicts/objects, 4 for lists if key.endswith("_id"): return 0 + elif isinstance(value, datetime): + return 1 elif isinstance(value, dict) or isinstance(value, BaseModel): - return 2 - elif isinstance(value, list): return 3 + elif isinstance(value, list): + return 4 else: - return 1 + return 2 - return dict(sorted(model.model_dump().items(), key=lambda kv: _sort_items(kv[0], kv[1]))) + return dict(sorted(model_dump.items(), key=lambda kv: _sort_items(kv[0], kv[1]))) console.print( _pretty_print_results(_dump_sorted_model(model), show_nulls=show_nulls, expand=expand, padding=padding) diff --git a/src/together/lib/cli/utils/_help_examples.py b/src/together/lib/cli/utils/_help_examples.py index 224eee8a..75ff7cb2 100644 --- a/src/together/lib/cli/utils/_help_examples.py +++ b/src/together/lib/cli/utils/_help_examples.py @@ -251,6 +251,10 @@ [dim]-[/dim] Update or delete a cluster: [primary]tg beta clusters update --num-gpus 16 --cluster-type KUBERNETES[/primary] [primary]tg beta clusters delete [/primary] + +[dim]-[/dim] Manage node remediations: + [primary]tg beta clusters remediations ls [/primary] + [primary]tg beta clusters remediations create --mode VM_ONLY[/primary] """ BETA_CLUSTERS_CREATE_HELP_EXAMPLES = """[dim]Examples:[/dim] @@ -329,6 +333,39 @@ [primary]tg beta clusters storage update --size-tib 4[/primary] """ +BETA_CLUSTERS_REMEDIATIONS_HELP_EXAMPLES = """[dim]Examples:[/dim] +[dim]-[/dim] List all remediations for a cluster: + [primary]tg beta clusters remediations ls [/primary] + +[dim]-[/dim] List remediations for one instance: + [primary]tg beta clusters remediations ls [/primary] + +[dim]-[/dim] Create a remediation: + [primary]tg beta clusters remediations create --mode VM_ONLY --reason "node unhealthy"[/primary] + +[dim]-[/dim] Get remediation details: + [primary]tg beta clusters remediations [/primary] + +[dim]-[/dim] Review automated remediations: + [primary]tg beta clusters remediations approve [/primary] + [primary]tg beta clusters remediations reject --comment "already handled"[/primary] + [primary]tg beta clusters remediations cancel [/primary] +""" + +BETA_CLUSTERS_REMEDIATIONS_CREATE_HELP_EXAMPLES = """[dim]Examples:[/dim] +[dim]-[/dim] Create a VM-only remediation: + [primary]tg beta clusters remediations create --mode VM_ONLY[/primary] + +[dim]-[/dim] Create a host-aware remediation: + [primary]tg beta clusters remediations create --mode HOST_AWARE[/primary] + +[dim]-[/dim] Create a eviction-without-replacement remediation: + [primary]tg beta clusters remediations create --mode EVICT_WITHOUT_REPLACEMENT[/primary] + +[dim]-[/dim] Create a reboot-vm remediation: + [primary]tg beta clusters remediations create --mode REBOOT_VM[/primary] +""" + ## Beta > Jig commands JIG_HELP_EXAMPLES = """[dim]Examples:[/dim] diff --git a/src/together/lib/cli/utils/_preparse_tokens.py b/src/together/lib/cli/utils/_preparse_tokens.py index cfc7ba9a..2b302303 100644 --- a/src/together/lib/cli/utils/_preparse_tokens.py +++ b/src/together/lib/cli/utils/_preparse_tokens.py @@ -15,6 +15,7 @@ "endpoints": re.compile(r"^endpoint-"), "beta clusters": _UUID_RE, "beta clusters storage": _UUID_RE, + "beta clusters remediations": _UUID_RE, "beta jig volumes": _UUID_RE, } diff --git a/tests/cli/test_beta_clusters.py b/tests/cli/test_beta_clusters.py index 5f2bf86a..1e204c49 100644 --- a/tests/cli/test_beta_clusters.py +++ b/tests/cli/test_beta_clusters.py @@ -53,6 +53,28 @@ def _cluster_body(cluster_id: str = "cluster-1", name: str = "my-cluster", **ove } +def _remediation_body(remediation_id: str = "rem-1", **overrides: Any) -> dict[str, Any]: + body: dict[str, Any] = { + "id": remediation_id, + "cluster_id": "c1", + "instance_id": "i1", + "mode": "REMEDIATION_MODE_VM_ONLY", + "state": "PENDING_APPROVAL", + "trigger": "REMEDIATION_TRIGGER_AUTOMATED", + "reason": "health check failed", + } + body.update(overrides) + return body + + +def _remediation_list_body(*remediations: dict[str, Any]) -> dict[str, Any]: + return { + "has_next": False, + "next_page_token": "", + "remediations": list(remediations), + } + + class TestBetaClustersList: @pytest.mark.respx(base_url=base_url) def test_list_table(self, respx_mock: MockRouter, cli_runner: CliRunner) -> None: @@ -238,3 +260,148 @@ def test_storage_delete_json(self, respx_mock: MockRouter, cli_runner: CliRunner result = cli_runner.invoke(["beta", "clusters", "storage", "delete", "vol-1", "--json"]) assert json.loads(result.output) == {"success": True} assert result.exit_code == 0 + + +class TestBetaClustersRemediations: + @pytest.mark.respx(base_url=base_url) + def test_remediations_create_json(self, respx_mock: MockRouter, cli_runner: CliRunner) -> None: + route = respx_mock.post("/compute/clusters/c1/instances/i1/remediations").mock( + return_value=httpx.Response(200, json=_remediation_body("rem-created", state="PENDING")) + ) + result = cli_runner.invoke( + [ + "beta", + "clusters", + "remediations", + "create", + "c1", + "i1", + "--mode", + "VM_ONLY", + "--reason", + "node unhealthy", + "--remediation-id", + "rem-created", + "--json", + ], + ) + + assert json.loads(result.output)["id"] == "rem-created" + request = cast(Call, route.calls[0]).request + assert request.url.params["remediation_id"] == "rem-created" + assert json.loads(request.content.decode()) == { + "mode": "REMEDIATION_MODE_VM_ONLY", + "reason": "node unhealthy", + } + assert result.exit_code == 0 + + @pytest.mark.respx(base_url=base_url) + def test_remediations_list_uses_wildcard_when_instance_id_omitted( + self, respx_mock: MockRouter, cli_runner: CliRunner + ) -> None: + payload = _remediation_list_body(_remediation_body()) + route = respx_mock.get("/compute/clusters/c1/instances/-/remediations").mock( + return_value=httpx.Response(200, json=payload) + ) + result = cli_runner.invoke(["beta", "clusters", "remediations", "list", "c1", "--json"]) + + assert json.loads(result.output) == payload + assert cast(Call, route.calls[0]).request.url.path == "/compute/clusters/c1/instances/-/remediations" + assert result.exit_code == 0 + + @pytest.mark.respx(base_url=base_url) + def test_remediations_list_accepts_instance_id(self, respx_mock: MockRouter, cli_runner: CliRunner) -> None: + payload = _remediation_list_body(_remediation_body()) + route = respx_mock.get("/compute/clusters/c1/instances/i1/remediations").mock( + return_value=httpx.Response(200, json=payload) + ) + result = cli_runner.invoke(["beta", "clusters", "remediations", "list", "c1", "i1", "--json"]) + + assert json.loads(result.output) == payload + assert cast(Call, route.calls[0]).request.url.path == "/compute/clusters/c1/instances/i1/remediations" + assert result.exit_code == 0 + + @pytest.mark.respx(base_url=base_url) + def test_remediations_retrieve_resolves_cluster_and_instance( + self, respx_mock: MockRouter, cli_runner: CliRunner + ) -> None: + body = _remediation_body("rem-get", state="RUNNING") + respx_mock.get("/compute/clusters").mock( + return_value=httpx.Response(200, json={"clusters": [_cluster_body("c1")]}) + ) + respx_mock.get("/compute/clusters/c1/instances/-/remediations").mock( + return_value=httpx.Response(200, json=_remediation_list_body(_remediation_body("rem-get"))) + ) + route = respx_mock.get("/compute/clusters/c1/instances/i1/remediations/rem-get").mock( + return_value=httpx.Response(200, json=body) + ) + + result = cli_runner.invoke(["beta", "clusters", "remediations", "get", "rem-get", "--json"]) + + assert json.loads(result.output) == body + assert cast(Call, route.calls[0]).request.url.path == "/compute/clusters/c1/instances/i1/remediations/rem-get" + assert result.exit_code == 0 + + @pytest.mark.respx(base_url=base_url) + def test_remediations_approve_resolves_cluster_and_instance( + self, respx_mock: MockRouter, cli_runner: CliRunner + ) -> None: + respx_mock.get("/compute/clusters").mock( + return_value=httpx.Response(200, json={"clusters": [_cluster_body("c1")]}) + ) + respx_mock.get("/compute/clusters/c1/instances/-/remediations").mock( + return_value=httpx.Response(200, json=_remediation_list_body(_remediation_body("rem-approve"))) + ) + route = respx_mock.post("/compute/clusters/c1/instances/i1/remediations/rem-approve/approve").mock( + return_value=httpx.Response(200, json=_remediation_body("rem-approve", state="PENDING")) + ) + + result = cli_runner.invoke( + ["beta", "clusters", "remediations", "approve", "rem-approve", "--comment", "go", "--json"] + ) + + assert json.loads(result.output)["state"] == "PENDING" + assert json.loads(cast(Call, route.calls[0]).request.content.decode()) == {"comment": "go"} + assert result.exit_code == 0 + + @pytest.mark.respx(base_url=base_url) + def test_remediations_cancel_resolves_cluster_and_instance( + self, respx_mock: MockRouter, cli_runner: CliRunner + ) -> None: + respx_mock.get("/compute/clusters").mock( + return_value=httpx.Response(200, json={"clusters": [_cluster_body("c1")]}) + ) + respx_mock.get("/compute/clusters/c1/instances/-/remediations").mock( + return_value=httpx.Response(200, json=_remediation_list_body(_remediation_body("rem-cancel"))) + ) + route = respx_mock.post("/compute/clusters/c1/instances/i1/remediations/rem-cancel/cancel").mock( + return_value=httpx.Response(200, json=_remediation_body("rem-cancel", state="CANCELLED")) + ) + + result = cli_runner.invoke(["beta", "clusters", "remediations", "cancel", "rem-cancel", "--json"]) + + assert json.loads(result.output)["state"] == "CANCELLED" + assert route.calls + assert result.exit_code == 0 + + @pytest.mark.respx(base_url=base_url) + def test_remediations_reject_resolves_cluster_and_instance( + self, respx_mock: MockRouter, cli_runner: CliRunner + ) -> None: + respx_mock.get("/compute/clusters").mock( + return_value=httpx.Response(200, json={"clusters": [_cluster_body("c1")]}) + ) + respx_mock.get("/compute/clusters/c1/instances/-/remediations").mock( + return_value=httpx.Response(200, json=_remediation_list_body(_remediation_body("rem-reject"))) + ) + route = respx_mock.post("/compute/clusters/c1/instances/i1/remediations/rem-reject/reject").mock( + return_value=httpx.Response(200, json=_remediation_body("rem-reject", state="CANCELLED")) + ) + + result = cli_runner.invoke( + ["beta", "clusters", "remediations", "reject", "rem-reject", "--comment", "skip", "--json"] + ) + + assert json.loads(result.output)["state"] == "CANCELLED" + assert json.loads(cast(Call, route.calls[0]).request.content.decode()) == {"comment": "skip"} + assert result.exit_code == 0 diff --git a/uv.lock b/uv.lock index a10f2d91..abe93dbc 100644 --- a/uv.lock +++ b/uv.lock @@ -1559,7 +1559,7 @@ wheels = [ [[package]] name = "together" -version = "2.12.0" +version = "2.14.0" source = { editable = "." } dependencies = [ { name = "anyio" },