Compare commits

...
Author SHA1 Message Date
Stavros 996316b524 chore: remove gateway extractors 2026-09-21 14:19:05 +03:00
Stavros 90e898abbf Merge branch 'main' into feat/k8s_gateways 2026-09-21 14:11:52 +03:00
Stavros 34456b94b1 chore: add missing acls service modification for domain normalization 2026-09-20 21:22:56 +03:00
StavrosandCodex 45e165f742 tests: add tests for kubernetes service and extractors
Co-authored-by: Codex <noreply@openai.com>
2026-09-20 21:21:36 +03:00
Stavros b43cf76d9b refactor: use typed objects for kubernetes 2026-09-20 21:07:06 +03:00
Stavros 5a685c4a6b Merge branch 'main' into feat/k8s_gateways 2026-09-20 18:51:23 +03:00
ContreandClaude Sonnet 4.6 e1b1e722e7 fix(acl): only let a label provider define ACLs for domains it routes
The app name fallback matches any domain that starts with the app name,
so an app named myapp served on myapp.example.com also defined the ACLs
of myapp.evil.com. Behind a proxy with a catch-all route, a request can
be authorized against the wrong app that way.

Label providers now receive the domain being authorized. The Kubernetes
provider keeps the hosts of every Ingress, HTTPRoute and GRPCRoute it
watches and withholds the apps of the resources that do not route the
domain, which bounds the name fallback to the hosts a resource actually
serves. Wildcard hostnames keep matching as a suffix, so nested
subdomains stay resolvable by app name.

Container labels carry no routing information, so the Docker provider
cannot narrow its results down and keeps yielding every app.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-19 12:18:02 +02:00
ContreandClaude Sonnet 4.6 3a216e95e2 Merge branch 'main' into feat/k8s_gateways
Reapply the Gateway API support on top of the KubernetesService rework
from main, which moved the service to ding-managed watchers and a
Lookup based LabelProvider, and started requiring an app to match a host
the resource actually routes.

Ingresses declare their hosts in spec.rules[].host while HTTPRoutes and
GRPCRoutes use spec.hostnames, so host extraction is now dispatched per
resource kind. Route hostnames may carry the Gateway API wildcard label,
which is matched as a suffix, and routes without hostnames are skipped
since the hosts of the gateway listeners they attach to cannot be
resolved from the route alone.

