diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44656f9c..22807e2b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,6 +70,23 @@ jobs: enable-cache: true cache-dependency-glob: uv.lock + - name: Provision pinned Helm for deployment render tests + env: + HELM_VERSION: v4.3.0 + HELM_SHA256: 86584a54def73570558f66f5111cc53dfed56689637ae32c1201205d494f54fb + run: | + set -euo pipefail + tool_dir="$(mktemp -d "$RUNNER_TEMP/helm.XXXXXX")" + archive="$tool_dir/helm.tar.gz" + curl --fail --silent --show-error --location --retry 3 \ + --proto '=https' --tlsv1.2 \ + "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" \ + --output "$archive" + printf '%s %s\n' "$HELM_SHA256" "$archive" | sha256sum --check --strict + tar -xzf "$archive" -C "$tool_dir" linux-amd64/helm + test "$("$tool_dir/linux-amd64/helm" version --template '{{.Version}}')" = "$HELM_VERSION" + printf '%s\n' "$tool_dir/linux-amd64" >> "$GITHUB_PATH" + - name: Checkout pinned agent-utilities source uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: diff --git a/deploy/helm/graph-os/templates/extensions.yaml b/deploy/helm/graph-os/templates/extensions.yaml index 1c854739..0a28e1da 100644 --- a/deploy/helm/graph-os/templates/extensions.yaml +++ b/deploy/helm/graph-os/templates/extensions.yaml @@ -28,6 +28,10 @@ spec: enableServiceLinks: {{ $root.Values.enableServiceLinks }} securityContext: {{- toYaml $root.Values.podSecurityContext | nindent 8 }} + {{- with $root.Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} containers: - name: {{ $item.name }} image: {{ include "graph-os.image" $item.image }} diff --git a/deploy/helm/graph-os/templates/policy-and-scaling.yaml b/deploy/helm/graph-os/templates/policy-and-scaling.yaml index 6e35a674..3cb9b9a0 100644 --- a/deploy/helm/graph-os/templates/policy-and-scaling.yaml +++ b/deploy/helm/graph-os/templates/policy-and-scaling.yaml @@ -118,6 +118,23 @@ spec: {{- if include "graph-os.engineTcp" . }} - {protocol: TCP, port: {{ .Values.engine.port }}} {{- end }} + {{- $extensions := concat .Values.connectors .Values.components }} + {{- if $extensions }} + # Extension ports are reachable only from this namespace; ingressFrom + # continues to grant access only to the core service ports above. + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Release.Namespace }} + ports: + {{- $ports := list }} + {{- range $extension := $extensions }} + {{- $ports = append $ports $extension.port }} + {{- end }} + {{- range $port := $ports | uniq }} + - {protocol: TCP, port: {{ $port }}} + {{- end }} + {{- end }} egress: - to: - namespaceSelector: diff --git a/tests/deployment/test_helm_extensions.py b/tests/deployment/test_helm_extensions.py new file mode 100644 index 00000000..0f7ed82a --- /dev/null +++ b/tests/deployment/test_helm_extensions.py @@ -0,0 +1,175 @@ +"""Render extension workloads and their namespace-scoped network contract.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path +from typing import Any + +import pytest +import yaml + +CHART = Path(__file__).resolve().parents[2] / "deploy" / "helm" / "graph-os" +NAMESPACE = "extension-fixture" +LOCAL_PEER = { + "namespaceSelector": {"matchLabels": {"kubernetes.io/metadata.name": NAMESPACE}} +} +INGRESS_PEER = { + "namespaceSelector": { + "matchLabels": {"kubernetes.io/metadata.name": "ingress-fixture"} + } +} + + +def _extension(name: str, port: int) -> dict[str, Any]: + return { + "name": name, + "port": port, + "image": { + "repository": "registry.invalid/fixture/connector", + "digest": "sha256:" + "a" * 64, + }, + } + + +def _render(tmp_path: Path, values: dict[str, Any]) -> list[dict[str, Any]]: + helm = shutil.which("helm") + assert helm is not None, "Helm must be provisioned to run chart rendering tests" + value_file = tmp_path / "values.yaml" + value_file.write_text(yaml.safe_dump(values), encoding="utf-8") + env = dict(os.environ, KUBECONFIG=os.devnull) + for name in ( + "HELM_CACHE_HOME", + "HELM_CONFIG_HOME", + "HELM_DATA_HOME", + "HELM_PLUGINS", + ): + directory = tmp_path / name.lower() + directory.mkdir() + env[name] = str(directory) + flags = ["--kube-version", "1.31.0", "--values", str(value_file)] + commands = [ + [helm, "lint", str(CHART), "--strict", *flags], + [helm, "template", "fixture", str(CHART), "--namespace", NAMESPACE, *flags], + ] + outputs = [] + for command in commands: + result = subprocess.run( + command, capture_output=True, text=True, timeout=30, check=False, env=env + ) + assert result.returncode == 0, result.stdout + result.stderr + outputs.append(result.stdout) + return [document for document in yaml.safe_load_all(outputs[-1]) if document] + + +def _ports_for_peer(policy: dict[str, Any], peer: dict[str, Any]) -> set[int]: + ports: set[int] = set() + for rule in policy["spec"]["ingress"]: + if peer in rule["from"]: + assert rule["ports"], "An ingress rule must not allow every port" + assert all(port["protocol"] == "TCP" for port in rule["ports"]) + ports.update(port["port"] for port in rule["ports"]) + return ports + + +def _values( + mode: str, pull_secrets: list[dict[str, str]], extensions: str +) -> dict[str, Any]: + values: dict[str, Any] = { + "imagePullSecrets": pull_secrets, + "graphos": {"image": {"digest": "sha256:" + "b" * 64}}, + "engine": {"placement": "child" if mode == "child" else "sidecar"}, + "networkPolicy": {"ingressFrom": [INGRESS_PEER]}, + "connectors": [], + "components": [], + } + if mode == "shared": + values["topology"] = "out-of-process-shared" + values["engine"]["tls"] = {"secretName": "fixture-engine-tls"} + if extensions in {"connector", "both", "shared-port"}: + values["connectors"] = [_extension("sample-mcp", 9123)] + if extensions in {"component", "both", "shared-port"}: + port = 9123 if extensions == "shared-port" else 9234 + values["components"] = [_extension("sample-component", port)] + return values + + +def _assert_extension_pods( + documents: list[dict[str, Any]], + expected: list[dict[str, Any]], + pull_secrets: list[dict[str, str]], +) -> None: + deployments = { + document["metadata"]["labels"]["graph-os.extension/name"]: document + for document in documents + if document["kind"] == "Deployment" + and "graph-os.extension/name" in document["metadata"]["labels"] + } + assert set(deployments) == {extension["name"] for extension in expected} + for extension in expected: + pod = deployments[extension["name"]]["spec"]["template"]["spec"] + assert pod.get("imagePullSecrets", []) == pull_secrets + assert pod["automountServiceAccountToken"] is False + assert pod["containers"][0]["ports"][0]["containerPort"] == extension["port"] + + +def _assert_network_policy( + documents: list[dict[str, Any]], expected: list[dict[str, Any]], mode: str +) -> None: + policy = next( + document for document in documents if document["kind"] == "NetworkPolicy" + ) + core_ports = {8000, 8080} | ({9100} if mode == "shared" else set()) + extension_ports = {extension["port"] for extension in expected} + assert _ports_for_peer(policy, LOCAL_PEER) == core_ports | extension_ports + assert _ports_for_peer(policy, INGRESS_PEER) == core_ports + rules = policy["spec"]["ingress"] + assert len(rules) == (2 if expected else 1) + if expected: + assert rules[1]["from"] == [LOCAL_PEER] + assert len(rules[1]["ports"]) == len(extension_ports) + _assert_default_egress(policy) + + +def _assert_default_egress(policy: dict[str, Any]) -> None: + assert policy["spec"]["egress"] == [ + {"to": [LOCAL_PEER]}, + { + "to": [ + { + "namespaceSelector": { + "matchLabels": {"kubernetes.io/metadata.name": "kube-system"} + } + } + ], + "ports": [{"protocol": "UDP", "port": 53}, {"protocol": "TCP", "port": 53}], + }, + ] + + +@pytest.mark.parametrize("mode", ["sidecar", "child", "shared"]) +@pytest.mark.parametrize("pull_secrets", [[], [{"name": "fixture-registry-pull"}]]) +@pytest.mark.parametrize( + "extensions", ["none", "connector", "component", "both", "shared-port"] +) +def test_extension_render_contract( + tmp_path: Path, mode: str, pull_secrets: list[dict[str, str]], extensions: str +) -> None: + values = _values(mode, pull_secrets, extensions) + documents = _render(tmp_path, values) + expected = values["connectors"] + values["components"] + _assert_extension_pods(documents, expected, pull_secrets) + _assert_network_policy(documents, expected, mode) + + +def test_disabled_network_policy_renders_no_policy(tmp_path: Path) -> None: + documents = _render( + tmp_path, + { + "networkPolicy": {"enabled": False}, + "connectors": [_extension("sample-mcp", 9123)], + }, + ) + assert all(document["kind"] != "NetworkPolicy" for document in documents)