diff --git a/src/spero/remediations/__init__.py b/src/spero/remediations/__init__.py index ec47f8f..0ba1706 100644 --- a/src/spero/remediations/__init__.py +++ b/src/spero/remediations/__init__.py @@ -14,7 +14,12 @@ from spero.remediations.base import Remediation, RemediationResult from spero.remediations.host import KillProcess, RespawnProcess, RestartService, RotateLogs from spero.remediations.keda import UnpauseScaledObject # EXPERIMENTAL: elpio serverless seam spike -from spero.remediations.kubernetes import DeletePod, RolloutRestart, ScaleDeployment +from spero.remediations.kubernetes import ( + DeletePod, + PatchRequests, + RolloutRestart, + ScaleDeployment, +) REMEDIATIONS: dict[str, type[Remediation]] = { cls.type: cls @@ -26,6 +31,7 @@ RolloutRestart, ScaleDeployment, DeletePod, + PatchRequests, UnpauseScaledObject, ) } @@ -49,6 +55,7 @@ def build_remediation(spec: RemediationSpec) -> Remediation: "REMEDIATIONS", "DeletePod", "KillProcess", + "PatchRequests", "Remediation", "RemediationResult", "RespawnProcess", diff --git a/src/spero/remediations/kubernetes.py b/src/spero/remediations/kubernetes.py index 062db0b..81f4e12 100644 --- a/src/spero/remediations/kubernetes.py +++ b/src/spero/remediations/kubernetes.py @@ -51,6 +51,46 @@ async def apply(self, provider: Provider) -> RemediationResult: ) +class PatchRequests(Remediation): + """`kubectl set resources deployment/ --requests=...` -- rightsizing. + + Closes the loop the ``resource-usage`` probe opens: when a pod runs hot against + its declared requests, raise (or lower) them. Marked destructive because it + mutates the workload spec and triggers a rolling restart, so it may never run + unattended -- autonomy ``auto`` is rejected at policy load, and a human or the + AI approver always gates it. At least one of ``cpu``/``memory`` is required. + """ + + type: ClassVar[str] = "patch-requests" + destructive: ClassVar[bool] = True + + def __init__( + self, + deployment: str, + cpu: str | None = None, + memory: str | None = None, + container: str | None = None, + ) -> None: + if not cpu and not memory: + raise ValueError("patch-requests needs at least one of cpu/memory") + self.deployment = deployment + self.cpu = str(cpu) if cpu else None + self.memory = str(memory) if memory else None + self.container = container + + async def apply(self, provider: Provider) -> RemediationResult: + requests = ",".join( + f"{k}={v}" for k, v in (("cpu", self.cpu), ("memory", self.memory)) if v + ) + cmd = ["set", "resources", f"deployment/{self.deployment}", f"--requests={requests}"] + if self.container: + cmd += ["-c", self.container] + r = await provider.run(cmd, timeout=60) + return RemediationResult( + r.ok, r.stderr.strip() or f"set requests {requests} on {self.deployment}" + ) + + class DeletePod(Remediation): """`kubectl delete pod -l ` -- forceful; let the controller recreate.""" diff --git a/tests/test_rightsizing.py b/tests/test_rightsizing.py new file mode 100644 index 0000000..9c951e7 --- /dev/null +++ b/tests/test_rightsizing.py @@ -0,0 +1,77 @@ +# -#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-# +# __creation__ = 2026-06-07 +# __author__ = "jndjama (Joy Ndjama)" +# __copyright__ = "Copyright 2026 ALTIKVA." +# __licence__ = "MIT & CC BY-NC-SA (https://www.altikva.com/licenses/LICENSE-1.0)" +# -#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-# +# Description: Tests for the patch-requests rightsizing remediation. + +"""Tests for the `patch-requests` remediation (rightsizing) and its gating.""" + +from __future__ import annotations + +import pytest + +from spero.core.models import Autonomy, ProbeSpec, RemediationSpec, TargetPolicy +from spero.providers.command import CommandResult +from spero.remediations import build_remediation +from spero.remediations.kubernetes import PatchRequests + + +class _RecordingProvider: + def __init__(self) -> None: + self.commands: list[list[str]] = [] + + async def run(self, command: list[str], *, timeout: float | None = None) -> CommandResult: + self.commands.append(command) + return CommandResult(0, "deployment.apps/web resource requirements updated", "") + + +async def test_patch_requests_sets_both_resources() -> None: + provider = _RecordingProvider() + rem = PatchRequests(deployment="web", cpu="200m", memory="256Mi", container="app") + result = await rem.apply(provider) # type: ignore[arg-type] + assert result.success + assert provider.commands == [ + ["set", "resources", "deployment/web", "--requests=cpu=200m,memory=256Mi", "-c", "app"] + ] + + +async def test_patch_requests_cpu_only() -> None: + provider = _RecordingProvider() + await PatchRequests(deployment="web", cpu="500m").apply(provider) # type: ignore[arg-type] + assert provider.commands == [["set", "resources", "deployment/web", "--requests=cpu=500m"]] + + +def test_patch_requests_requires_a_resource() -> None: + with pytest.raises(ValueError, match="at least one of cpu/memory"): + PatchRequests(deployment="web") + + +def test_patch_requests_is_destructive_so_auto_is_rejected() -> None: + assert PatchRequests.destructive is True + # _validate_buildable runs at construction and rejects destructive + auto. + with pytest.raises(ValueError, match="destructive"): + TargetPolicy( + name="web", + provider="k8s", + probe=ProbeSpec(type="resource-usage", params={"selector": "app=web"}), + remediations=[ + RemediationSpec( + type="patch-requests", + params={"deployment": "web", "cpu": "200m"}, + autonomy=Autonomy.auto, + ) + ], + ) + + +def test_patch_requests_builds_from_spec_when_gated() -> None: + rem = build_remediation( + RemediationSpec( + type="patch-requests", + params={"deployment": "web", "memory": "512Mi"}, + autonomy=Autonomy.gated, + ) + ) + assert isinstance(rem, PatchRequests)