The cache key gains the resource kind because an Ingress and an
HTTPRoute may share a name within a namespace, and the catch-all path
warning is extended to HTTPRoute path matches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-19 12:03:13 +02:00
Contre 2769725775 feat(k8s): Support for GRPCRoute 2026-05-18 14:48:01 +02:00
Contre 419da20681 feat(k8s): Support new Gateway api 2026-05-18 11:44:45 +02:00
8 changed files with 560 additions and 843 deletions
+2
View File
@@ -28,6 +28,7 @@ require (
golang.org/x/oauth2 v0.36.0
golang.org/x/tools v0.49.0
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.37.0
k8s.io/apimachinery v0.37.0
k8s.io/client-go v0.37.0
modernc.org/sqlite v1.58.0
@@ -81,6 +82,7 @@ require (
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/gnostic-models v0.7.1 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
+2 -2
View File
@@ -168,8 +168,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+14 -8
View File
@@ -12,8 +12,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 {
@@ -42,7 +46,7 @@ func NewAccessControlsService(i AccessControlServiceInput) *AccessControlsServic
}
}
func (service *AccessControlsService) ensureAscii(str string) bool {
func ensureAscii(str string) bool {
for i := 0; i < len(str); i++ {
if str[i] > unicode.MaxASCII {
return false
@@ -51,7 +55,7 @@ func (service *AccessControlsService) ensureAscii(str string) bool {
return true
}
func (service *AccessControlsService) normalizeDomain(domain string) string {
func normalizeDomain(domain string) string {
if host, _, err := net.SplitHostPort(domain); err == nil {
domain = host
}
@@ -60,11 +64,11 @@ func (service *AccessControlsService) normalizeDomain(domain string) string {
}
func (service *AccessControlsService) getACLs(domain string, lookup func(locator func(name string, app *model.App) bool) error) (*model.App, error) {
if !service.ensureAscii(domain) {
if !ensureAscii(domain) {
return nil, errors.New("domain contains non-ascii characters")
}
normalizedDomain := service.normalizeDomain(domain)
normalizedDomain := normalizeDomain(domain)
if !strings.HasSuffix(normalizedDomain, "."+service.runtime.CookieDomain) && normalizedDomain != service.runtime.CookieDomain {
return nil, fmt.Errorf("domain does not match cookie domain, expected %s (or a subdomain), got %s", service.runtime.CookieDomain, domain)
@@ -76,11 +80,11 @@ func (service *AccessControlsService) getACLs(domain string, lookup func(locator
locatorFunc := func(name string, app *model.App) bool {
if app.Config.Domain != "" {
if !service.ensureAscii(app.Config.Domain) {
if !ensureAscii(app.Config.Domain) {
service.log.App.Warn().Str("name", name).Str("domain", app.Config.Domain).Msg("Domain contains non-ascii characters, skipping")
return false
}
if normalizedDomain == service.normalizeDomain(app.Config.Domain) {
if normalizedDomain == normalizeDomain(app.Config.Domain) {
service.log.App.Debug().Str("name", name).Msg("Found matching container by domain")
domainMatch = app
return true
@@ -145,7 +149,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
@@ -20,7 +20,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")
}
@@ -153,7 +153,9 @@ func TestAccessControlsService(t *testing.T) {
Config: &model.Config{},
LabelProvider: mock,
})
app, err := acls.getACLs(test.domain, mock.Lookup)
app, err := acls.getACLs(test.domain, func(locator func(name string, app *model.App) bool) error {
return mock.Lookup(test.domain, locator)
})
if test.errorFunc != nil {
test.errorFunc(t, err)
return
@@ -186,11 +188,14 @@ 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,
Runtime: &runtime,
Config: &model.Config{},
Log: log,
Runtime: &runtime,
Config: &model.Config{},
LabelProvider: mock,
})
_, err := acls.getACLs("example.com", func(locator func(name string, app *model.App) bool) error {
return mock.Lookup("example.com", locator)
})
_, err := acls.getACLs("example.com", mock.Lookup)
assert.Error(t, err)
// get acls should return an error when multiple apps with the same domain exist
+4 -1
View File
@@ -119,7 +119,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
@@ -0,0 +1,68 @@
package service
import (
"slices"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
networking "k8s.io/api/networking/v1"
)
type KubernetesIngressExtractor struct {
log *logger.Logger
}
type KubernetesIngressExtractorInput struct {
Log *logger.Logger
}
func NewKubernetesIngressExtractor(i KubernetesIngressExtractorInput) *KubernetesIngressExtractor {
return &KubernetesIngressExtractor{
log: i.Log,
}
}
func (k *KubernetesIngressExtractor) getPaths(rule networking.IngressRule) []string {
var paths []string
if rule.HTTP == nil {
return paths
}
for _, path := range rule.HTTP.Paths {
paths = append(paths, path.Path)
}
return paths
}
func (k *KubernetesIngressExtractor) getHosts(rules []networking.IngressRule) []string {
var hosts []string
for _, rule := range rules {
hosts = append(hosts, rule.Host)
paths := k.getPaths(rule)
if len(paths) == 0 {
continue
}
if !slices.Contains(paths, "/") {
k.log.App.Warn().Strs("hosts", hosts).Strs("paths", paths).Msg("Ingress rule does not contain a catch-all path, another ingress 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 rule to ensure auth checks are applied to all paths for this host.")
}
}
return hosts
}
func (k *KubernetesIngressExtractor) Extract(ingress *networking.Ingress) *ExtractionResult {
annotations := ingress.GetAnnotations()
hosts := k.getHosts(ingress.Spec.Rules)
return &ExtractionResult{
typ: ResourceTypeIngress,
name: ingress.GetName(),
namespace: ingress.GetNamespace(),
hosts: hosts,
annotations: annotations,
}
}
+244 -173
View File
@@ -12,23 +12,111 @@ import (
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/validators"
"go.uber.org/dig"
networking "k8s.io/api/networking/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/rest"
)
type ingressEntry struct {
type watchedResource struct {
gvr schema.GroupVersionResource
typ ResourceType
}
func (w watchedResource) pretty() string {
return w.gvr.Group + "/" + w.gvr.Version + "/" + w.gvr.Resource
}
type ResourceType string
const (
ResourceTypeIngress ResourceType = "ingress"
)
var supportedResources = []watchedResource{
{
gvr: schema.GroupVersionResource{
Group: "networking.k8s.io",
Version: "v1",
Resource: "ingresses",
},
typ: ResourceTypeIngress,
},
}
func hostMatchesHostname(host string, hostname string) bool {
host = normalizeDomain(host)
hostname = normalizeDomain(hostname)
if suffix, ok := strings.CutPrefix(host, "*."); ok {
return strings.HasSuffix(hostname, "."+suffix)
}
return host == hostname
}
func hostCoversName(host string, name string) bool {
host = strings.ToLower(host)
if strings.HasPrefix(host, "*.") {
return true
}
return strings.HasPrefix(host, strings.ToLower(name+"."))
}
type ExtractionResult struct {
typ ResourceType
name string
namespace string
hosts []string
annotations map[string]string
}
type typedItem struct {
typ ResourceType
ingress *networking.Ingress
}
func convertFromUnstructured[T any](obj *unstructured.Unstructured) (*T, error) {
var typed *T
err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &typed)
if err != nil {
var zero *T
return zero, fmt.Errorf("failed to convert ingress to typed object: %w", err)
}
return typed, nil
}
func (ti *typedItem) fromUnstructured(typ ResourceType, obj *unstructured.Unstructured) (*typedItem, error) {
switch typ {
case ResourceTypeIngress:
typed, err := convertFromUnstructured[networking.Ingress](obj)
if err != nil {
return nil, err
}
return &typedItem{
typ: ResourceTypeIngress,
ingress: typed,
}, nil
default:
return nil, fmt.Errorf("unknown resource type %s", typ)
}
}
type resourceEntry struct {
name string
app model.App
}
type ingressKey struct {
type routedApps struct {
hosts []string
entries []resourceEntry
}
type resourceKey struct {
typ ResourceType
namespace string
name string
}
@@ -36,10 +124,14 @@ type ingressKey struct {
type KubernetesService struct {
log *logger.Logger
client dynamic.Interface
connected bool
mu sync.RWMutex
ingressEntries map[ingressKey][]ingressEntry
apps map[resourceKey]routedApps
client dynamic.Interface
mu sync.RWMutex
connected bool
extractors struct {
ingress *KubernetesIngressExtractor
}
}
type KubernetesServiceInput struct {
@@ -61,32 +153,42 @@ 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",
}
accessCtx, accessCancel := context.WithTimeout(i.Ctx, 5*time.Second)
defer accessCancel()
_, 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)
}
i.Log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Successfully accessed Ingress API, starting watcher")
service := &KubernetesService{
log: i.Log,
client: client,
ingressEntries: make(map[ingressKey][]ingressEntry),
log: i.Log,
client: client,
apps: make(map[resourceKey]routedApps),
}
i.Ding.Go(func(ctx context.Context) {
service.watchGVR(gvr, ctx)
}, ding.RingMajor)
service.extractors.ingress = NewKubernetesIngressExtractor(KubernetesIngressExtractorInput{
Log: i.Log,
})
watchedGVRs := make(map[string]bool)
for _, res := range supportedResources {
ctx, cancel := context.WithTimeout(i.Ctx, 5*time.Second)
_, err := client.Resource(res.gvr).List(ctx, metav1.ListOptions{Limit: 1})
cancel()
if err != nil {
// The Gateway API CRDs are not installed on every cluster, so a
// single unreachable resource is not fatal
i.Log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Failed to access resource, skipping watcher")
continue
}
i.Log.App.Debug().Str("res", res.pretty()).Msg("Successfully accessed resource, starting watcher")
i.Ding.Go(func(ctx context.Context) {
service.watchGVR(res, ctx)
}, ding.RingMajor)
watchedGVRs[res.gvr.Resource] = true
}
if len(watchedGVRs) == 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 +196,38 @@ 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.apps[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.apps, 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) {
if !ensureAscii(domain) {
k.log.App.Debug().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 _, app := range k.apps {
if !slices.ContainsFunc(app.hosts, func(host string) bool {
return hostMatchesHostname(host, domain)
}) {
continue
}
for _, entry := range app.entries {
if ok := locator(entry.name, &entry.app); ok {
return
}
@@ -120,156 +235,106 @@ func (k *KubernetesService) getEntry(locator func(name string, app *model.App) b
}
}
func (k *KubernetesService) extractPaths(rule map[string]any) ([]string, error) {
http, found, err := unstructured.NestedMap(rule, "http")
if err != nil {
return nil, fmt.Errorf("reading http from rule: %w", err)
}
if !found {
return nil, nil
}
paths, found, err := unstructured.NestedSlice(http, "paths")
if err != nil {
return nil, fmt.Errorf("reading http.paths: %w", err)
}
if !found {
return nil, nil
}
var result []string
for _, p := range paths {
path, ok := p.(map[string]any)
if !ok {
continue
}
if p, ok := path["path"].(string); ok && p != "" {
result = append(result, p)
}
}
return result, nil
}
func (k *KubernetesService) updateFromItem(res watchedResource, typedItem *typedItem) {
var result *ExtractionResult
func (k *KubernetesService) extractHosts(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)
}
if !found {
return nil, nil
}
var hosts []string
for _, r := range rules {
rule, ok := r.(map[string]any)
if !ok {
continue
}
if host, ok := rule["host"].(string); ok && host != "" {
hosts = append(hosts, host)
}
paths, err := k.extractPaths(rule)
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 ingress rule")
continue
}
if len(paths) == 0 {
continue
}
if !slices.Contains(paths, "/") {
k.log.App.Warn().Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Strs("paths", paths).Msg("Ingress rule does not contain a catch-all path, another ingress 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 rule to ensure auth checks are applied to all paths for this host.")
}
}
k.log.App.Trace().Strs("hosts", hosts).Msg("Extracted hosts from ingress rules")
return hosts, nil
}
func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
key := ingressKey{
namespace: item.GetNamespace(),
name: item.GetName(),
}
annotations := item.GetAnnotations()
if annotations == nil {
k.removeIngress(key)
if typedItem == nil {
k.log.App.Warn().Str("res", res.pretty()).Msg("Resource is nil, skipping")
return
}
hosts, err := k.extractHosts(item)
switch typedItem.typ {
case ResourceTypeIngress:
if typedItem.ingress == nil {
k.log.App.Warn().Str("res", res.pretty()).Msg("Ingress is nil, skipping")
return
}
result = k.extractors.ingress.Extract(typedItem.ingress)
}
if result == nil {
k.log.App.Warn().Str("res", res.pretty()).Msg("Failed to extract resource, skipping")
return
}
key := resourceKey{
typ: res.typ,
namespace: result.namespace,
name: result.name,
}
if len(result.hosts) == 0 {
k.log.App.Warn().Str("res", res.pretty()).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](result.annotations, "apps")
if err != nil {
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
}
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)
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)
return
}
var entries []ingressEntry
v := validators.NewDomainValidator(validators.DomainValidatorOptions{})
var entries []resourceEntry
for name, config := range labels.Apps {
if config.Config.Domain != "" {
hostname, err := v.SafeHostname(config.Config.Domain)
if err != nil {
if !ensureAscii(config.Config.Domain) {
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{
name: name,
app: config,
})
continue
} else {
if slices.ContainsFunc(result.hosts, func(host string) bool {
return hostMatchesHostname(host, config.Config.Domain)
}) {
entries = append(entries, resourceEntry{
name: name,
app: config,
})
continue
}
}
}
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(result.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, result.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("res", res.pretty()).Msg("Failed to list resources for resync")
return err
}
for i := range list.Items {
k.updateFromItem(&list.Items[i])
for _, item := range list.Items {
newTypedItem, err := new(typedItem).fromUnstructured(res.typ, &item)
if err != nil {
k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Failed to decode resource, skipping")
continue
}
k.updateFromItem(res, newTypedItem)
}
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Int("count", len(list.Items)).Msg("Resync complete")
k.log.App.Debug().Str("res", res.pretty()).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 +342,68 @@ 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("res", res.pretty()).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("res", res.pretty()).Msg("Received unexpected event object, skipping")
continue
}
newTypedItem, err := new(typedItem).fromUnstructured(res.typ, item)
if err != nil {
k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Failed to decode resource, skipping")
continue
}
switch event.Type {
case watch.Added, watch.Modified:
k.updateFromItem(item)
k.updateFromItem(res, newTypedItem)
case watch.Deleted:
k.removeIngress(ingressKey{
k.removeResource(resourceKey{
typ: res.typ,
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("res", res.pretty()).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("res", res.pretty()).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("res", res.pretty()).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("res", res.pretty()).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("res", res.pretty()).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("res", res.pretty()).Msg("Watcher started successfully")
if !k.runWatcher(res, watcher, resyncTicker, ctx) {
cancel()
return
}
@@ -341,13 +412,13 @@ func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource, ctx contex
}
}
func (k *KubernetesService) Lookup(locator func(name string, app *model.App) bool) error {
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
}
+215 -653
View File
@@ -4,676 +4,238 @@ import (
"strings"
"testing"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
networking "k8s.io/api/networking/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
func TestKubernetesService(t *testing.T) {
func watchedResourceForTest(t *testing.T, typ ResourceType) watchedResource {
t.Helper()
for _, resource := range supportedResources {
if resource.typ == typ {
return resource
}
}
t.Fatalf("unsupported resource type %q", typ)
return watchedResource{}
}
func newKubernetesServiceForTest(log *logger.Logger) *KubernetesService {
service := &KubernetesService{
apps: make(map[resourceKey]routedApps),
log: log,
}
service.extractors.ingress = NewKubernetesIngressExtractor(KubernetesIngressExtractorInput{Log: log})
return service
}
func testIngress(name string, annotations map[string]string, hosts ...string) *typedItem {
rules := make([]networking.IngressRule, 0, len(hosts))
for _, host := range hosts {
rules = append(rules, networking.IngressRule{Host: host})
}
return &typedItem{
typ: ResourceTypeIngress,
ingress: &networking.Ingress{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default", Annotations: annotations},
Spec: networking.IngressSpec{Rules: rules},
},
}
}
func lookupApp(service *KubernetesService, domain string) *model.App {
var app *model.App
service.getEntry(domain, func(name string, candidate *model.App) bool {
if candidate.Config.Domain == domain || strings.HasPrefix(domain, name+".") {
app = candidate
return true
}
return false
})
return app
}
func TestKubernetesServiceUpdateFromItem(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
type testCase struct {
description string
run func(t *testing.T, svc *KubernetesService)
tests := []struct {
name string
resource ResourceType
item *typedItem
domain string
wantConfigDomain string
allow string
}{
{
name: "Ingress matches a configured domain",
resource: ResourceTypeIngress,
item: testIngress("ingress", map[string]string{
"tinyauth.apps.dashboard.config.domain": "dashboard.example.com",
"tinyauth.apps.dashboard.users.allow": "alice",
}, "dashboard.example.com"),
domain: "dashboard.example.com", wantConfigDomain: "dashboard.example.com", allow: "alice",
},
{
name: "Ingress matches an app name case insensitively",
resource: ResourceTypeIngress,
item: testIngress("ingress", map[string]string{
"tinyauth.apps.dashboard.users.allow": "alice",
}, "Dashboard.example.com"),
domain: "dashboard.example.com", allow: "alice",
},
}
tests := []testCase{
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
service := newKubernetesServiceForTest(log)
service.updateFromItem(watchedResourceForTest(t, test.resource), test.item)
app := lookupApp(service, test.domain)
require.NotNil(t, app)
assert.Equal(t, test.allow, app.Users.Allow)
assert.Equal(t, test.wantConfigDomain, app.Config.Domain)
})
}
}
func TestKubernetesServiceUpdateFromItemRemovesStaleEntries(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
tests := []struct {
name string
resource ResourceType
item *typedItem
}{
{"Ingress without annotations", ResourceTypeIngress, testIngress("route", nil, "app.example.com")},
{"Ingress without hosts", ResourceTypeIngress, testIngress("route", map[string]string{"tinyauth.apps.app.users.allow": "alice"})},
{"Ingress with invalid annotations", ResourceTypeIngress, testIngress("route", map[string]string{"tinyauth.apps.app.users.break": "invalid"}, "app.example.com")},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
service := newKubernetesServiceForTest(log)
key := resourceKey{typ: test.resource, namespace: "default", name: "route"}
service.addResourceEntries(key, []string{"app.example.com"}, []resourceEntry{{
name: "app",
app: model.App{Config: model.AppConfig{Domain: "app.example.com"}},
}})
service.updateFromItem(watchedResourceForTest(t, test.resource), test.item)
assert.Nil(t, lookupApp(service, "app.example.com"))
})
}
}
func TestTypedItemFromUnstructured(t *testing.T) {
tests := []struct {
name string
resource ResourceType
item unstructured.Unstructured
assert func(t *testing.T, item *typedItem)
}{
{
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{
namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
})
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "foo.example.com" {
got = app
return true
}
return false
})
require.NotNil(t, got)
assert.Equal(t, "foo.example.com", got.Config.Domain)
},
},
{
description: "RemoveIngress 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{
namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
})
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "foo.example.com" {
got = app
return true
}
return false
})
require.NotNil(t, got)
assert.Equal(t, "foo.example.com", got.Config.Domain)
got = nil
svc.removeIngress(ingressKey{
namespace: "default",
name: "my-ingress",
})
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "foo.example.com" {
got = app
return true
}
return false
})
assert.Nil(t, got)
},
},
{
description: "AddIngressApps replaces stale entries for the same ingress",
run: func(t *testing.T, svc *KubernetesService) {
old := model.App{Config: model.AppConfig{Domain: "old.example.com"}}
svc.addIngressEntries(ingressKey{
namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: old,
name: "foo",
},
})
updated := model.App{Config: model.AppConfig{Domain: "new.example.com"}}
svc.addIngressEntries(ingressKey{
namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: updated,
name: "foo",
},
})
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "old.example.com" {
got = app
return true
}
return false
})
assert.Nil(t, got)
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "new.example.com" {
got = app
return true
}
return false
})
require.NotNil(t, got)
assert.Equal(t, "new.example.com", got.Config.Domain)
},
},
{
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{
namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
})
var got *model.App
err := svc.Lookup(func(name string, app *model.App) bool {
if app.Config.Domain == "hit.example.com" {
got = app
return true
}
return false
})
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "hit.example.com", got.Config.Domain)
},
},
{
description: "GetLabels returns empty app on cache miss when started",
run: func(t *testing.T, svc *KubernetesService) {
svc.connected = true
var got *model.App
err := svc.Lookup(func(name string, app *model.App) bool {
if app.Config.Domain == "notfound.example.com" {
got = app
return true
}
return false
})
require.NoError(t, err)
require.Nil(t, got)
},
},
{
description: "GetLabels resolves app by app name",
run: func(t *testing.T, svc *KubernetesService) {
svc.connected = true
app := model.App{Path: model.AppPath{Allow: "/foo"}}
svc.addIngressEntries(ingressKey{
namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
})
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
})
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "/foo", got.Path.Allow)
},
},
{
description: "GetLabels returns empty app when service not yet started",
run: func(t *testing.T, svc *KubernetesService) {
var got *model.App
err := svc.Lookup(func(name string, app *model.App) bool {
return false
})
require.NoError(t, err)
assert.Nil(t, got)
},
},
{
description: "UpdateFromItem parses annotations and populates cache",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
"tinyauth.apps.myapp.users.allow": "alice",
})
item.Object["spec"] = map[string]any{
"rules": []any{
map[string]any{
"host": "myapp.example.com",
},
},
}
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "myapp.example.com" {
got = app
return true
}
return false
})
require.NotNil(t, got)
assert.Equal(t, "myapp.example.com", got.Config.Domain)
assert.Equal(t, "alice", got.Users.Allow)
},
},
{
description: "Update from item skips annotations with no hosts",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
})
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "myapp.example.com" {
got = app
return true
}
return false
})
assert.Nil(t, got)
},
},
{
description: "UpdateFromItem fails when label parsing fails",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
"tinyauth.apps.myapp.users.break": "i-dont-exist",
})
item.Object["spec"] = map[string]any{
"rules": []any{
map[string]any{
"host": "myapp.example.com",
},
},
}
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "myapp.example.com" {
got = app
return true
}
return false
})
require.Nil(t, got)
},
},
{
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{
namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
})
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("my-ingress")
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "todelete.example.com" {
got = app
return true
}
return false
})
assert.Nil(t, got)
},
},
{
description: "ExtractPaths returns all non empty paths from a rule",
run: func(t *testing.T, svc *KubernetesService) {
rule := map[string]any{
"http": map[string]any{
"paths": []any{
map[string]any{"path": "/"},
map[string]any{"path": "/api"},
map[string]any{"path": ""},
map[string]any{"pathType": "Prefix"},
"not-a-map",
},
},
}
paths, err := svc.extractPaths(rule)
require.NoError(t, err)
assert.Equal(t, []string{"/", "/api"}, paths)
},
},
{
description: "ExtractPaths returns nothing when http or paths are missing",
run: func(t *testing.T, svc *KubernetesService) {
paths, err := svc.extractPaths(map[string]any{})
require.NoError(t, err)
assert.Empty(t, paths)
paths, err = svc.extractPaths(map[string]any{
"http": map[string]any{},
})
require.NoError(t, err)
assert.Empty(t, paths)
},
},
{
description: "ExtractPaths errors when http is not a map",
run: func(t *testing.T, svc *KubernetesService) {
paths, err := svc.extractPaths(map[string]any{
"http": "invalid",
})
require.Error(t, err)
assert.Nil(t, paths)
},
},
{
description: "ExtractPaths errors when paths is not a slice",
run: func(t *testing.T, svc *KubernetesService) {
paths, err := svc.extractPaths(map[string]any{
"http": map[string]any{
"paths": "invalid",
},
})
require.Error(t, err)
assert.Nil(t, paths)
},
},
{
description: "ExtractHosts returns hosts from all rules",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "foo.example.com",
"http": map[string]any{
"paths": []any{
map[string]any{"path": "/"},
},
},
},
map[string]any{
"host": "bar.example.com",
},
map[string]any{
"host": "",
},
"not-a-map",
}, "spec", "rules"))
hosts, err := svc.extractHosts(&item)
require.NoError(t, err)
assert.Equal(t, []string{"foo.example.com", "bar.example.com"}, hosts)
},
},
{
description: "ExtractHosts still returns hosts when a rule has no catch all path",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "foo.example.com",
"http": map[string]any{
"paths": []any{
map[string]any{"path": "/api"},
},
},
},
}, "spec", "rules"))
hosts, err := svc.extractHosts(&item)
require.NoError(t, err)
assert.Equal(t, []string{"foo.example.com"}, hosts)
},
},
{
description: "ExtractHosts still returns hosts when path extraction fails",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "foo.example.com",
"http": "invalid",
},
}, "spec", "rules"))
hosts, err := svc.extractHosts(&item)
require.NoError(t, err)
assert.Equal(t, []string{"foo.example.com"}, hosts)
},
},
{
description: "ExtractHosts returns nothing when spec.rules is missing",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
hosts, err := svc.extractHosts(&item)
require.NoError(t, err)
assert.Empty(t, hosts)
},
},
{
description: "ExtractHosts errors when spec.rules is not a slice",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
require.NoError(t, unstructured.SetNestedField(item.Object, "invalid", "spec", "rules"))
hosts, err := svc.extractHosts(&item)
require.Error(t, err)
assert.Nil(t, hosts)
},
},
{
description: "UpdateFromItem registers app when its domain matches an ingress host",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
})
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "myapp.example.com",
},
}, "spec", "rules"))
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if name == "myapp" {
got = app
return true
}
return false
})
require.NotNil(t, got)
assert.Equal(t, "myapp.example.com", got.Config.Domain)
},
},
{
description: "UpdateFromItem registers app when its name matches an ingress host prefix",
run: func(t *testing.T, svc *KubernetesService) {
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(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if name == "myapp" {
got = app
return true
}
return false
})
require.NotNil(t, got)
assert.Equal(t, "alice", got.Users.Allow)
},
},
{
description: "UpdateFromItem skips apps that match neither host nor name",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
})
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "other.example.com",
},
}, "spec", "rules"))
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
got = app
return true
})
assert.Nil(t, got)
},
},
{
description: "UpdateFromItem falls back to app name when the domain is invalid",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "not a domain",
})
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "myapp.example.com",
},
}, "spec", "rules"))
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if name == "myapp" {
got = app
return true
}
return false
})
require.NotNil(t, got)
},
},
{
description: "UpdateFromItem removes entries when host extraction fails",
run: func(t *testing.T, svc *KubernetesService) {
key := ingressKey{
namespace: "default",
name: "test-ingress",
}
svc.addIngressEntries(key, []ingressEntry{
{
app: model.App{Config: model.AppConfig{Domain: "stale.example.com"}},
name: "foo",
},
})
item := unstructured.Unstructured{}
item.SetNamespace(key.namespace)
item.SetName(key.name)
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
})
require.NoError(t, unstructured.SetNestedField(item.Object, "invalid", "spec", "rules"))
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
got = app
return true
})
assert.Nil(t, got)
},
},
{
description: "UpdateFromItem removes entries when annotations are not decodable",
run: func(t *testing.T, svc *KubernetesService) {
key := ingressKey{
namespace: "default",
name: "test-ingress",
}
svc.addIngressEntries(key, []ingressEntry{
{
app: model.App{Config: model.AppConfig{Domain: "stale.example.com"}},
name: "foo",
},
})
item := unstructured.Unstructured{}
item.SetNamespace(key.namespace)
item.SetName(key.name)
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.oauthWhitelist": "[",
})
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
got = app
return true
})
assert.Nil(t, got)
name: "Ingress",
resource: ResourceTypeIngress,
item: unstructured.Unstructured{Object: map[string]any{
"metadata": map[string]any{"name": "ingress", "namespace": "default"},
"spec": map[string]any{"rules": []any{map[string]any{"host": "app.example.com"}}},
}},
assert: func(t *testing.T, item *typedItem) {
require.NotNil(t, item.ingress)
assert.Equal(t, "app.example.com", item.ingress.Spec.Rules[0].Host)
},
},
}
for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
svc := &KubernetesService{
ingressEntries: make(map[ingressKey][]ingressEntry),
log: log,
}
test.run(t, svc)
t.Run(test.name, func(t *testing.T) {
item, err := new(typedItem).fromUnstructured(test.resource, &test.item)
require.NoError(t, err)
assert.Equal(t, test.resource, item.typ)
test.assert(t, item)
})
}
}
func TestKubernetesServiceLookup(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
tests := []struct {
name string
connected bool
domain string
wantApp bool
}{
{"Returns a matching app when connected", true, "app.example.com", true},
{"Skips the cache before the service is connected", false, "app.example.com", false},
{"Skips an invalid domain", true, "app.example.com\xC3\xA9", false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
service := newKubernetesServiceForTest(log)
service.connected = test.connected
service.addResourceEntries(resourceKey{typ: ResourceTypeIngress, namespace: "default", name: "route"}, []string{"app.example.com"}, []resourceEntry{{
name: "app",
app: model.App{Config: model.AppConfig{Domain: "app.example.com"}},
}})
var app *model.App
err := service.Lookup(test.domain, func(_ string, candidate *model.App) bool {
app = candidate
return true
})
require.NoError(t, err)
assert.Equal(t, test.wantApp, app != nil)
})
}
}
func TestKubernetesServiceKeepsResourceTypesSeparate(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
service := newKubernetesServiceForTest(log)
resources := []struct {
resource ResourceType
item *typedItem
domain string
}{
{ResourceTypeIngress, testIngress("shared", map[string]string{"tinyauth.apps.ingress.config.domain": "ingress.example.com"}, "ingress.example.com"), "ingress.example.com"},
}
for _, resource := range resources {
service.updateFromItem(watchedResourceForTest(t, resource.resource), resource.item)
}
for _, resource := range resources {
assert.NotNil(t, lookupApp(service, resource.domain))
}
}
func TestKubernetesHostMatching(t *testing.T) {
tests := []struct {
name string
host string
domain string
want bool
}{
{"Exact host", "app.example.com", "app.example.com", true},
{"Case insensitive exact host", "App.Example.com", "app.example.com", true},
{"Wildcard host", "*.example.com", "deep.app.example.com", true},
{"Wildcard does not match its apex", "*.example.com", "example.com", false},
{"Different host", "app.example.com", "other.example.com", false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t, test.want, hostMatchesHostname(test.host, test.domain))
})
}
}