Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/spero/remediations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,6 +31,7 @@
RolloutRestart,
ScaleDeployment,
DeletePod,
PatchRequests,
UnpauseScaledObject,
)
}
Expand All @@ -49,6 +55,7 @@ def build_remediation(spec: RemediationSpec) -> Remediation:
"REMEDIATIONS",
"DeletePod",
"KillProcess",
"PatchRequests",
"Remediation",
"RemediationResult",
"RespawnProcess",
Expand Down
40 changes: 40 additions & 0 deletions src/spero/remediations/kubernetes.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,46 @@ async def apply(self, provider: Provider) -> RemediationResult:
)


class PatchRequests(Remediation):
"""`kubectl set resources deployment/<name> --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 <selector>` -- forceful; let the controller recreate."""

Expand Down
77 changes: 77 additions & 0 deletions tests/test_rightsizing.py
Original file line number Diff line number Diff line change
@@ -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)