diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 66b88aa..744ba39 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -29,8 +29,7 @@ jobs: enable-cache: true - name: Install packages required for tests - shell: bash - run: cd tests && uv sync --no-install-project + run: uv sync --project tests --no-install-project - name: Run integration tests run: uv run --project tests pytest -v --tb=short tests/ @@ -40,9 +39,21 @@ jobs: timeout-minutes: 60 steps: - - name: Run linters - uses: PiwikPRO/actions/python/lint@master + - name: Check out repository code + uses: actions/checkout@v4 + + - name: Install Python + uses: actions/setup-python@v5 with: - use-black: true - use-flake: true - use-isort: true \ No newline at end of file + python-version: "3.13" + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - name: Install packages required for linting + run: uv sync --project tests --no-install-project + + - name: Run ruff check + run: uv run --project tests ruff check . \ No newline at end of file diff --git a/README.md b/README.md index 99dd085..f58c370 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,47 @@ You can enable network policies by setting `--set networkPolicies.enabled=true` When wormhole is deployed with network policies support, each time it exposes a remote service it also creates a matching network policy. The network policy is created in the same namespace as the service and allows filtering of the traffic from other workloads in the cluster to the remote service. +Pods can declare which Wormhole-exposed applications they can access using labels. There are two supported label formats: + +**Recommended format (supports multiple apps per pod):** + +Use labels where the app name is embedded in the label **key**, allowing a single pod to access multiple Wormhole-exposed applications: + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: my-pod + namespace: default + labels: + consumes.wormhole.glothriel.github.com/nginx-nginx: "true" + consumes.wormhole.glothriel.github.com/default-postgres: "true" +spec: + containers: + - name: app + image: myapp:latest +``` + +**Legacy format (backward compatible):** + +Older configurations using the single-key format are still supported: + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: my-pod + namespace: default + labels: + wormhole.glothriel.github.com/network-policy-consumes-app: nginx-nginx +spec: + containers: + - name: app + image: myapp:latest +``` + +The corresponding NetworkPolicy that gets created looks like: + ``` apiVersion: networking.k8s.io/v1 kind: NetworkPolicy @@ -120,9 +161,13 @@ spec: ingress: - from: - namespaceSelector: {} - podSelector: - matchLabels: - wormhole.glothriel.github.com/network-policy-consumes-app: <> + podSelector: + matchLabels: + consumes.wormhole.glothriel.github.com/nginx-nginx: "true" + - namespaceSelector: {} + podSelector: + matchLabels: + wormhole.glothriel.github.com/network-policy-consumes-app: nginx-nginx ports: - port: 25001 protocol: TCP @@ -133,9 +178,9 @@ spec: - Ingress ``` -Such policies allow communication from any pod in any namespace, providing, that the pod that tries to communicate has a label `wormhole.glothriel.github.com/network-policy-consumes-app` with the value of the name of the service that is exposed. The app name (unless override by `wormhole.glothriel.github.com/name=my-custom-name`) is `-` (for example `default-nginx`) of the service exposed from remote cluster. +The app name (unless overridden by `wormhole.glothriel.github.com/name=my-custom-name`) is `-` (for example `default-nginx`) of the service exposed from remote cluster. -Effectively this means, that the permission to communicate is granted per application, not per peer. Having permission to communicate with app having given name, allows the pod to communicate with all the apps with given name, no matter the peer the app is exposed from. This is especially important in the context of the server, as it may have multiple clients, all exposing the same app. +Permission to communicate is granted per application, not per peer. Having permission to communicate with an app having a given name allows the pod to communicate with all apps with that name, regardless of which peer the app is exposed from. This is especially important in the context of the server, as it may have multiple clients all exposing the same app. ## HTTP API @@ -230,9 +275,12 @@ No body or query parameters are required. Requirements: +* Go 1.25+ * Helm * Tilt * K3d +* Python 3.10+ +* uv (for test dependencies) ``` k3d cluster create wormhole --registry-create wormhole @@ -252,15 +300,29 @@ The additional services should be immediately created. Please note, that all thr ### Integration tests +Install test dependencies: + +```bash +uv sync --project tests --no-install-project ``` -cd tests && uv sync --no-install-project && cd - +Run integration tests: + +```bash uv run --project tests pytest tests/ ``` -If you are re-running the tests multiple times, you may want to reuse the K3d cluster, you can do this by setting the `REUSE_CLUSTER` environment variable to a truthy value. It will then abstain from removing the cluster after the tests are done and reuse it for the next run. +**Reusing the K3d cluster between test runs:** -``` +If you are re-running the tests multiple times, you can set the `REUSE_CLUSTER` environment variable to avoid recreating the cluster each time: + +```bash export REUSE_CLUSTER=1 uv run --project tests pytest tests/ ``` + +**Linting test code:** + +```bash +uv run --project tests ruff check . +``` diff --git a/kubernetes/raw/curl/all.yaml b/kubernetes/raw/curl/all.yaml index 1f9db76..80c6995 100644 --- a/kubernetes/raw/curl/all.yaml +++ b/kubernetes/raw/curl/all.yaml @@ -25,3 +25,18 @@ spec: - name: curl-container image: curlimages/curl command: ["sleep", "999999"] + +--- +apiVersion: v1 +kind: Pod +metadata: + name: curl-with-new-labels + namespace: default + labels: + consumes.wormhole.glothriel.github.com/nginx-nginx: "true" +spec: + terminationGracePeriodSeconds: 1 + containers: + - name: curl-container + image: curlimages/curl + command: ["sleep", "999999"] diff --git a/pkg/k8s/networkpolicies.go b/pkg/k8s/networkpolicies.go index 63043d6..a56a9df 100644 --- a/pkg/k8s/networkpolicies.go +++ b/pkg/k8s/networkpolicies.go @@ -2,7 +2,10 @@ package k8s import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" + "strings" "github.com/sirupsen/logrus" v1 "k8s.io/api/core/v1" @@ -19,6 +22,31 @@ type managedK8sNetworkPolicy struct { } const consumesNpLabel = "wormhole.glothriel.github.com/network-policy-consumes-app" +const consumesNpLabelPrefix = "consumes.wormhole.glothriel.github.com/" + +func consumesNpLabelKey(appName string) string { + labelName := appName + if len(labelName) > 63 { + // Apply same hashing approach as capName for label key segment limit + hasher := sha256.New() + hasher.Write([]byte(labelName)) + hash := hex.EncodeToString(hasher.Sum(nil))[:8] + + searchStart := 32 + searchEnd := 54 + + substring := labelName[searchStart:searchEnd] + hyphenIndex := strings.LastIndex(substring, "-") + + if hyphenIndex != -1 { + actualIndex := searchStart + hyphenIndex + labelName = labelName[:actualIndex] + "-" + hash + } else { + labelName = labelName[:54] + "-" + hash + } + } + return consumesNpLabelPrefix + labelName +} func (m *managedK8sNetworkPolicy) Add(metadata k8sResourceMetadata, clientset *kubernetes.Clientset) error { networkPoliciesClient := clientset.NetworkingV1().NetworkPolicies(m.namespace) @@ -68,6 +96,16 @@ func (m *managedK8sNetworkPolicy) npDefinition(port int, metadata k8sResourceMet }, }, From: []networkingv1.NetworkPolicyPeer{ + // New format: app name in label key + { + PodSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + consumesNpLabelKey(metadata.originalApp.Name): "true", + }, + }, + NamespaceSelector: &metav1.LabelSelector{}, + }, + // Old format: backward compatibility { PodSelector: &metav1.LabelSelector{ MatchLabels: map[string]string{ diff --git a/pkg/k8s/networkpolicies_test.go b/pkg/k8s/networkpolicies_test.go new file mode 100644 index 0000000..ba273cf --- /dev/null +++ b/pkg/k8s/networkpolicies_test.go @@ -0,0 +1,100 @@ +package k8s + +import ( + "testing" + + "github.com/glothriel/wormhole/pkg/apps" + "github.com/stretchr/testify/assert" +) + +func TestConsumesNpLabelKey(t *testing.T) { + tests := []struct { + name string + appName string + expected func(string) bool + }{ + { + name: "short app name", + appName: "nginx", + expected: func(result string) bool { + return result == "consumes.wormhole.glothriel.github.com/nginx" + }, + }, + { + name: "app name with hyphen", + appName: "default-nginx", + expected: func(result string) bool { + return result == "consumes.wormhole.glothriel.github.com/default-nginx" + }, + }, + { + name: "very long app name gets hashed", + appName: "alpha-beta-gamma-delta-epsilon-zeta-eta-iota-kappa-lambda-mi-ni-xi-omikron-pi-rho-sigma", + expected: func(result string) bool { + // Should have the prefix and a hashed suffix + if !assert.ObjectsAreEqual(result[:len(consumesNpLabelPrefix)], consumesNpLabelPrefix) { + return false + } + labelPart := result[len(consumesNpLabelPrefix):] + // Should be at most 63 chars and contain a hyphen followed by 8-char hash + if len(labelPart) > 63 { + return false + } + parts := result[len(consumesNpLabelPrefix):] + hyphenPos := len(parts) - 9 + if hyphenPos < 0 || parts[hyphenPos] != '-' { + return false + } + return true + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := consumesNpLabelKey(tt.appName) + assert.True(t, tt.expected(result)) + }) + } +} + +func TestNpDefinitionDualFormat(t *testing.T) { + // given + m := &managedK8sNetworkPolicy{ + namespace: "test-ns", + selectors: map[string]string{"app": "wormhole-client"}, + } + + metadata := k8sResourceMetadata{ + entityName: "client-nginx-nginx", + originalApp: apps.App{ + Name: "nginx-nginx", + Peer: "client", + OriginalPort: 80, + }, + afterExposedApp: apps.App{ + Name: "nginx-nginx", + Peer: "client", + OriginalPort: 80, + Address: "client-nginx-nginx.test-ns:25001", + }, + } + + // when + np := m.npDefinition(25001, metadata) + + // then + assert.NotNil(t, np) + assert.Len(t, np.Spec.Ingress, 1) + assert.Len(t, np.Spec.Ingress[0].From, 2, "should have two From entries for dual format support") + + // Check new format entry + newFormatPeer := np.Spec.Ingress[0].From[0] + assert.NotNil(t, newFormatPeer.PodSelector) + assert.Equal(t, "true", newFormatPeer.PodSelector.MatchLabels[consumesNpLabelKey("nginx-nginx")]) + + // Check old format entry + oldFormatPeer := np.Spec.Ingress[0].From[1] + assert.NotNil(t, oldFormatPeer.PodSelector) + assert.Equal(t, "nginx-nginx", oldFormatPeer.PodSelector.MatchLabels[consumesNpLabel]) +} diff --git a/tests/conftest.py b/tests/conftest.py index 519eb6a..6a73a02 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,6 @@ import logging import os import subprocess -import tempfile import sys import pytest diff --git a/tests/fixtures.py b/tests/fixtures.py index 042cdac..a73b0c1 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -2,9 +2,7 @@ import os import shutil import subprocess -from contextlib import contextmanager -import psutil import requests from retry import retry @@ -52,6 +50,9 @@ def stop(self): def call_with_network_policy(self, command, max_time_seconds=None): return self._call("curl-with-labels", command, max_time_seconds) + def call_with_new_network_policy(self, command, max_time_seconds=None): + return self._call("curl-with-new-labels", command, max_time_seconds) + def call_without_network_policy(self, command, max_time_seconds=None): return self._call("curl-no-labels", command, max_time_seconds) diff --git a/tests/pyproject.toml b/tests/pyproject.toml index c567c99..d29327b 100644 --- a/tests/pyproject.toml +++ b/tests/pyproject.toml @@ -13,5 +13,6 @@ dependencies = [ "psutil>=6.0.0", "requests>=2.32.0", "retry>=0.9.2", + "ruff>=0.1.0", ] diff --git a/tests/test_kubernetes.py b/tests/test_kubernetes.py index 0febede..884be9a 100644 --- a/tests/test_kubernetes.py +++ b/tests/test_kubernetes.py @@ -229,6 +229,51 @@ def _ensure_that_proxied_service_is_reachable(): ) +def test_connection_via_tunnel_with_new_network_policy_labels( + kubectl, + k8s_server, + k8s_client, + mock_server, + curl, +): + annotator = Annotator(mock_server, kubectl) + amount_of_services_before_annotation = Services.count(kubectl, "server") + annotator.do("wormhole.glothriel.github.com/exposed", "yes") + + @retry(tries=DEFAULT_RETRY_TRIES, delay=DEFAULT_RETRY_DELAY) + def _ensure_that_proxied_service_is_created(): + assert Services.count(kubectl, "server") == amount_of_services_before_annotation + 1 + + _ensure_that_proxied_service_is_created() + + @retry(tries=int(DEFAULT_RETRY_TRIES / 10), delay=DEFAULT_RETRY_DELAY) + def _ensure_that_proxied_service_is_reachable_with_old_format(): + # Old format labels should still work + curl.call_with_network_policy( + "http://server-nginx-nginx.client.svc.cluster.local", + max_time_seconds=10, + ) + + _ensure_that_proxied_service_is_reachable_with_old_format() + + @retry(tries=int(DEFAULT_RETRY_TRIES / 10), delay=DEFAULT_RETRY_DELAY) + def _ensure_that_proxied_service_is_reachable_with_new_format(): + # New format labels should also work + curl.call_with_new_network_policy( + "http://server-nginx-nginx.client.svc.cluster.local", + max_time_seconds=10, + ) + + _ensure_that_proxied_service_is_reachable_with_new_format() + + # Calling CURL from non-annotated pod should fail + with pytest.raises(Exception): + curl.call_without_network_policy( + "http://server-nginx-nginx.client.svc.cluster.local", + max_time_seconds=10, + ) + + def test_reconnecting_clients_with_keys( kubectl, k8s_server, diff --git a/tests/uv.lock b/tests/uv.lock index d3ddd7b..8e9239b 100644 --- a/tests/uv.lock +++ b/tests/uv.lock @@ -412,6 +412,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/0d/53aea75710af4528a25ed6837d71d117602b01946b307a3912cb3cfcbcba/retry-0.9.2-py2.py3-none-any.whl", hash = "sha256:ccddf89761fa2c726ab29391837d4327f819ea14d244c232a1d24c67a2f98606", size = 7986, upload-time = "2016-05-11T13:58:39.925Z" }, ] +[[package]] +name = "ruff" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" }, + { url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" }, + { url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" }, + { url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" }, + { url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" }, + { url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" }, + { url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" }, + { url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" }, + { url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" }, + { url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" }, + { url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" }, +] + [[package]] name = "tomli" version = "2.3.0" @@ -489,6 +514,7 @@ dependencies = [ { name = "pytest" }, { name = "requests" }, { name = "retry" }, + { name = "ruff" }, ] [package.metadata] @@ -498,4 +524,5 @@ requires-dist = [ { name = "pytest", specifier = ">=8.0.0" }, { name = "requests", specifier = ">=2.32.0" }, { name = "retry", specifier = ">=0.9.2" }, + { name = "ruff", specifier = ">=0.1.0" }, ]