Commit 2f52f368 authored by Konstantinos Togias's avatar Konstantinos Togias
Browse files

Report container endpoints after kubernetes activate

SRM resolves the traffic influence application server from the endpoints a
workload reports at deploy, but the adapter returned none. After helm
install, a container with external ports now reports one endpoint per
port: the node port Kubernetes assigned, read with kubectl, and the host
configured as endpoint_host in the control path.

Without endpoint_host, or when kubectl is missing or fails, the result has
no endpoints as before and the deploy still succeeds.
parent 248436f6
Loading
Loading
Loading
Loading
+35 −0
Changes for src/openop_tfsdk/contract/adapters/kubernetes.py: 35 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -28,6 +28,7 @@ from openop_tfsdk.contract.execution_models import (
    CapabilityRuntimeStateRequest,
    LifecycleState,
    RuntimeState,
    ServiceEndpoint,
)
from openop_tfsdk.contract.models import SupportedAction

@@ -149,8 +150,42 @@ class KubernetesCapabilityAdapter:
            capability_kind=request.capability_kind,
            state=LifecycleState.ACTIVE,
            external_id=release,
            endpoints=self._endpoints(unit, release, namespace),
        )

    def _endpoints(self, unit, release: str, namespace: str) -> Optional[list]:
        # Kubernetes picks the node ports at install; the host reachable from outside is configured.
        host = self._infrastructure.get("endpoint_host")
        if not host or unit.runtime_type != "container" or not unit.resource_requirements:
            return None
        interfaces = unit.resource_requirements.interfaces
        external = [interface for interface in interfaces if interface.visibility == "external"]
        if not external:
            return None
        try:
            result = subprocess.run(
                ["kubectl", "get", "service", release, "--namespace", namespace, "-o", "json"],
                capture_output=True,
                text=True,
            )
        except FileNotFoundError:
            log.warning("endpoints_unavailable", reason="kubectl_not_installed")
            return None
        if result.returncode != 0:
            log.warning("endpoints_unavailable", reason="kubectl_failed")
            return None
        node_ports = {
            port["name"]: port["nodePort"] for port in json.loads(result.stdout)["spec"]["ports"]
        }
        return [
            ServiceEndpoint(
                interface_id=interface.interface_id,
                fqdn=host,
                port=node_ports[f"p{interface.port}-{interface.protocol.lower()}"],
            )
            for interface in external
        ]

    def read_runtime_state(self, request: CapabilityRuntimeStateRequest) -> CapabilityRuntimeState:
        if not request.external_id:
            raise ValidationError("read_runtime_state needs the external_id returned by activate")
+62 −1
Changes for tests/contract/test_kubernetes_container.py: 62 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -11,10 +11,13 @@ from openop_tfsdk.contract.adapters.kubernetes import (
    KubernetesCapabilityAdapter,
)
from openop_tfsdk.contract.errors import ValidationError
from openop_tfsdk.contract.execution_models import LifecycleState, ServiceEndpoint

HOST = {"endpoint_host": "192.0.2.10"}


def _interface(port, protocol="tcp", visibility="internal"):
    return SimpleNamespace(port=port, protocol=protocol, visibility=visibility)
    return SimpleNamespace(interface_id="api", port=port, protocol=protocol, visibility=visibility)


def _request(runtime_type, artifact_ref="nginx:1.25", interfaces=None):
@@ -109,3 +112,61 @@ def test_mixed_visibility_is_rejected_before_exposing_internal_ports(helm_calls,
    with pytest.raises(ValidationError, match="mixed internal and external"):
        getattr(KubernetesCapabilityAdapter(), action)(_request("container", interfaces=interfaces))
    assert helm_calls == []


def _cluster(monkeypatch, kubectl):
    calls = []

    def run(args, **kwargs):
        calls.append(args)
        if args[0] == "helm":
            return SimpleNamespace(returncode=0, stdout="", stderr="")
        return kubectl()

    monkeypatch.setattr("openop_tfsdk.contract.adapters.kubernetes.subprocess.run", run)
    return calls


def _service():
    ports = [{"name": "p80-tcp", "port": 80, "nodePort": 31234}]
    return SimpleNamespace(returncode=0, stdout=json.dumps({"spec": {"ports": ports}}), stderr="")


def test_container_reports_its_node_ports_as_endpoints(monkeypatch):
    calls = _cluster(monkeypatch, _service)
    request = _request("container", interfaces=[_interface(80, visibility="external")])
    result = KubernetesCapabilityAdapter(HOST).activate(request)
    assert calls[1] == [
        "kubectl",
        "get",
        "service",
        "video-app",
        "--namespace",
        "default",
        "-o",
        "json",
    ]
    assert result.endpoints == [ServiceEndpoint(interface_id="api", fqdn="192.0.2.10", port=31234)]


def test_no_endpoints_without_a_configured_host(monkeypatch):
    calls = _cluster(monkeypatch, _service)
    request = _request("container", interfaces=[_interface(80, visibility="external")])
    assert KubernetesCapabilityAdapter().activate(request).endpoints is None
    assert len(calls) == 1


def _missing_kubectl():
    raise FileNotFoundError


@pytest.mark.parametrize(
    "kubectl",
    [lambda: SimpleNamespace(returncode=1, stdout="", stderr="forbidden"), _missing_kubectl],
)
def test_unreadable_service_still_activates(monkeypatch, kubectl):
    _cluster(monkeypatch, kubectl)
    request = _request("container", interfaces=[_interface(80, visibility="external")])
    result = KubernetesCapabilityAdapter(HOST).activate(request)
    assert result.state == LifecycleState.ACTIVE
    assert result.endpoints is None