-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add deterministic verifiers (resource_exists, resource_field) to replace LLM judge for fix-config and deploy-config #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3322663
525822f
349b6a3
0f2bf3f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| import subprocess | ||
| import time | ||
| from typing import Literal, Optional | ||
| from pkg.agents.verifier.base import BaseVerifier, VerificationResult | ||
|
|
||
|
|
||
| class ResourceExistsVerifier(BaseVerifier): | ||
| """Verifies that a named Kubernetes resource exists in the cluster. | ||
|
|
||
| Generic primitive over `kubectl get <kind> <name>`. Because it reads live | ||
| cluster state, a successful check also proves the resource was actually | ||
| applied (not merely printed by the agent). | ||
| """ | ||
|
|
||
| type: Literal["resource_exists"] = "resource_exists" | ||
| kind: str | ||
| name: str | ||
| namespace: Optional[str] = None | ||
|
|
||
| def verify(self, timeout_sec: int) -> VerificationResult: | ||
| start_time = time.time() | ||
| delay = 1 | ||
| max_delay = 10 | ||
|
|
||
| while True: | ||
| success, details = self._check_exists() | ||
| if success: | ||
| return VerificationResult( | ||
| success=True, | ||
| elapsed_time=time.time() - start_time, | ||
| reason=details.get("reason"), | ||
| details=details, | ||
| ) | ||
| if time.time() - start_time >= timeout_sec: | ||
| return VerificationResult( | ||
| success=False, | ||
| elapsed_time=time.time() - start_time, | ||
| reason=details.get("reason"), | ||
| details=details, | ||
| ) | ||
| time.sleep(delay) | ||
| delay = min(delay * 2, max_delay) | ||
|
|
||
| def _check_exists(self) -> tuple[bool, dict]: | ||
| cmd = ["kubectl", "get", self.kind, self.name] | ||
| if self.namespace: | ||
| cmd.extend(["-n", self.namespace]) | ||
| try: | ||
| result = subprocess.run(cmd, capture_output=True, text=True, check=True) | ||
| return True, { | ||
| "reason": f"{self.kind}/{self.name} exists", | ||
| "output": result.stdout.strip(), | ||
| } | ||
| except subprocess.CalledProcessError as e: | ||
| return False, { | ||
| "reason": f"{self.kind}/{self.name} not found: {e.stderr.strip()}" | ||
| } | ||
|
jegaths marked this conversation as resolved.
|
||
| except FileNotFoundError: | ||
| return False, { | ||
| "reason": "kubectl command not found in PATH. Please ensure kubectl is installed." | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,86 @@ | ||||||
| import subprocess | ||||||
| import time | ||||||
| from typing import Literal, Optional | ||||||
| from pkg.agents.verifier.base import BaseVerifier, VerificationResult | ||||||
|
|
||||||
|
|
||||||
| class ResourceFieldVerifier(BaseVerifier): | ||||||
| """Verifies that a specific field on a Kubernetes resource equals an | ||||||
| expected value. | ||||||
|
|
||||||
| Generic primitive over `kubectl get <kind> <name> -o jsonpath=<json_path>`. | ||||||
| kubectl performs the extraction, so no extra Python dependency is required. | ||||||
| Reading the live field value also proves the resource was actually applied | ||||||
| with the correct configuration. | ||||||
|
|
||||||
| Example (container env var): | ||||||
| json_path = "{.spec.template.spec.containers[?(@.name=='frontend')]" | ||||||
| ".env[?(@.name=='USE_GEMINI_API')].value}" | ||||||
| expected = "false" | ||||||
| """ | ||||||
|
|
||||||
| type: Literal["resource_field"] = "resource_field" | ||||||
| kind: str | ||||||
| name: str | ||||||
| json_path: str | ||||||
| expected: str | ||||||
| namespace: Optional[str] = None | ||||||
|
|
||||||
| def verify(self, timeout_sec: int) -> VerificationResult: | ||||||
| start_time = time.time() | ||||||
| delay = 1 | ||||||
| max_delay = 10 | ||||||
|
|
||||||
| while True: | ||||||
| success, details = self._check_field() | ||||||
| if success: | ||||||
| return VerificationResult( | ||||||
| success=True, | ||||||
| elapsed_time=time.time() - start_time, | ||||||
| reason=details.get("reason"), | ||||||
| details=details, | ||||||
| ) | ||||||
| if time.time() - start_time >= timeout_sec: | ||||||
| return VerificationResult( | ||||||
| success=False, | ||||||
| elapsed_time=time.time() - start_time, | ||||||
| reason=details.get("reason"), | ||||||
| details=details, | ||||||
| ) | ||||||
| time.sleep(delay) | ||||||
| delay = min(delay * 2, max_delay) | ||||||
|
Comment on lines
+30
to
+51
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using Using start_time = time.monotonic()
delay = 1
max_delay = 10
while True:
success, details = self._check_field()
if success:
return VerificationResult(
success=True,
elapsed_time=time.monotonic() - start_time,
reason=details.get("reason"),
details=details,
)
if time.monotonic() - start_time >= timeout_sec:
return VerificationResult(
success=False,
elapsed_time=time.monotonic() - start_time,
reason=details.get("reason"),
details=details,
)
time.sleep(delay)
delay = min(delay * 2, max_delay) |
||||||
|
|
||||||
| def _check_field(self) -> (bool, dict): | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The type hint syntax Use
Suggested change
|
||||||
| cmd = [ | ||||||
| "kubectl", | ||||||
| "get", | ||||||
| self.kind, | ||||||
| self.name, | ||||||
| "-o", | ||||||
| f"jsonpath={self.json_path}", | ||||||
| ] | ||||||
| if self.namespace: | ||||||
| cmd.extend(["-n", self.namespace]) | ||||||
| try: | ||||||
| result = subprocess.run(cmd, capture_output=True, text=True, check=True) | ||||||
| except subprocess.CalledProcessError as e: | ||||||
| return False, { | ||||||
| "reason": f"Failed to read {self.kind}/{self.name}: {e.stderr.strip()}" | ||||||
| } | ||||||
|
Comment on lines
+64
to
+69
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If Catching try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
except subprocess.CalledProcessError as e:
return False, {
"reason": f"Failed to read {self.kind}/{self.name}: {e.stderr.strip()}"
}
except FileNotFoundError:
return False, {
"reason": "kubectl command not found in PATH. Please ensure kubectl is installed."
} |
||||||
|
|
||||||
| actual = result.stdout.strip() | ||||||
| success = actual == self.expected | ||||||
| reason = ( | ||||||
| f"{self.kind}/{self.name} field matched expected value '{self.expected}'" | ||||||
| if success | ||||||
| else ( | ||||||
| f"{self.kind}/{self.name} field mismatch: expected " | ||||||
| f"'{self.expected}', got '{actual}'" | ||||||
| ) | ||||||
| ) | ||||||
| return success, { | ||||||
| "reason": reason, | ||||||
| "json_path": self.json_path, | ||||||
| "expected": self.expected, | ||||||
| "actual": actual, | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using
time.time()for measuring elapsed time or timeouts is susceptible to system clock adjustments (e.g., NTP synchronization or manual clock changes). This can lead to unexpected behavior or infinite loops in the timeout logic.Using
time.monotonic()is the standard and robust way to measure elapsed time in Python.