diff --git a/internal/service/access_controls_service.go b/internal/service/access_controls_service.go index f8816a1f..c11d9fcb 100644 --- a/internal/service/access_controls_service.go +++ b/internal/service/access_controls_service.go @@ -10,8 +10,12 @@ import ( "go.uber.org/dig" ) +// LabelProvider looks up the apps it knows about for the given domain. A +// provider that knows which hosts its apps are served on MUST only yield the +// ones that are actually served on domain, so that an unrelated app cannot +// claim it by name. type LabelProvider interface { - Lookup(locator func(name string, app *model.App) bool) error + Lookup(domain string, locator func(name string, app *model.App) bool) error } type AccessControlsService struct { @@ -113,7 +117,9 @@ func (service *AccessControlsService) GetAccessControls(domain string) (*model.A // If we have a label provider configured, try to get ACLs from it if service.labelProvider != nil { - return service.getACLs(domain, service.labelProvider.Lookup) + return service.getACLs(domain, func(locator func(name string, app *model.App) bool) error { + return service.labelProvider.Lookup(domain, locator) + }) } // No labels diff --git a/internal/service/access_controls_service_test.go b/internal/service/access_controls_service_test.go index 30415933..734f8181 100644 --- a/internal/service/access_controls_service_test.go +++ b/internal/service/access_controls_service_test.go @@ -18,7 +18,7 @@ func newMockProvider(acls map[string]model.App, shouldError bool) *mockProvider return &mockProvider{acls: acls, shouldError: shouldError} } -func (m *mockProvider) Lookup(locator func(name string, app *model.App) bool) error { +func (m *mockProvider) Lookup(_ string, locator func(name string, app *model.App) bool) error { if m.shouldError { return errors.New("mock error") } @@ -121,7 +121,7 @@ func TestAccessControlsService(t *testing.T) { Config: &model.Config{}, LabelProvider: mock, }) - app, err := acls.getACLs(test.domain, mock.Lookup) + app, err := acls.GetAccessControls(test.domain) require.NoError(t, err) require.Equal(t, test.want, app) }) @@ -145,10 +145,11 @@ func TestAccessControlsService(t *testing.T) { // get acls should return an error when the provider fails mock := newMockProvider(map[string]model.App{}, true) acls := NewAccessControlsService(AccessControlServiceInput{ - Log: log, - Config: &model.Config{}, + Log: log, + Config: &model.Config{}, + LabelProvider: mock, }) - _, err := acls.getACLs("example.com", mock.Lookup) + _, err := acls.GetAccessControls("example.com") require.Error(t, err) // get access controls should get acls from diff --git a/internal/service/docker_service.go b/internal/service/docker_service.go index 21265a2e..46e354e9 100644 --- a/internal/service/docker_service.go +++ b/internal/service/docker_service.go @@ -67,7 +67,10 @@ func (docker *DockerService) inspectContainer(containerId string) (container.Ins return docker.client.ContainerInspect(docker.context, containerId) } -func (docker *DockerService) Lookup(locator func(name string, app *model.App) bool) error { +// Lookup yields every app labelled on a running container. Container labels +// carry no routing information, so the domain cannot be used to narrow the +// results down and the caller is left to match them. +func (docker *DockerService) Lookup(_ string, locator func(name string, app *model.App) bool) error { if !docker.isConnected { docker.log.App.Debug().Msg("Docker service not connected, returning empty labels") return nil diff --git a/internal/service/kubernetes_service.go b/internal/service/kubernetes_service.go index 942c5e17..de292294 100644 --- a/internal/service/kubernetes_service.go +++ b/internal/service/kubernetes_service.go @@ -23,12 +23,67 @@ import ( "k8s.io/client-go/rest" ) -type ingressEntry struct { +// watchedResource describes a kind of resource that can carry tinyauth +// annotations, along with the specifics of extracting the hosts it routes. +type watchedResource struct { + gvr schema.GroupVersionResource + // gatewayAPI resources declare their hosts in spec.hostnames instead of + // spec.rules[].host and may use the wildcard label (`*.`). + gatewayAPI bool + // httpPaths marks resources that route on HTTP paths, which means another + // resource may claim the same host on a different path. + httpPaths bool +} + +// api returns a human readable identifier for the watched resource. +func (r watchedResource) api() string { + return r.gvr.GroupVersion().String() + "/" + r.gvr.Resource +} + +var watchedResources = []watchedResource{ + { + gvr: schema.GroupVersionResource{ + Group: "networking.k8s.io", + Version: "v1", + Resource: "ingresses", + }, + httpPaths: true, + }, + { + gvr: schema.GroupVersionResource{ + Group: "gateway.networking.k8s.io", + Version: "v1", + Resource: "httproutes", + }, + gatewayAPI: true, + httpPaths: true, + }, + { + gvr: schema.GroupVersionResource{ + Group: "gateway.networking.k8s.io", + Version: "v1", + Resource: "grpcroutes", + }, + gatewayAPI: true, + }, +} + +type resourceEntry struct { name string app model.App } -type ingressKey struct { +// routedApps holds the apps annotated on a resource along with the hosts that +// resource routes, which bound the domains those apps may define ACLs for. +type routedApps struct { + hosts []string + entries []resourceEntry +} + +// resourceKey identifies a watched resource. The kind is part of the key +// because an Ingress and an HTTPRoute may share a name within a namespace. +type resourceKey struct { + resource string namespace string name string } @@ -36,10 +91,10 @@ type ingressKey struct { type KubernetesService struct { log *logger.Logger - client dynamic.Interface - connected bool - mu sync.RWMutex - ingressEntries map[ingressKey][]ingressEntry + client dynamic.Interface + connected bool + mu sync.RWMutex + resourceApps map[resourceKey]routedApps } type KubernetesServiceInput struct { @@ -61,32 +116,38 @@ func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error) 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: i.Log, + client: client, + resourceApps: make(map[resourceKey]routedApps), } - accessCtx, accessCancel := context.WithTimeout(i.Ctx, 5*time.Second) - defer accessCancel() + watching := 0 - _, err = client.Resource(gvr).List(accessCtx, metav1.ListOptions{Limit: 1}) - if err != nil { - i.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) - } + for _, res := range watchedResources { + accessCtx, accessCancel := context.WithTimeout(i.Ctx, 5*time.Second) + _, err := client.Resource(res.gvr).List(accessCtx, metav1.ListOptions{Limit: 1}) + accessCancel() + + if err != nil { + // The Gateway API CRDs are not installed on every cluster, so a + // single unreachable API is not fatal + i.Log.App.Warn().Err(err).Str("api", res.api()).Msg("Failed to access API, skipping watcher") + continue + } - i.Log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Successfully accessed Ingress API, starting watcher") + i.Log.App.Debug().Str("api", res.api()).Msg("Successfully accessed API, starting watcher") - service := &KubernetesService{ - log: i.Log, - client: client, - ingressEntries: make(map[ingressKey][]ingressEntry), + i.Ding.Go(func(ctx context.Context) { + service.watchGVR(res, ctx) + }, ding.RingMajor) + + watching++ } - i.Ding.Go(func(ctx context.Context) { - service.watchGVR(gvr, ctx) - }, ding.RingMajor) + if watching == 0 { + return nil, fmt.Errorf("failed to access any supported kubernetes api (ingresses, httproutes, grpcroutes)") + } service.connected = true i.Log.App.Debug().Msg("Kubernetes label provider started successfully") @@ -94,25 +155,43 @@ func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error) return service, nil } -func (k *KubernetesService) addIngressEntries(key ingressKey, entries []ingressEntry) { +func (k *KubernetesService) addResourceEntries(key resourceKey, hosts []string, entries []resourceEntry) { k.mu.Lock() defer k.mu.Unlock() - k.ingressEntries[key] = entries + k.resourceApps[key] = routedApps{ + hosts: hosts, + entries: entries, + } } -func (k *KubernetesService) removeIngress(key ingressKey) { +func (k *KubernetesService) removeResource(key resourceKey) { k.mu.Lock() defer k.mu.Unlock() - delete(k.ingressEntries, key) + delete(k.resourceApps, key) } -func (k *KubernetesService) getEntry(locator func(name string, app *model.App) bool) { +func (k *KubernetesService) getEntry(domain string, locator func(name string, app *model.App) bool) { + v := validators.NewDomainValidator(validators.DomainValidatorOptions{}) + + hostname, err := v.SafeHostname(domain) + if err != nil { + k.log.App.Debug().Err(err).Str("domain", domain).Msg("Domain is invalid, skipping lookup") + return + } + k.mu.RLock() defer k.mu.RUnlock() - // O(n^2) is not great but the number of ingress entries is expected to be small - for _, entries := range k.ingressEntries { - for _, entry := range entries { + // O(n^2) is not great but the number of resource entries is expected to be small + for _, apps := range k.resourceApps { + // Only a resource that routes the domain may define its ACLs, otherwise + // an app could claim any domain that happens to start with its name + if !slices.ContainsFunc(apps.hosts, func(host string) bool { + return hostMatches(host, hostname) + }) { + continue + } + for _, entry := range apps.entries { if ok := locator(entry.name, &entry.app); ok { return } @@ -120,6 +199,33 @@ func (k *KubernetesService) getEntry(locator func(name string, app *model.App) b } } +// hostMatches reports whether hostname is routed by host. It honours the +// Gateway API wildcard label (`*.`), which is a suffix match, so +// `*.example.com` matches `test.example.com` and `foo.test.example.com` but +// not `example.com`. +func hostMatches(host string, hostname string) bool { + host = strings.ToLower(host) + + if suffix, ok := strings.CutPrefix(host, "*."); ok { + return strings.HasSuffix(hostname, "."+suffix) + } + + return host == hostname +} + +// hostCoversName reports whether an app name could resolve to a host routed by +// the resource. A wildcard host covers any app name since `*.example.com` +// routes `.example.com` for every name. +func hostCoversName(host string, name string) bool { + host = strings.ToLower(host) + + if strings.HasPrefix(host, "*.") { + return true + } + + return strings.HasPrefix(host, strings.ToLower(name+".")) +} + func (k *KubernetesService) extractPaths(rule map[string]any) ([]string, error) { http, found, err := unstructured.NestedMap(rule, "http") if err != nil { @@ -148,7 +254,85 @@ func (k *KubernetesService) extractPaths(rule map[string]any) ([]string, error) return result, nil } -func (k *KubernetesService) extractHosts(item *unstructured.Unstructured) ([]string, error) { +// extractRoutePaths returns the paths matched by a Gateway API route rule and +// whether the rule matches every path for its hosts. +func (k *KubernetesService) extractRoutePaths(rule map[string]any) ([]string, bool, error) { + matches, found, err := unstructured.NestedSlice(rule, "matches") + if err != nil { + return nil, false, fmt.Errorf("reading matches from rule: %w", err) + } + if !found || len(matches) == 0 { + // An omitted matches list defaults to a PathPrefix match on "/" + return nil, true, nil + } + var result []string + catchAll := false + for _, m := range matches { + match, ok := m.(map[string]any) + if !ok { + continue + } + path, ok := match["path"].(map[string]any) + if !ok { + // A match without a path constrains something else, such as headers + // or a gRPC method, and leaves the path unrestricted + catchAll = true + continue + } + // Both fields are optional and default to a PathPrefix match on "/" + pathType, ok := path["type"].(string) + if !ok || pathType == "" { + pathType = "PathPrefix" + } + value, ok := path["value"].(string) + if !ok || value == "" { + value = "/" + } + result = append(result, value) + if pathType == "PathPrefix" && value == "/" { + catchAll = true + } + } + return result, catchAll, nil +} + +// warnMissingCatchAllPath warns when a Gateway API route does not match every +// path for the hosts it routes. Unlike an Ingress, a route declares its hosts +// once for all of its rules, so the rules are checked as a whole. +func (k *KubernetesService) warnMissingCatchAllPath(item *unstructured.Unstructured) { + rules, found, err := unstructured.NestedSlice(item.Object, "spec", "rules") + if err != nil { + // This is purely to warn users + // It doesn't affect our ability to extract hosts, so we won't fail the whole operation + k.log.App.Warn().Err(err).Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Msg("Failed to extract paths from route rules") + return + } + if !found || len(rules) == 0 { + return + } + var paths []string + for _, r := range rules { + rule, ok := r.(map[string]any) + if !ok { + continue + } + rulePaths, catchAll, err := k.extractRoutePaths(rule) + if err != nil { + k.log.App.Warn().Err(err).Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Msg("Failed to extract paths from route rule") + continue + } + if catchAll { + return + } + paths = append(paths, rulePaths...) + } + if len(paths) == 0 { + return + } + k.log.App.Warn().Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Strs("paths", paths).Msg("Route does not contain a catch-all path, another route may be able to bypass auth checks if it routes the same host with a different path. Consider adding a catch-all path to this route to ensure auth checks are applied to all paths for this host.") +} + +func (k *KubernetesService) extractIngressHosts(item *unstructured.Unstructured) ([]string, error) { rules, found, err := unstructured.NestedSlice(item.Object, "spec", "rules") if err != nil { return nil, fmt.Errorf("reading spec.rules: %w", err) @@ -183,38 +367,69 @@ func (k *KubernetesService) extractHosts(item *unstructured.Unstructured) ([]str return hosts, nil } -func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) { - key := ingressKey{ +func (k *KubernetesService) extractRouteHosts(res watchedResource, item *unstructured.Unstructured) ([]string, error) { + hostnames, found, err := unstructured.NestedStringSlice(item.Object, "spec", "hostnames") + if err != nil { + return nil, fmt.Errorf("reading spec.hostnames: %w", err) + } + if !found { + // A route without hostnames inherits the ones of the gateway listeners + // it attaches to, which we cannot resolve from the route alone + return nil, nil + } + var hosts []string + for _, hostname := range hostnames { + if hostname != "" { + hosts = append(hosts, hostname) + } + } + if res.httpPaths { + k.warnMissingCatchAllPath(item) + } + k.log.App.Trace().Strs("hosts", hosts).Msg("Extracted hosts from route hostnames") + return hosts, nil +} + +func (k *KubernetesService) extractHosts(res watchedResource, item *unstructured.Unstructured) ([]string, error) { + if res.gatewayAPI { + return k.extractRouteHosts(res, item) + } + return k.extractIngressHosts(item) +} + +func (k *KubernetesService) updateFromItem(res watchedResource, item *unstructured.Unstructured) { + key := resourceKey{ + resource: res.gvr.Resource, namespace: item.GetNamespace(), name: item.GetName(), } annotations := item.GetAnnotations() if annotations == nil { - k.removeIngress(key) + k.removeResource(key) return } - hosts, err := k.extractHosts(item) + hosts, err := k.extractHosts(res, item) if err != nil { - k.removeIngress(key) + k.removeResource(key) return } if len(hosts) == 0 { - k.log.App.Warn().Str("namespace", key.namespace).Str("name", key.name).Msg("No hosts found in ingress, skipping") - k.removeIngress(key) + k.log.App.Warn().Str("api", res.api()).Str("namespace", key.namespace).Str("name", key.name).Msg("No hosts found in resource, skipping") + k.removeResource(key) return } labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps") if err != nil { - k.log.App.Warn().Err(err).Str("namespace", key.namespace).Str("name", key.name).Msg("Failed to decode ingress labels, skipping") - k.removeIngress(key) + k.log.App.Warn().Err(err).Str("namespace", key.namespace).Str("name", key.name).Msg("Failed to decode resource labels, skipping") + k.removeResource(key) return } - var entries []ingressEntry + var entries []resourceEntry v := validators.NewDomainValidator(validators.DomainValidatorOptions{}) @@ -223,8 +438,10 @@ func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) { hostname, err := v.SafeHostname(config.Config.Domain) if err != nil { k.log.App.Warn().Err(err).Str("namespace", key.namespace).Str("name", key.name).Str("domain", config.Config.Domain).Msg("Domain is invalid, matching will rely on app name") - } else if slices.Contains(hosts, hostname) { - entries = append(entries, ingressEntry{ + } else if slices.ContainsFunc(hosts, func(host string) bool { + return hostMatches(host, hostname) + }) { + entries = append(entries, resourceEntry{ name: name, app: config, }) @@ -232,44 +449,43 @@ func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) { } } - for _, host := range hosts { - if strings.HasPrefix(strings.ToLower(host), strings.ToLower(name+".")) { - entries = append(entries, ingressEntry{ - name: name, - app: config, - }) - break - } + if slices.ContainsFunc(hosts, func(host string) bool { + return hostCoversName(host, name) + }) { + entries = append(entries, resourceEntry{ + name: name, + app: config, + }) } } if len(entries) == 0 { - k.removeIngress(key) + k.removeResource(key) return } - k.addIngressEntries(key, entries) + k.addResourceEntries(key, hosts, entries) } -func (k *KubernetesService) resyncGVR(gvr schema.GroupVersionResource, ctx context.Context) error { +func (k *KubernetesService) resyncGVR(res watchedResource, ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - list, err := k.client.Resource(gvr).List(ctx, metav1.ListOptions{}) + list, err := k.client.Resource(res.gvr).List(ctx, metav1.ListOptions{}) if err != nil { - k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to list resources for resync") + k.log.App.Warn().Err(err).Str("api", res.api()).Msg("Failed to list resources for resync") return err } for i := range list.Items { - k.updateFromItem(&list.Items[i]) + k.updateFromItem(res, &list.Items[i]) } - k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Int("count", len(list.Items)).Msg("Resync complete") + k.log.App.Debug().Str("api", res.api()).Int("count", len(list.Items)).Msg("Resync complete") return nil } // runWatcher drains events from an active watcher until it closes or the context is done. // Returns true if the caller should restart the watcher, false if it should exit. -func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.Interface, resyncTicker *time.Ticker, ctx context.Context) bool { +func (k *KubernetesService) runWatcher(res watchedResource, w watch.Interface, resyncTicker *time.Ticker, ctx context.Context) bool { for { select { case <-ctx.Done(): @@ -277,62 +493,63 @@ func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch. return false case event, ok := <-w.ResultChan(): if !ok { - k.log.App.Warn().Str("api", gvr.GroupVersion().String()).Msg("Watcher channel closed, restarting watcher") + k.log.App.Warn().Str("api", res.api()).Msg("Watcher channel closed, restarting watcher") w.Stop() time.Sleep(5 * time.Second) return true } item, ok := event.Object.(*unstructured.Unstructured) if !ok { - k.log.App.Warn().Str("api", gvr.GroupVersion().String()).Msg("Received unexpected event object, skipping") + k.log.App.Warn().Str("api", res.api()).Msg("Received unexpected event object, skipping") continue } switch event.Type { case watch.Added, watch.Modified: - k.updateFromItem(item) + k.updateFromItem(res, item) case watch.Deleted: - k.removeIngress(ingressKey{ + k.removeResource(resourceKey{ + resource: res.gvr.Resource, namespace: item.GetNamespace(), name: item.GetName(), }) } case <-resyncTicker.C: - if err := k.resyncGVR(gvr, ctx); err != nil { - k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed during watcher run") + if err := k.resyncGVR(res, ctx); err != nil { + k.log.App.Warn().Err(err).Str("api", res.api()).Msg("Periodic resync failed during watcher run") } } } } -func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource, ctx context.Context) { +func (k *KubernetesService) watchGVR(res watchedResource, ctx context.Context) { resyncTicker := time.NewTicker(5 * time.Minute) defer resyncTicker.Stop() - if err := k.resyncGVR(gvr, ctx); err != nil { - k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Initial resync failed, will retry") + if err := k.resyncGVR(res, ctx); err != nil { + k.log.App.Warn().Err(err).Str("api", res.api()).Msg("Initial resync failed, will retry") time.Sleep(30 * time.Second) } for { select { case <-ctx.Done(): - k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Shutting down kubernetes watcher") + k.log.App.Debug().Str("api", res.api()).Msg("Shutting down kubernetes watcher") return case <-resyncTicker.C: - if err := k.resyncGVR(gvr, ctx); err != nil { - k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed, will retry") + if err := k.resyncGVR(res, ctx); err != nil { + k.log.App.Warn().Err(err).Str("api", res.api()).Msg("Periodic resync failed, will retry") } default: ctx, cancel := context.WithCancel(ctx) - watcher, err := k.client.Resource(gvr).Watch(ctx, metav1.ListOptions{}) + watcher, err := k.client.Resource(res.gvr).Watch(ctx, metav1.ListOptions{}) if err != nil { - k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to start watcher, will retry") + k.log.App.Warn().Err(err).Str("api", res.api()).Msg("Failed to start watcher, will retry") cancel() time.Sleep(10 * time.Second) continue } - k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Watcher started successfully") - if !k.runWatcher(gvr, watcher, resyncTicker, ctx) { + k.log.App.Debug().Str("api", res.api()).Msg("Watcher started successfully") + if !k.runWatcher(res, watcher, resyncTicker, ctx) { cancel() return } @@ -341,13 +558,16 @@ func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource, ctx contex } } -func (k *KubernetesService) Lookup(locator func(name string, app *model.App) bool) error { +// Lookup yields the apps annotated on the resources that route domain. Apps +// annotated on any other resource are withheld, since they are served +// elsewhere and must not define the ACLs of this domain. +func (k *KubernetesService) Lookup(domain string, locator func(name string, app *model.App) bool) error { if !k.connected { k.log.App.Debug().Msg("Kubernetes label provider not started, skipping") return nil } - k.getEntry(locator) + k.getEntry(domain, locator) return nil } diff --git a/internal/service/kubernetes_service_test.go b/internal/service/kubernetes_service_test.go index d1b0b6bc..90c0accf 100644 --- a/internal/service/kubernetes_service_test.go +++ b/internal/service/kubernetes_service_test.go @@ -12,6 +12,37 @@ import ( "github.com/tinyauthapp/tinyauth/internal/utils/logger" ) +func mustWatchedResource(resource string) watchedResource { + for _, res := range watchedResources { + if res.gvr.Resource == resource { + return res + } + } + panic("unknown watched resource: " + resource) +} + +var ( + testIngressResource = mustWatchedResource("ingresses") + testHTTPRouteResource = mustWatchedResource("httproutes") + testGRPCRouteResource = mustWatchedResource("grpcroutes") +) + +// aclLocator mimics the way the access controls service matches apps, first on +// the configured domain and then on the app name. +func aclLocator(domain string, got **model.App) func(name string, app *model.App) bool { + return func(name string, app *model.App) bool { + if app.Config.Domain == domain { + *got = app + return true + } + if strings.HasPrefix(strings.ToLower(domain), strings.ToLower(name+".")) { + *got = app + return true + } + return false + } +} + func TestKubernetesService(t *testing.T) { log := logger.NewLogger().WithTestConfig() log.Init() @@ -26,10 +57,11 @@ 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.addIngressEntries(ingressKey{ + svc.addResourceEntries(resourceKey{ + resource: "ingresses", namespace: "default", name: "my-ingress", - }, []ingressEntry{ + }, []string{"foo.example.com"}, []resourceEntry{ { app: app, name: "foo", @@ -37,7 +69,7 @@ func TestKubernetesService(t *testing.T) { }) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("foo.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "foo.example.com" { got = app return true @@ -46,16 +78,26 @@ func TestKubernetesService(t *testing.T) { }) require.NotNil(t, got) assert.Equal(t, "foo.example.com", got.Config.Domain) + + got = nil + svc.getEntry("unknown.example.com", func(name string, app *model.App) bool { + got = app + return true + }) + assert.Nil(t, got) }, }, { - 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: "foo.example.com"}} - svc.addIngressEntries(ingressKey{ + key := resourceKey{ + resource: "ingresses", namespace: "default", name: "my-ingress", - }, []ingressEntry{ + } + + app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}} + svc.addResourceEntries(key, []string{"foo.example.com"}, []resourceEntry{ { app: app, name: "foo", @@ -63,7 +105,7 @@ func TestKubernetesService(t *testing.T) { }) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("foo.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "foo.example.com" { got = app return true @@ -74,12 +116,9 @@ func TestKubernetesService(t *testing.T) { assert.Equal(t, "foo.example.com", got.Config.Domain) got = nil - svc.removeIngress(ingressKey{ - namespace: "default", - name: "my-ingress", - }) + svc.removeResource(key) - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("foo.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "foo.example.com" { got = app return true @@ -90,13 +129,16 @@ func TestKubernetesService(t *testing.T) { }, }, { - description: "AddIngressApps replaces stale entries for the same ingress", + description: "AddResourceEntries 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.addIngressEntries(ingressKey{ + key := resourceKey{ + resource: "ingresses", namespace: "default", name: "my-ingress", - }, []ingressEntry{ + } + + old := model.App{Config: model.AppConfig{Domain: "old.example.com"}} + svc.addResourceEntries(key, []string{"old.example.com"}, []resourceEntry{ { app: old, name: "foo", @@ -104,10 +146,7 @@ func TestKubernetesService(t *testing.T) { }) updated := model.App{Config: model.AppConfig{Domain: "new.example.com"}} - svc.addIngressEntries(ingressKey{ - namespace: "default", - name: "my-ingress", - }, []ingressEntry{ + svc.addResourceEntries(key, []string{"new.example.com"}, []resourceEntry{ { app: updated, name: "foo", @@ -115,7 +154,7 @@ func TestKubernetesService(t *testing.T) { }) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("old.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "old.example.com" { got = app return true @@ -124,7 +163,7 @@ func TestKubernetesService(t *testing.T) { }) assert.Nil(t, got) - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("new.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "new.example.com" { got = app return true @@ -135,16 +174,66 @@ func TestKubernetesService(t *testing.T) { assert.Equal(t, "new.example.com", got.Config.Domain) }, }, + { + description: "Resources of different kinds with the same name do not clobber each other", + run: func(t *testing.T, svc *KubernetesService) { + ingress := unstructured.Unstructured{} + ingress.SetNamespace("default") + ingress.SetName("shared") + ingress.SetAnnotations(map[string]string{ + "tinyauth.apps.ingapp.config.domain": "ingapp.example.com", + }) + require.NoError(t, unstructured.SetNestedSlice(ingress.Object, []any{ + map[string]any{ + "host": "ingapp.example.com", + }, + }, "spec", "rules")) + + httpRoute := unstructured.Unstructured{} + httpRoute.SetNamespace("default") + httpRoute.SetName("shared") + httpRoute.SetAnnotations(map[string]string{ + "tinyauth.apps.gwapp.config.domain": "gwapp.example.com", + }) + require.NoError(t, unstructured.SetNestedStringSlice(httpRoute.Object, []string{ + "gwapp.example.com", + }, "spec", "hostnames")) + + svc.updateFromItem(testIngressResource, &ingress) + svc.updateFromItem(testHTTPRouteResource, &httpRoute) + + var got *model.App + svc.getEntry("ingapp.example.com", func(name string, app *model.App) bool { + if app.Config.Domain == "ingapp.example.com" { + got = app + return true + } + return false + }) + require.NotNil(t, got) + + got = nil + svc.getEntry("gwapp.example.com", func(name string, app *model.App) bool { + if app.Config.Domain == "gwapp.example.com" { + got = app + return true + } + return false + }) + require.NotNil(t, got) + }, + }, { description: "GetLabels returns app from cache when connected", run: func(t *testing.T, svc *KubernetesService) { svc.connected = true app := model.App{Config: model.AppConfig{Domain: "hit.example.com"}} - svc.addIngressEntries(ingressKey{ + svc.addResourceEntries(resourceKey{ + resource: "ingresses", namespace: "default", name: "my-ingress", - }, []ingressEntry{ + }, []string{"hit.example.com"}, []resourceEntry{ { app: app, name: "foo", @@ -152,7 +241,7 @@ func TestKubernetesService(t *testing.T) { }) var got *model.App - err := svc.Lookup(func(name string, app *model.App) bool { + err := svc.Lookup("hit.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "hit.example.com" { got = app return true @@ -170,7 +259,7 @@ func TestKubernetesService(t *testing.T) { svc.connected = true var got *model.App - err := svc.Lookup(func(name string, app *model.App) bool { + err := svc.Lookup("notfound.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "notfound.example.com" { got = app return true @@ -187,10 +276,11 @@ func TestKubernetesService(t *testing.T) { svc.connected = true app := model.App{Path: model.AppPath{Allow: "/foo"}} - svc.addIngressEntries(ingressKey{ + svc.addResourceEntries(resourceKey{ + resource: "ingresses", namespace: "default", name: "my-ingress", - }, []ingressEntry{ + }, []string{"foo.internal.example.com"}, []resourceEntry{ { app: app, name: "foo", @@ -198,13 +288,7 @@ func TestKubernetesService(t *testing.T) { }) var got *model.App - err := svc.Lookup(func(name string, app *model.App) bool { - if strings.HasPrefix("foo.internal.example.com", "foo.") { - got = app - return true - } - return false - }) + err := svc.Lookup("foo.internal.example.com", aclLocator("foo.internal.example.com", &got)) require.NoError(t, err) require.NotNil(t, got) assert.Equal(t, "/foo", got.Path.Allow) @@ -213,9 +297,138 @@ func TestKubernetesService(t *testing.T) { { description: "GetLabels returns empty app when service not yet started", run: func(t *testing.T, svc *KubernetesService) { + app := model.App{Config: model.AppConfig{Domain: "hit.example.com"}} + svc.addResourceEntries(resourceKey{ + resource: "ingresses", + namespace: "default", + name: "my-ingress", + }, []string{"hit.example.com"}, []resourceEntry{ + { + app: app, + name: "foo", + }, + }) + var got *model.App - err := svc.Lookup(func(name string, app *model.App) bool { - return false + err := svc.Lookup("hit.example.com", func(name string, app *model.App) bool { + got = app + return true + }) + require.NoError(t, err) + assert.Nil(t, got) + }, + }, + { + description: "Lookup withholds apps that are served on another host", + run: func(t *testing.T, svc *KubernetesService) { + svc.connected = true + + item := unstructured.Unstructured{} + item.SetNamespace("default") + item.SetName("test-ingress") + item.SetAnnotations(map[string]string{ + "tinyauth.apps.myapp.users.allow": "alice", + }) + require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{ + map[string]any{ + "host": "myapp.example.com", + }, + }, "spec", "rules")) + + svc.updateFromItem(testIngressResource, &item) + + // The app is served on myapp.example.com, so it must not be + // able to define the ACLs of a look-alike domain it does not + // route just because the name happens to prefix it + var got *model.App + err := svc.Lookup("myapp.evil.com", aclLocator("myapp.evil.com", &got)) + require.NoError(t, err) + assert.Nil(t, got) + + err = svc.Lookup("myapp.example.com", aclLocator("myapp.example.com", &got)) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "alice", got.Users.Allow) + }, + }, + { + description: "Lookup yields apps for any domain covered by a wildcard host", + run: func(t *testing.T, svc *KubernetesService) { + svc.connected = true + + item := unstructured.Unstructured{} + item.SetNamespace("default") + item.SetName("test-httproute") + item.SetAnnotations(map[string]string{ + "tinyauth.apps.myapp.users.allow": "alice", + }) + require.NoError(t, unstructured.SetNestedStringSlice(item.Object, []string{ + "*.example.com", + }, "spec", "hostnames")) + + svc.updateFromItem(testHTTPRouteResource, &item) + + // A wildcard is a suffix match, so nested subdomains stay + // resolvable by app name + var got *model.App + err := svc.Lookup("myapp.sub.example.com", aclLocator("myapp.sub.example.com", &got)) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "alice", got.Users.Allow) + + got = nil + err = svc.Lookup("myapp.example.net", aclLocator("myapp.example.net", &got)) + require.NoError(t, err) + assert.Nil(t, got) + }, + }, + { + description: "Lookup ignores the port of the domain", + run: func(t *testing.T, svc *KubernetesService) { + svc.connected = true + + app := model.App{Config: model.AppConfig{Domain: "myapp.example.com"}} + svc.addResourceEntries(resourceKey{ + resource: "ingresses", + namespace: "default", + name: "my-ingress", + }, []string{"myapp.example.com"}, []resourceEntry{ + { + app: app, + name: "myapp", + }, + }) + + var got *model.App + err := svc.Lookup("myapp.example.com:8443", func(name string, app *model.App) bool { + got = app + return true + }) + require.NoError(t, err) + require.NotNil(t, got) + }, + }, + { + description: "Lookup skips an invalid domain", + run: func(t *testing.T, svc *KubernetesService) { + svc.connected = true + + app := model.App{Config: model.AppConfig{Domain: "myapp.example.com"}} + svc.addResourceEntries(resourceKey{ + resource: "ingresses", + namespace: "default", + name: "my-ingress", + }, []string{"myapp.example.com"}, []resourceEntry{ + { + app: app, + name: "myapp", + }, + }) + + var got *model.App + err := svc.Lookup("not a domain", func(name string, app *model.App) bool { + got = app + return true }) require.NoError(t, err) assert.Nil(t, got) @@ -239,10 +452,10 @@ func TestKubernetesService(t *testing.T) { }, } - svc.updateFromItem(&item) + svc.updateFromItem(testIngressResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("myapp.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "myapp.example.com" { got = app return true @@ -265,10 +478,10 @@ func TestKubernetesService(t *testing.T) { "tinyauth.apps.myapp.config.domain": "myapp.example.com", }) - svc.updateFromItem(&item) + svc.updateFromItem(testIngressResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("myapp.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "myapp.example.com" { got = app return true @@ -296,10 +509,10 @@ func TestKubernetesService(t *testing.T) { }, } - svc.updateFromItem(&item) + svc.updateFromItem(testIngressResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("myapp.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "myapp.example.com" { got = app return true @@ -314,10 +527,11 @@ func TestKubernetesService(t *testing.T) { 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.addIngressEntries(ingressKey{ + svc.addResourceEntries(resourceKey{ + resource: "ingresses", namespace: "default", name: "my-ingress", - }, []ingressEntry{ + }, []string{"todelete.example.com"}, []resourceEntry{ { app: app, name: "foo", @@ -328,10 +542,10 @@ func TestKubernetesService(t *testing.T) { item.SetNamespace("default") item.SetName("my-ingress") - svc.updateFromItem(&item) + svc.updateFromItem(testIngressResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("todelete.example.com", func(name string, app *model.App) bool { if app.Config.Domain == "todelete.example.com" { got = app return true @@ -421,7 +635,7 @@ func TestKubernetesService(t *testing.T) { "not-a-map", }, "spec", "rules")) - hosts, err := svc.extractHosts(&item) + hosts, err := svc.extractHosts(testIngressResource, &item) require.NoError(t, err) assert.Equal(t, []string{"foo.example.com", "bar.example.com"}, hosts) }, @@ -443,7 +657,7 @@ func TestKubernetesService(t *testing.T) { }, }, "spec", "rules")) - hosts, err := svc.extractHosts(&item) + hosts, err := svc.extractIngressHosts(&item) require.NoError(t, err) assert.Equal(t, []string{"foo.example.com"}, hosts) }, @@ -461,7 +675,7 @@ func TestKubernetesService(t *testing.T) { }, }, "spec", "rules")) - hosts, err := svc.extractHosts(&item) + hosts, err := svc.extractIngressHosts(&item) require.NoError(t, err) assert.Equal(t, []string{"foo.example.com"}, hosts) }, @@ -473,7 +687,7 @@ func TestKubernetesService(t *testing.T) { item.SetNamespace("default") item.SetName("test-ingress") - hosts, err := svc.extractHosts(&item) + hosts, err := svc.extractIngressHosts(&item) require.NoError(t, err) assert.Empty(t, hosts) }, @@ -486,11 +700,378 @@ func TestKubernetesService(t *testing.T) { item.SetName("test-ingress") require.NoError(t, unstructured.SetNestedField(item.Object, "invalid", "spec", "rules")) - hosts, err := svc.extractHosts(&item) + hosts, err := svc.extractIngressHosts(&item) + require.Error(t, err) + assert.Nil(t, hosts) + }, + }, + { + description: "ExtractRouteHosts returns the hostnames of a route", + run: func(t *testing.T, svc *KubernetesService) { + item := unstructured.Unstructured{} + item.SetNamespace("default") + item.SetName("test-httproute") + require.NoError(t, unstructured.SetNestedStringSlice(item.Object, []string{ + "foo.example.com", + "", + "*.bar.example.com", + }, "spec", "hostnames")) + + hosts, err := svc.extractHosts(testHTTPRouteResource, &item) + require.NoError(t, err) + assert.Equal(t, []string{"foo.example.com", "*.bar.example.com"}, hosts) + }, + }, + { + description: "ExtractRouteHosts returns nothing when spec.hostnames is missing", + run: func(t *testing.T, svc *KubernetesService) { + item := unstructured.Unstructured{} + item.SetNamespace("default") + item.SetName("test-httproute") + + hosts, err := svc.extractRouteHosts(testHTTPRouteResource, &item) + require.NoError(t, err) + assert.Empty(t, hosts) + }, + }, + { + description: "ExtractRouteHosts errors when spec.hostnames is not a string slice", + run: func(t *testing.T, svc *KubernetesService) { + item := unstructured.Unstructured{} + item.SetNamespace("default") + item.SetName("test-httproute") + require.NoError(t, unstructured.SetNestedField(item.Object, "invalid", "spec", "hostnames")) + + hosts, err := svc.extractRouteHosts(testHTTPRouteResource, &item) require.Error(t, err) assert.Nil(t, hosts) }, }, + { + description: "ExtractRoutePaths treats omitted matches as a catch all", + run: func(t *testing.T, svc *KubernetesService) { + paths, catchAll, err := svc.extractRoutePaths(map[string]any{}) + require.NoError(t, err) + assert.True(t, catchAll) + assert.Empty(t, paths) + }, + }, + { + description: "ExtractRoutePaths applies the default path match", + run: func(t *testing.T, svc *KubernetesService) { + paths, catchAll, err := svc.extractRoutePaths(map[string]any{ + "matches": []any{ + map[string]any{ + "path": map[string]any{}, + }, + }, + }) + require.NoError(t, err) + assert.True(t, catchAll) + assert.Equal(t, []string{"/"}, paths) + }, + }, + { + description: "ExtractRoutePaths reports no catch all for scoped path matches", + run: func(t *testing.T, svc *KubernetesService) { + paths, catchAll, err := svc.extractRoutePaths(map[string]any{ + "matches": []any{ + map[string]any{ + "path": map[string]any{ + "type": "PathPrefix", + "value": "/api", + }, + }, + map[string]any{ + "path": map[string]any{ + "type": "Exact", + "value": "/", + }, + }, + "not-a-map", + }, + }) + require.NoError(t, err) + assert.False(t, catchAll) + assert.Equal(t, []string{"/api", "/"}, paths) + }, + }, + { + description: "ExtractRoutePaths treats a match without a path as a catch all", + run: func(t *testing.T, svc *KubernetesService) { + paths, catchAll, err := svc.extractRoutePaths(map[string]any{ + "matches": []any{ + map[string]any{ + "method": map[string]any{ + "service": "com.example.Service", + }, + }, + }, + }) + require.NoError(t, err) + assert.True(t, catchAll) + assert.Empty(t, paths) + }, + }, + { + description: "ExtractRoutePaths errors when matches is not a slice", + run: func(t *testing.T, svc *KubernetesService) { + paths, catchAll, err := svc.extractRoutePaths(map[string]any{ + "matches": "invalid", + }) + require.Error(t, err) + assert.False(t, catchAll) + assert.Nil(t, paths) + }, + }, + { + 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", + }) + require.NoError(t, unstructured.SetNestedStringSlice(item.Object, []string{ + "gwapp.example.com", + }, "spec", "hostnames")) + + svc.updateFromItem(testHTTPRouteResource, &item) + + var got *model.App + svc.getEntry("gwapp.example.com", func(name string, app *model.App) bool { + if app.Config.Domain == "gwapp.example.com" { + got = app + return true + } + return false + }) + require.NotNil(t, got) + assert.Equal(t, "gwapp.example.com", got.Config.Domain) + assert.Equal(t, "bob", got.Users.Allow) + }, + }, + { + 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", + }) + require.NoError(t, unstructured.SetNestedStringSlice(item.Object, []string{ + "grpcapp.example.com", + }, "spec", "hostnames")) + + svc.updateFromItem(testGRPCRouteResource, &item) + + var got *model.App + svc.getEntry("grpcapp.example.com", func(name string, app *model.App) bool { + if app.Config.Domain == "grpcapp.example.com" { + got = app + return true + } + return false + }) + require.NotNil(t, got) + assert.Equal(t, "grpcapp.example.com", got.Config.Domain) + assert.Equal(t, "carol", got.Users.Allow) + }, + }, + { + description: "UpdateFromItem skips routes without hostnames", + 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", + }) + + svc.updateFromItem(testHTTPRouteResource, &item) + + var got *model.App + svc.getEntry("gwapp.example.com", func(name string, app *model.App) bool { + got = app + return true + }) + assert.Nil(t, got) + }, + }, + { + description: "UpdateFromItem registers an app whose domain is covered by a wildcard hostname", + 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": "deep.gwapp.example.com", + }) + require.NoError(t, unstructured.SetNestedStringSlice(item.Object, []string{ + "*.example.com", + }, "spec", "hostnames")) + + svc.updateFromItem(testHTTPRouteResource, &item) + + var got *model.App + svc.getEntry("deep.gwapp.example.com", func(name string, app *model.App) bool { + if name == "gwapp" { + got = app + return true + } + return false + }) + require.NotNil(t, got) + assert.Equal(t, "deep.gwapp.example.com", got.Config.Domain) + }, + }, + { + description: "UpdateFromItem registers an app by name under a wildcard hostname", + 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.users.allow": "alice", + }) + require.NoError(t, unstructured.SetNestedStringSlice(item.Object, []string{ + "*.example.com", + }, "spec", "hostnames")) + + svc.updateFromItem(testHTTPRouteResource, &item) + + var got *model.App + svc.getEntry("gwapp.example.com", func(name string, app *model.App) bool { + if name == "gwapp" { + got = app + return true + } + return false + }) + require.NotNil(t, got) + assert.Equal(t, "alice", got.Users.Allow) + }, + }, + { + description: "HostMatches honours the gateway api wildcard suffix rule", + run: func(t *testing.T, svc *KubernetesService) { + assert.True(t, hostMatches("foo.example.com", "foo.example.com")) + assert.True(t, hostMatches("Foo.Example.com", "foo.example.com")) + assert.False(t, hostMatches("bar.example.com", "foo.example.com")) + + // A wildcard is a suffix match over one or more labels + assert.True(t, hostMatches("*.example.com", "foo.example.com")) + assert.True(t, hostMatches("*.example.com", "foo.test.example.com")) + assert.False(t, hostMatches("*.example.com", "example.com")) + assert.False(t, hostMatches("*.example.com", "foo.example.net")) + }, + }, + { + description: "HostCoversName matches app names against a host", + run: func(t *testing.T, svc *KubernetesService) { + assert.True(t, hostCoversName("foo.example.com", "foo")) + assert.True(t, hostCoversName("Foo.example.com", "FOO")) + assert.False(t, hostCoversName("bar.example.com", "foo")) + assert.False(t, hostCoversName("example.com", "foo")) + + // A wildcard routes . for every name + assert.True(t, hostCoversName("*.example.com", "foo")) + assert.True(t, hostCoversName("*.example.com", "bar")) + }, + }, + { + description: "UpdateFromItem registers a route that has no catch-all path", + 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", + }) + require.NoError(t, unstructured.SetNestedStringSlice(item.Object, []string{ + "gwapp.example.com", + }, "spec", "hostnames")) + require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{ + map[string]any{ + "matches": []any{ + map[string]any{ + "path": map[string]any{ + "type": "PathPrefix", + "value": "/api", + }, + }, + }, + }, + }, "spec", "rules")) + + svc.updateFromItem(testHTTPRouteResource, &item) + + var got *model.App + svc.getEntry("gwapp.example.com", func(name string, app *model.App) bool { + if name == "gwapp" { + got = app + return true + } + return false + }) + require.NotNil(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", + }) + require.NoError(t, unstructured.SetNestedSlice(ingress.Object, []any{ + map[string]any{ + "host": "ingapp.example.com", + }, + }, "spec", "rules")) + + httpRoute := unstructured.Unstructured{} + httpRoute.SetNamespace("default") + httpRoute.SetName("my-httproute") + httpRoute.SetAnnotations(map[string]string{ + "tinyauth.apps.gwapp.config.domain": "gwapp.example.com", + }) + require.NoError(t, unstructured.SetNestedStringSlice(httpRoute.Object, []string{ + "gwapp.example.com", + }, "spec", "hostnames")) + + svc.updateFromItem(testIngressResource, &ingress) + svc.updateFromItem(testHTTPRouteResource, &httpRoute) + + var got *model.App + svc.getEntry("ingapp.example.com", func(name string, app *model.App) bool { + if app.Config.Domain == "ingapp.example.com" { + got = app + return true + } + return false + }) + require.NotNil(t, got) + assert.Equal(t, "ingapp.example.com", got.Config.Domain) + + got = nil + svc.getEntry("gwapp.example.com", func(name string, app *model.App) bool { + if app.Config.Domain == "gwapp.example.com" { + got = app + return true + } + return false + }) + require.NotNil(t, got) + assert.Equal(t, "gwapp.example.com", got.Config.Domain) + }, + }, { description: "UpdateFromItem registers app when its domain matches an ingress host", run: func(t *testing.T, svc *KubernetesService) { @@ -506,10 +1087,10 @@ func TestKubernetesService(t *testing.T) { }, }, "spec", "rules")) - svc.updateFromItem(&item) + svc.updateFromItem(testIngressResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("myapp.example.com", func(name string, app *model.App) bool { if name == "myapp" { got = app return true @@ -535,10 +1116,10 @@ func TestKubernetesService(t *testing.T) { }, }, "spec", "rules")) - svc.updateFromItem(&item) + svc.updateFromItem(testIngressResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("myapp.example.com", func(name string, app *model.App) bool { if name == "myapp" { got = app return true @@ -564,10 +1145,10 @@ func TestKubernetesService(t *testing.T) { }, }, "spec", "rules")) - svc.updateFromItem(&item) + svc.updateFromItem(testIngressResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("other.example.com", func(name string, app *model.App) bool { got = app return true }) @@ -589,10 +1170,10 @@ func TestKubernetesService(t *testing.T) { }, }, "spec", "rules")) - svc.updateFromItem(&item) + svc.updateFromItem(testIngressResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("myapp.example.com", func(name string, app *model.App) bool { if name == "myapp" { got = app return true @@ -605,11 +1186,12 @@ func TestKubernetesService(t *testing.T) { { description: "UpdateFromItem removes entries when host extraction fails", run: func(t *testing.T, svc *KubernetesService) { - key := ingressKey{ + key := resourceKey{ + resource: "ingresses", namespace: "default", name: "test-ingress", } - svc.addIngressEntries(key, []ingressEntry{ + svc.addResourceEntries(key, []string{"stale.example.com"}, []resourceEntry{ { app: model.App{Config: model.AppConfig{Domain: "stale.example.com"}}, name: "foo", @@ -624,10 +1206,10 @@ func TestKubernetesService(t *testing.T) { }) require.NoError(t, unstructured.SetNestedField(item.Object, "invalid", "spec", "rules")) - svc.updateFromItem(&item) + svc.updateFromItem(testIngressResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("stale.example.com", func(name string, app *model.App) bool { got = app return true }) @@ -637,11 +1219,12 @@ func TestKubernetesService(t *testing.T) { { description: "UpdateFromItem removes entries when annotations are not decodable", run: func(t *testing.T, svc *KubernetesService) { - key := ingressKey{ + key := resourceKey{ + resource: "ingresses", namespace: "default", name: "test-ingress", } - svc.addIngressEntries(key, []ingressEntry{ + svc.addResourceEntries(key, []string{"stale.example.com"}, []resourceEntry{ { app: model.App{Config: model.AppConfig{Domain: "stale.example.com"}}, name: "foo", @@ -655,10 +1238,10 @@ func TestKubernetesService(t *testing.T) { "tinyauth.apps.myapp.config.oauthWhitelist": "[", }) - svc.updateFromItem(&item) + svc.updateFromItem(testIngressResource, &item) var got *model.App - svc.getEntry(func(name string, app *model.App) bool { + svc.getEntry("stale.example.com", func(name string, app *model.App) bool { got = app return true }) @@ -670,8 +1253,8 @@ func TestKubernetesService(t *testing.T) { for _, test := range tests { t.Run(test.description, func(t *testing.T) { svc := &KubernetesService{ - ingressEntries: make(map[ingressKey][]ingressEntry), - log: log, + resourceApps: make(map[resourceKey]routedApps), + log: log, } test.run(t, svc) })