feat(k8s): Support new Gateway api

This commit is contained in:
Contre
2026-05-18 11:44:45 +02:00
parent 8932f2ad46
commit 419da20681
2 changed files with 125 additions and 63 deletions
+62 -48
View File
@@ -19,17 +19,17 @@ import (
"k8s.io/client-go/rest"
)
type ingressKey struct {
type resourceKey struct {
namespace string
name string
}
type ingressAppKey struct {
ingressKey
type resourceAppKey struct {
resourceKey
appName string
}
type ingressApp struct {
type resourceApp struct {
domain string
appName string
app model.App
@@ -42,9 +42,22 @@ type KubernetesService struct {
client dynamic.Interface
started bool
mu sync.RWMutex
ingressApps map[ingressKey][]ingressApp
domainIndex map[string]ingressAppKey
appNameIndex map[string]ingressAppKey
resourceApps map[resourceKey][]resourceApp
domainIndex map[string]resourceAppKey
appNameIndex map[string]resourceAppKey
}
var watchedGVRs = []schema.GroupVersionResource{
{
Group: "networking.k8s.io",
Version: "v1",
Resource: "ingresses",
},
{
Group: "gateway.networking.k8s.io",
Version: "v1",
Resource: "httproutes",
},
}
func NewKubernetesService(
@@ -62,74 +75,75 @@ func NewKubernetesService(
return nil, fmt.Errorf("failed to create kubernetes client: %w", err)
}
gvr := schema.GroupVersionResource{
Group: "networking.k8s.io",
Version: "v1",
Resource: "ingresses",
service := &KubernetesService{
log: log,
ctx: ctx,
client: client,
resourceApps: make(map[resourceKey][]resourceApp),
domainIndex: make(map[string]resourceAppKey),
appNameIndex: make(map[string]resourceAppKey),
}
accessCtx, accessCancel := context.WithTimeout(ctx, 5*time.Second)
defer accessCancel()
_, err = client.Resource(gvr).List(accessCtx, metav1.ListOptions{Limit: 1})
if err != nil {
log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to access Ingress API, Kubernetes label provider will be disabled")
return nil, fmt.Errorf("failed to access ingress api: %w", err)
started := 0
for _, gvr := range watchedGVRs {
_, err = client.Resource(gvr).List(accessCtx, metav1.ListOptions{Limit: 1})
if err != nil {
log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to access API, skipping watcher")
continue
}
log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Successfully accessed API, starting watcher")
gvrCopy := gvr
wg.Go(func() {
service.watchGVR(gvrCopy)
})
started++
}
log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Successfully accessed Ingress API, starting watcher")
service := &KubernetesService{
log: log,
ctx: ctx,
client: client,
ingressApps: make(map[ingressKey][]ingressApp),
domainIndex: make(map[string]ingressAppKey),
appNameIndex: make(map[string]ingressAppKey),
if started == 0 {
return nil, fmt.Errorf("failed to access any supported kubernetes API (ingresses, httproutes)")
}
wg.Go(func() {
service.watchGVR(gvr)
})
service.started = true
log.App.Debug().Msg("Kubernetes label provider started successfully")
return service, nil
}
func (k *KubernetesService) addIngressApps(namespace, name string, apps []ingressApp) {
func (k *KubernetesService) addResourceApps(namespace, name string, apps []resourceApp) {
k.mu.Lock()
defer k.mu.Unlock()
key := ingressKey{namespace, name}
// Remove existing entries for this ingress
if existing, ok := k.ingressApps[key]; ok {
key := resourceKey{namespace, name}
// Remove existing entries for this resource
if existing, ok := k.resourceApps[key]; ok {
for _, app := range existing {
delete(k.domainIndex, app.domain)
delete(k.appNameIndex, app.appName)
}
}
// Add new entries
k.ingressApps[key] = apps
k.resourceApps[key] = apps
for _, app := range apps {
appKey := ingressAppKey{key, app.appName}
appKey := resourceAppKey{key, app.appName}
k.domainIndex[app.domain] = appKey
k.appNameIndex[app.appName] = appKey
}
}
func (k *KubernetesService) removeIngress(namespace, name string) {
func (k *KubernetesService) removeResource(namespace, name string) {
k.mu.Lock()
defer k.mu.Unlock()
key := ingressKey{namespace, name}
if apps, ok := k.ingressApps[key]; ok {
key := resourceKey{namespace, name}
if apps, ok := k.resourceApps[key]; ok {
for _, app := range apps {
delete(k.domainIndex, app.domain)
delete(k.appNameIndex, app.appName)
}
delete(k.ingressApps, key)
delete(k.resourceApps, key)
}
}
@@ -138,7 +152,7 @@ func (k *KubernetesService) getByDomain(domain string) *model.App {
defer k.mu.RUnlock()
if appKey, ok := k.domainIndex[domain]; ok {
if apps, ok := k.ingressApps[appKey.ingressKey]; ok {
if apps, ok := k.resourceApps[appKey.resourceKey]; ok {
for i := range apps {
app := &apps[i]
if app.domain == domain && app.appName == appKey.appName {
@@ -155,7 +169,7 @@ func (k *KubernetesService) getByAppName(appName string) *model.App {
defer k.mu.RUnlock()
if appKey, ok := k.appNameIndex[appName]; ok {
if apps, ok := k.ingressApps[appKey.ingressKey]; ok {
if apps, ok := k.resourceApps[appKey.resourceKey]; ok {
for i := range apps {
app := &apps[i]
if app.appName == appName {
@@ -172,30 +186,30 @@ func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
name := item.GetName()
annotations := item.GetAnnotations()
if annotations == nil {
k.removeIngress(namespace, name)
k.removeResource(namespace, name)
return
}
labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps")
if err != nil {
k.log.App.Warn().Err(err).Str("namespace", namespace).Str("name", name).Msg("Failed to decode ingress labels, skipping")
k.removeIngress(namespace, name)
k.log.App.Warn().Err(err).Str("namespace", namespace).Str("name", name).Msg("Failed to decode labels, skipping")
k.removeResource(namespace, name)
return
}
var apps []ingressApp
var apps []resourceApp
for appName, appLabels := range labels.Apps {
if appLabels.Config.Domain == "" {
continue
}
apps = append(apps, ingressApp{
apps = append(apps, resourceApp{
domain: appLabels.Config.Domain,
appName: appName,
app: appLabels,
})
}
if len(apps) == 0 {
k.removeIngress(namespace, name)
k.removeResource(namespace, name)
} else {
k.addIngressApps(namespace, name, apps)
k.addResourceApps(namespace, name, apps)
}
}
@@ -239,7 +253,7 @@ func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.
case watch.Added, watch.Modified:
k.updateFromItem(item)
case watch.Deleted:
k.removeIngress(item.GetNamespace(), item.GetName())
k.removeResource(item.GetNamespace(), item.GetName())
}
case <-resyncTicker.C:
if err := k.resyncGVR(gvr); err != nil {
+63 -15
View File
@@ -25,7 +25,7 @@ func TestKubernetesService(t *testing.T) {
description: "Cache by domain returns app and misses unknown domain",
run: func(t *testing.T, svc *KubernetesService) {
app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}}
svc.addIngressApps("default", "my-ingress", []ingressApp{
svc.addResourceApps("default", "my-ingress", []resourceApp{
{domain: "foo.example.com", appName: "foo", app: app},
})
@@ -41,7 +41,7 @@ func TestKubernetesService(t *testing.T) {
description: "Cache by app name returns app and misses unknown name",
run: func(t *testing.T, svc *KubernetesService) {
app := model.App{Config: model.AppConfig{Domain: "bar.example.com"}}
svc.addIngressApps("default", "my-ingress", []ingressApp{
svc.addResourceApps("default", "my-ingress", []resourceApp{
{domain: "bar.example.com", appName: "bar", app: app},
})
@@ -54,14 +54,14 @@ func TestKubernetesService(t *testing.T) {
},
},
{
description: "RemoveIngress clears domain and app name entries",
description: "RemoveResource clears domain and app name entries",
run: func(t *testing.T, svc *KubernetesService) {
app := model.App{Config: model.AppConfig{Domain: "baz.example.com"}}
svc.addIngressApps("default", "my-ingress", []ingressApp{
svc.addResourceApps("default", "my-ingress", []resourceApp{
{domain: "baz.example.com", appName: "baz", app: app},
})
svc.removeIngress("default", "my-ingress")
svc.removeResource("default", "my-ingress")
got := svc.getByDomain("baz.example.com")
assert.Nil(t, got)
@@ -70,15 +70,15 @@ func TestKubernetesService(t *testing.T) {
},
},
{
description: "AddIngressApps replaces stale entries for the same ingress",
description: "AddResourceApps replaces stale entries for the same resource",
run: func(t *testing.T, svc *KubernetesService) {
old := model.App{Config: model.AppConfig{Domain: "old.example.com"}}
svc.addIngressApps("default", "my-ingress", []ingressApp{
svc.addResourceApps("default", "my-ingress", []resourceApp{
{domain: "old.example.com", appName: "old", app: old},
})
updated := model.App{Config: model.AppConfig{Domain: "new.example.com"}}
svc.addIngressApps("default", "my-ingress", []ingressApp{
svc.addResourceApps("default", "my-ingress", []resourceApp{
{domain: "new.example.com", appName: "new", app: updated},
})
@@ -96,7 +96,7 @@ func TestKubernetesService(t *testing.T) {
svc.started = true
app := model.App{Config: model.AppConfig{Domain: "hit.example.com"}}
svc.addIngressApps("default", "ing", []ingressApp{
svc.addResourceApps("default", "ing", []resourceApp{
{domain: "hit.example.com", appName: "hit", app: app},
})
@@ -121,7 +121,7 @@ func TestKubernetesService(t *testing.T) {
svc.started = true
app := model.App{Config: model.AppConfig{Domain: "myapp.internal.example.com"}}
svc.addIngressApps("default", "ing", []ingressApp{
svc.addResourceApps("default", "ing", []resourceApp{
{domain: "myapp.internal.example.com", appName: "myapp", app: app},
})
@@ -139,7 +139,7 @@ func TestKubernetesService(t *testing.T) {
},
},
{
description: "UpdateFromItem parses annotations and populates cache",
description: "UpdateFromItem parses annotations and populates cache from ingress",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
@@ -157,11 +157,30 @@ func TestKubernetesService(t *testing.T) {
assert.Equal(t, "alice", got.Users.Allow)
},
},
{
description: "UpdateFromItem parses annotations and populates cache from httproute",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-httproute")
item.SetAnnotations(map[string]string{
"tinyauth.apps.gwapp.config.domain": "gwapp.example.com",
"tinyauth.apps.gwapp.users.allow": "bob",
})
svc.updateFromItem(&item)
got := svc.getByDomain("gwapp.example.com")
require.NotNil(t, got)
assert.Equal(t, "gwapp.example.com", got.Config.Domain)
assert.Equal(t, "bob", got.Users.Allow)
},
},
{
description: "UpdateFromItem with no annotations removes existing cache entries",
run: func(t *testing.T, svc *KubernetesService) {
app := model.App{Config: model.AppConfig{Domain: "todelete.example.com"}}
svc.addIngressApps("default", "test-ingress", []ingressApp{
svc.addResourceApps("default", "test-ingress", []resourceApp{
{domain: "todelete.example.com", appName: "todelete", app: app},
})
@@ -175,14 +194,43 @@ func TestKubernetesService(t *testing.T) {
assert.Nil(t, got)
},
},
{
description: "Ingress and HTTPRoute apps coexist in cache",
run: func(t *testing.T, svc *KubernetesService) {
ingress := unstructured.Unstructured{}
ingress.SetNamespace("default")
ingress.SetName("my-ingress")
ingress.SetAnnotations(map[string]string{
"tinyauth.apps.ingapp.config.domain": "ingapp.example.com",
})
httproute := unstructured.Unstructured{}
httproute.SetNamespace("default")
httproute.SetName("my-httproute")
httproute.SetAnnotations(map[string]string{
"tinyauth.apps.gwapp.config.domain": "gwapp.example.com",
})
svc.updateFromItem(&ingress)
svc.updateFromItem(&httproute)
got := svc.getByDomain("ingapp.example.com")
require.NotNil(t, got)
assert.Equal(t, "ingapp.example.com", got.Config.Domain)
got = svc.getByDomain("gwapp.example.com")
require.NotNil(t, got)
assert.Equal(t, "gwapp.example.com", got.Config.Domain)
},
},
}
for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
svc := &KubernetesService{
ingressApps: make(map[ingressKey][]ingressApp),
domainIndex: make(map[string]ingressAppKey),
appNameIndex: make(map[string]ingressAppKey),
resourceApps: make(map[resourceKey][]resourceApp),
domainIndex: make(map[string]resourceAppKey),
appNameIndex: make(map[string]resourceAppKey),
log: log,
}
test.run(t, svc)