fix(auto): use action parser for repair gates
This commit is contained in:
@@ -57,6 +57,8 @@ class ActionKind(StrEnum):
|
||||
READONLY = "readonly"
|
||||
ROLLOUT = "rollout"
|
||||
SCALE = "scale"
|
||||
AUTOSCALE = "autoscale"
|
||||
SET_RESOURCES = "set_resources"
|
||||
DELETE_POD = "delete_pod"
|
||||
|
||||
|
||||
@@ -81,13 +83,29 @@ def is_safe_kubectl_action(command: str) -> bool:
|
||||
return parse_kubectl_action(command).ok
|
||||
|
||||
|
||||
def kubectl_safety_reason(command: str) -> str | None:
|
||||
"""Return None for a safe kubectl command, otherwise the parser reason.
|
||||
|
||||
Non-kubectl commands are outside this parser's scope and return None so
|
||||
SSH / host-repair gates can keep their own policy.
|
||||
"""
|
||||
|
||||
command = (command or "").strip()
|
||||
if not command.lower().startswith("kubectl"):
|
||||
return None
|
||||
parsed = parse_kubectl_action(command)
|
||||
return None if parsed.ok else parsed.reason
|
||||
|
||||
|
||||
def parse_kubectl_action(command: str) -> ParsedKubectlAction:
|
||||
"""Parse and validate a kubectl command for auto-execute safety.
|
||||
|
||||
The grammar is intentionally narrow:
|
||||
- readonly: get/describe/logs/top/version with bounded, known-safe flags
|
||||
- rollout: rollout restart/undo on workload resources
|
||||
- rollout: rollout restart on workload resources
|
||||
- scale: scale deployment/statefulset to a positive replica count
|
||||
- autoscale: HPA bounds on deployment/statefulset with positive min/max
|
||||
- set resources: CPU/memory requests/limits on deployment/statefulset
|
||||
- delete: delete one pod by name only
|
||||
"""
|
||||
|
||||
@@ -124,6 +142,10 @@ def parse_kubectl_action(command: str) -> ParsedKubectlAction:
|
||||
return _parse_rollout(rest, namespace, namespace_flags)
|
||||
if verb == "scale":
|
||||
return _parse_scale(rest, namespace, namespace_flags)
|
||||
if verb == "autoscale":
|
||||
return _parse_autoscale(rest, namespace, namespace_flags)
|
||||
if verb == "set":
|
||||
return _parse_set(rest, namespace, namespace_flags)
|
||||
if verb == "delete":
|
||||
return _parse_delete(rest, namespace, namespace_flags)
|
||||
return _reject("unsupported_verb")
|
||||
@@ -241,7 +263,7 @@ def _parse_rollout(
|
||||
if len(tokens) < 2:
|
||||
return _reject("rollout_missing_args")
|
||||
subverb = tokens[0]
|
||||
if subverb not in {"restart", "undo"}:
|
||||
if subverb != "restart":
|
||||
return _reject("unsupported_rollout_subverb")
|
||||
|
||||
resource_type, resource_name, rest = _split_resource_ref(tokens[1:])
|
||||
@@ -308,6 +330,104 @@ def _parse_scale(
|
||||
)
|
||||
|
||||
|
||||
def _parse_autoscale(
|
||||
tokens: list[str],
|
||||
namespace: str | None,
|
||||
namespace_flags: list[str],
|
||||
) -> ParsedKubectlAction:
|
||||
resource_type, resource_name, rest = _split_resource_ref(tokens)
|
||||
if resource_type not in _SCALABLE_RESOURCES or not resource_name:
|
||||
return _reject("invalid_autoscale_resource")
|
||||
|
||||
min_replicas: int | None = None
|
||||
max_replicas: int | None = None
|
||||
cpu_percent: int | None = None
|
||||
remaining_flags: list[str] = []
|
||||
i = 0
|
||||
while i < len(rest):
|
||||
token = rest[i]
|
||||
flag, raw_value, consumed = _consume_required_flag_value(
|
||||
rest,
|
||||
i,
|
||||
{"--min", "--max", "--cpu-percent"},
|
||||
)
|
||||
if not flag or raw_value is None:
|
||||
return _reject("unsupported_autoscale_flag")
|
||||
value = _parse_positive_int(raw_value)
|
||||
if value < 1:
|
||||
return _reject("autoscale_value_must_be_positive")
|
||||
if flag == "--min":
|
||||
min_replicas = value
|
||||
elif flag == "--max":
|
||||
max_replicas = value
|
||||
elif flag == "--cpu-percent":
|
||||
cpu_percent = value
|
||||
remaining_flags.extend(rest[i:i + consumed])
|
||||
i += consumed
|
||||
|
||||
if min_replicas is None or max_replicas is None:
|
||||
return _reject("autoscale_min_max_required")
|
||||
if max_replicas < min_replicas:
|
||||
return _reject("autoscale_max_below_min")
|
||||
if cpu_percent is not None and cpu_percent > 100:
|
||||
return _reject("autoscale_cpu_percent_out_of_range")
|
||||
|
||||
return ParsedKubectlAction(
|
||||
ok=True,
|
||||
reason="ok",
|
||||
kind=ActionKind.AUTOSCALE,
|
||||
verb="autoscale",
|
||||
resource_type=resource_type,
|
||||
resource_name=resource_name,
|
||||
namespace=namespace,
|
||||
flags=tuple(namespace_flags + remaining_flags),
|
||||
)
|
||||
|
||||
|
||||
def _parse_set(
|
||||
tokens: list[str],
|
||||
namespace: str | None,
|
||||
namespace_flags: list[str],
|
||||
) -> ParsedKubectlAction:
|
||||
if not tokens or tokens[0] != "resources":
|
||||
return _reject("unsupported_set_subverb")
|
||||
resource_type, resource_name, rest = _split_resource_ref(tokens[1:])
|
||||
if resource_type not in _SCALABLE_RESOURCES or not resource_name:
|
||||
return _reject("invalid_set_resources_target")
|
||||
|
||||
saw_resource_flag = False
|
||||
remaining_flags: list[str] = []
|
||||
i = 0
|
||||
while i < len(rest):
|
||||
flag, raw_value, consumed = _consume_required_flag_value(
|
||||
rest,
|
||||
i,
|
||||
{"--limits", "--requests"},
|
||||
)
|
||||
if not flag or raw_value is None:
|
||||
return _reject("unsupported_set_resources_flag")
|
||||
if not _resource_quantity_assignments_safe(raw_value):
|
||||
return _reject("invalid_resource_quantity")
|
||||
saw_resource_flag = True
|
||||
remaining_flags.extend(rest[i:i + consumed])
|
||||
i += consumed
|
||||
|
||||
if not saw_resource_flag:
|
||||
return _reject("set_resources_requires_limits_or_requests")
|
||||
|
||||
return ParsedKubectlAction(
|
||||
ok=True,
|
||||
reason="ok",
|
||||
kind=ActionKind.SET_RESOURCES,
|
||||
verb="set",
|
||||
subverb="resources",
|
||||
resource_type=resource_type,
|
||||
resource_name=resource_name,
|
||||
namespace=namespace,
|
||||
flags=tuple(namespace_flags + remaining_flags),
|
||||
)
|
||||
|
||||
|
||||
def _parse_delete(
|
||||
tokens: list[str],
|
||||
namespace: str | None,
|
||||
@@ -339,6 +459,43 @@ def _parse_positive_int(value: str) -> int:
|
||||
return int(value)
|
||||
|
||||
|
||||
def _consume_required_flag_value(
|
||||
tokens: list[str],
|
||||
index: int,
|
||||
allowed_flags: set[str],
|
||||
) -> tuple[str | None, str | None, int]:
|
||||
token = tokens[index]
|
||||
if "=" in token:
|
||||
flag, value = token.split("=", 1)
|
||||
if flag not in allowed_flags or not value:
|
||||
return None, None, 1
|
||||
return flag, value, 1
|
||||
|
||||
if token not in allowed_flags or index + 1 >= len(tokens):
|
||||
return None, None, 1
|
||||
value = tokens[index + 1]
|
||||
if not value or value.startswith("-"):
|
||||
return None, None, 1
|
||||
return token, value, 2
|
||||
|
||||
|
||||
def _resource_quantity_assignments_safe(value: str) -> bool:
|
||||
parts = value.split(",")
|
||||
if not parts:
|
||||
return False
|
||||
for part in parts:
|
||||
key, separator, quantity = part.partition("=")
|
||||
if separator != "=":
|
||||
return False
|
||||
if key not in {"cpu", "memory"}:
|
||||
return False
|
||||
if not quantity or not set(quantity) <= _SAFE_TOKEN_CHARS:
|
||||
return False
|
||||
if quantity in {"0", "0m", "0Mi", "0Gi"}:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _flags_allowed(tokens: list[str], allowed_flags: set[str]) -> bool:
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
|
||||
Reference in New Issue
Block a user