-
-
Notifications
You must be signed in to change notification settings - Fork 0
feat(operator): pod handoff #362
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: bmcquilkin/operator/membership
Are you sure you want to change the base?
Changes from all commits
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,94 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import logging | ||
| import urllib.error | ||
| import urllib.request | ||
| from enum import StrEnum | ||
| from typing import Any, cast | ||
|
|
||
| from sentry_streams_k8s.operator.constants import CONTROL_REQUEST_TIMEOUT_SECONDS | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class RuntimeState(StrEnum): | ||
| # Mirrors sentry_streams.adapters.stream_adapter.RuntimeState. | ||
| # TODO: Remove once sentry_streams is updated to expose RuntimeState. | ||
|
|
||
| IDLE = "idle" | ||
| STARTING = "starting" | ||
| CONSUMING = "consuming" | ||
| STOPPING = "stopping" | ||
| STOPPED = "stopped" | ||
| ERRORED = "errored" | ||
|
|
||
| @property | ||
| def is_terminal(self) -> bool: | ||
| return self in (RuntimeState.STOPPED, RuntimeState.ERRORED) | ||
|
|
||
|
|
||
| class ControlError(Exception): | ||
| """Raised when a control request could not be completed.""" | ||
|
|
||
|
|
||
| class ControlClient: | ||
| def __init__( | ||
| self, | ||
| port: int, | ||
| timeout: float = CONTROL_REQUEST_TIMEOUT_SECONDS, | ||
| ) -> None: | ||
| self._port = port | ||
| self._timeout = timeout | ||
|
|
||
| def _request(self, ip: str, path: str, method: str, body: dict[str, Any] | None = None) -> Any: | ||
| data = json.dumps(body).encode() if body is not None else b"" | ||
| request = urllib.request.Request( | ||
| f"http://{ip}:{self._port}{path}", | ||
| method=method, | ||
| data=data, | ||
| headers={"Content-Type": "application/json"} if body is not None else {}, | ||
| ) | ||
| with urllib.request.urlopen(request, timeout=self._timeout) as response: | ||
| raw = response.read() | ||
| return json.loads(raw) if raw else {} | ||
|
|
||
| def status(self, ip: str) -> RuntimeState | None: | ||
| try: | ||
| payload = self._request(ip, "/status", "GET") | ||
| except (urllib.error.URLError, OSError, ValueError) as error: | ||
| logger.debug("control server at %s is unreachable: %s", ip, error) | ||
| return None | ||
|
|
||
| state = cast(dict[str, Any], payload).get("state") | ||
| try: | ||
| return RuntimeState(state) if isinstance(state, str) else None | ||
| except ValueError: | ||
| logger.warning("control server at %s reported unknown state %r", ip, state) | ||
| return None | ||
|
|
||
| def readyz(self, ip: str) -> bool: | ||
| try: | ||
| self._request(ip, "/readyz", "GET") | ||
| return True | ||
| except (urllib.error.URLError, OSError, ValueError): | ||
| return False | ||
|
|
||
| def start(self, ip: str, group_instance_id: str) -> None: | ||
| try: | ||
| self._request(ip, "/start", "POST", {"group_instance_id": group_instance_id}) | ||
| except urllib.error.HTTPError as error: | ||
| if error.code == 409: | ||
| logger.info("consumer at %s was not idle when started: %s", ip, error) | ||
| return | ||
| raise ControlError(f"failed to start consumer at {ip}: {error}") from error | ||
| except (urllib.error.URLError, OSError, ValueError) as error: | ||
| raise ControlError(f"failed to start consumer at {ip}: {error}") from error | ||
|
|
||
| def stop(self, ip: str) -> bool: | ||
| try: | ||
| self._request(ip, "/stop", "POST") | ||
| return True | ||
| except (urllib.error.HTTPError, urllib.error.URLError, OSError, ValueError) as error: | ||
| logger.info("could not stop consumer at %s, treating it as stopped: %s", ip, error) | ||
| return False |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,8 @@ | |
|
|
||
| from sentry_streams_k8s.k8s_types import V1ConditionDict | ||
| from sentry_streams_k8s.operator.constants import ( | ||
| CONTROL_HOST_ENV, | ||
| CONTROL_PORT_ENV, | ||
| FIELD_MANAGER, | ||
| GROUP, | ||
| HEALTH_SCAN_INTERVAL_SECONDS, | ||
|
|
@@ -95,6 +97,23 @@ def _workload_namespace() -> str: | |
| return namespace | ||
|
|
||
|
|
||
| def _control_host() -> str: | ||
| host = os.environ.get(CONTROL_HOST_ENV, "").strip() | ||
| if not host: | ||
| raise RuntimeError(f"{CONTROL_HOST_ENV} must be set.") | ||
| return host | ||
|
|
||
|
|
||
| def _control_port() -> int: | ||
| port = os.environ.get(CONTROL_PORT_ENV, "").strip() | ||
| if not port: | ||
| raise RuntimeError(f"{CONTROL_PORT_ENV} must be set.") | ||
| try: | ||
| return int(port) | ||
| except ValueError: | ||
| raise RuntimeError(f"{CONTROL_PORT_ENV} must be an integer, got {port!r}.") | ||
|
|
||
|
|
||
| def _published_conditions(status: Mapping[str, object]) -> list[V1ConditionDict] | None: | ||
| conditions = status.get("conditions") | ||
| if not isinstance(conditions, list): | ||
|
|
@@ -196,6 +215,8 @@ async def _reconcile_once( | |
| namespace=namespace, | ||
| uid=uid, | ||
| workload_namespace=_workload_namespace(), | ||
| control_host=_control_host(), | ||
| control_port=_control_port(), | ||
| logger=logger, | ||
| status=status_patch, | ||
| previous_conditions=_published_conditions(published), | ||
|
|
@@ -270,7 +291,7 @@ async def handle_pipeline_pod_event( | |
|
|
||
| if type == "MODIFIED" and meta.deletion_timestamp is None: | ||
| health = pod_health(_deserialize_pod(body), datetime.now(timezone.utc)) | ||
| if not health.delete: | ||
| if not (health.delete or health.ready): | ||
|
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. Missing control env startup checkMedium Severity
Additional Locations (1)Reviewed by Cursor Bugbot for commit f5bd207. Configure here. |
||
| return | ||
|
|
||
| owner_uid = labels.get(OWNER_UID_LABEL) | ||
|
|
||


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.
Unmanaged readiness probe merge risk
Low Severity
When
control_portis set,build_containeralways injects a managedreadinessProbe, butvalidateonly rejects a conflicting templatelivenessProbe. A templatereadinessProbeis deep-merged with the managed probe and can produce an invalid dual-handler probe, so Pods may never become Ready and handoff stalls.Additional Locations (1)
sentry_streams_k8s/sentry_streams_k8s/consumer_builder.py#L367-L375Reviewed by Cursor Bugbot for commit f5bd207. Configure here.