From 419da20681f2f073c2a63484ace2bc7a84a918da Mon Sep 17 00:00:00 2001 From: Contre Date: Mon, 18 May 2026 11:44:45 +0200 Subject: [PATCH 1/2] feat(k8s): Support new Gateway api --- internal/service/kubernetes_service.go | 110 +++++++++++--------- internal/service/kubernetes_service_test.go | 78 +++++++++++--- 2 files changed, 125 insertions(+), 63 deletions(-) diff --git a/internal/service/kubernetes_service.go b/internal/service/kubernetes_service.go index 8976cb54..9f898084 100644 --- a/internal/service/kubernetes_service.go +++ b/internal/service/kubernetes_service.go @@ -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 { diff --git a/internal/service/kubernetes_service_test.go b/internal/service/kubernetes_service_test.go index 702fe0f8..c58d28be 100644 --- a/internal/service/kubernetes_service_test.go +++ b/internal/service/kubernetes_service_test.go @@ -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) From 2769725775b38f65ad619f856c530d5a545d5ce1 Mon Sep 17 00:00:00 2001 From: Contre Date: Mon, 18 May 2026 14:48:01 +0200 Subject: [PATCH 2/2] feat(k8s): Support for GRPCRoute --- internal/service/kubernetes_service.go | 5 +++++ internal/service/kubernetes_service_test.go | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/internal/service/kubernetes_service.go b/internal/service/kubernetes_service.go index 9f898084..34989c91 100644 --- a/internal/service/kubernetes_service.go +++ b/internal/service/kubernetes_service.go @@ -58,6 +58,11 @@ var watchedGVRs = []schema.GroupVersionResource{ Version: "v1", Resource: "httproutes", }, + { + Group: "gateway.networking.k8s.io", + Version: "v1", + Resource: "grpcroutes", + }, } func NewKubernetesService( diff --git a/internal/service/kubernetes_service_test.go b/internal/service/kubernetes_service_test.go index c58d28be..8bcb5836 100644 --- a/internal/service/kubernetes_service_test.go +++ b/internal/service/kubernetes_service_test.go @@ -194,6 +194,25 @@ func TestKubernetesService(t *testing.T) { assert.Nil(t, got) }, }, + { + description: "UpdateFromItem parses annotations and populates cache from grpcroute", + run: func(t *testing.T, svc *KubernetesService) { + item := unstructured.Unstructured{} + item.SetNamespace("default") + item.SetName("test-grpcroute") + item.SetAnnotations(map[string]string{ + "tinyauth.apps.grpcapp.config.domain": "grpcapp.example.com", + "tinyauth.apps.grpcapp.users.allow": "carol", + }) + + svc.updateFromItem(&item) + + got := svc.getByDomain("grpcapp.example.com") + require.NotNil(t, got) + assert.Equal(t, "grpcapp.example.com", got.Config.Domain) + assert.Equal(t, "carol", got.Users.Allow) + }, + }, { description: "Ingress and HTTPRoute apps coexist in cache", run: func(t *testing.T, svc *KubernetesService) {