Skip to content
Merged

104 #105

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions .github/workflows/integration.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand All @@ -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
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 .
78 changes: 70 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -120,9 +161,13 @@ spec:
ingress:
- from:
- namespaceSelector: {}
podSelector:
matchLabels:
wormhole.glothriel.github.com/network-policy-consumes-app: <<APP-NAME>>
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
Expand All @@ -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 `<service-namespace-name>-<service-name>` (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 `<service-namespace-name>-<service-name>` (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

Expand Down Expand Up @@ -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
Expand All @@ -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 .
```
15 changes: 15 additions & 0 deletions kubernetes/raw/curl/all.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
38 changes: 38 additions & 0 deletions pkg/k8s/networkpolicies.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package k8s

import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"

"github.com/sirupsen/logrus"
v1 "k8s.io/api/core/v1"
Expand All @@ -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)
Expand Down Expand Up @@ -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{
Expand Down
100 changes: 100 additions & 0 deletions pkg/k8s/networkpolicies_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
1 change: 0 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import logging
import os
import subprocess
import tempfile
import sys

import pytest
Expand Down
5 changes: 3 additions & 2 deletions tests/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@
import os
import shutil
import subprocess
from contextlib import contextmanager

import psutil
import requests
from retry import retry

Expand Down Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions tests/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ dependencies = [
"psutil>=6.0.0",
"requests>=2.32.0",
"retry>=0.9.2",
"ruff>=0.1.0",
]

Loading
Loading