1
0
Fork 0

NGINX Ingress Provider

Co-authored-by: Kevin Pollet <pollet.kevin@gmail.com>
This commit is contained in:
Romain 2025-06-23 18:06:04 +02:00 committed by GitHub
parent b39ee8ede5
commit 9bd5c61782
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 3894 additions and 18 deletions

View file

@ -4,11 +4,12 @@ import "github.com/traefik/traefik/v3/pkg/plugins"
// Experimental experimental Traefik features.
type Experimental struct {
Plugins map[string]plugins.Descriptor `description:"Plugins configuration." json:"plugins,omitempty" toml:"plugins,omitempty" yaml:"plugins,omitempty" export:"true"`
LocalPlugins map[string]plugins.LocalDescriptor `description:"Local plugins configuration." json:"localPlugins,omitempty" toml:"localPlugins,omitempty" yaml:"localPlugins,omitempty" export:"true"`
AbortOnPluginFailure bool `description:"Defines whether all plugins must be loaded successfully for Traefik to start." json:"abortOnPluginFailure,omitempty" toml:"abortOnPluginFailure,omitempty" yaml:"abortOnPluginFailure,omitempty" export:"true"`
FastProxy *FastProxyConfig `description:"Enables the FastProxy implementation." json:"fastProxy,omitempty" toml:"fastProxy,omitempty" yaml:"fastProxy,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
OTLPLogs bool `description:"Enables the OpenTelemetry logs integration." json:"otlplogs,omitempty" toml:"otlplogs,omitempty" yaml:"otlplogs,omitempty" export:"true"`
Plugins map[string]plugins.Descriptor `description:"Plugins configuration." json:"plugins,omitempty" toml:"plugins,omitempty" yaml:"plugins,omitempty" export:"true"`
LocalPlugins map[string]plugins.LocalDescriptor `description:"Local plugins configuration." json:"localPlugins,omitempty" toml:"localPlugins,omitempty" yaml:"localPlugins,omitempty" export:"true"`
AbortOnPluginFailure bool `description:"Defines whether all plugins must be loaded successfully for Traefik to start." json:"abortOnPluginFailure,omitempty" toml:"abortOnPluginFailure,omitempty" yaml:"abortOnPluginFailure,omitempty" export:"true"`
FastProxy *FastProxyConfig `description:"Enables the FastProxy implementation." json:"fastProxy,omitempty" toml:"fastProxy,omitempty" yaml:"fastProxy,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
OTLPLogs bool `description:"Enables the OpenTelemetry logs integration." json:"otlplogs,omitempty" toml:"otlplogs,omitempty" yaml:"otlplogs,omitempty" export:"true"`
KubernetesIngressNGINX bool `description:"Allow the Kubernetes Ingress NGINX provider usage." json:"kubernetesIngressNGINX,omitempty" toml:"kubernetesIngressNGINX,omitempty" yaml:"kubernetesIngressNGINX,omitempty" export:"true"`
// Deprecated: KubernetesGateway provider is not an experimental feature starting with v3.1. Please remove its usage from the static configuration.
KubernetesGateway bool `description:"(Deprecated) Allow the Kubernetes gateway api provider usage." json:"kubernetesGateway,omitempty" toml:"kubernetesGateway,omitempty" yaml:"kubernetesGateway,omitempty" export:"true"`

View file

@ -22,6 +22,7 @@ import (
"github.com/traefik/traefik/v3/pkg/provider/kubernetes/crd"
"github.com/traefik/traefik/v3/pkg/provider/kubernetes/gateway"
"github.com/traefik/traefik/v3/pkg/provider/kubernetes/ingress"
ingressnginx "github.com/traefik/traefik/v3/pkg/provider/kubernetes/ingress-nginx"
"github.com/traefik/traefik/v3/pkg/provider/kv/consul"
"github.com/traefik/traefik/v3/pkg/provider/kv/etcd"
"github.com/traefik/traefik/v3/pkg/provider/kv/redis"
@ -233,19 +234,20 @@ type Providers struct {
Docker *docker.Provider `description:"Enable Docker backend with default settings." json:"docker,omitempty" toml:"docker,omitempty" yaml:"docker,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
Swarm *docker.SwarmProvider `description:"Enable Docker Swarm backend with default settings." json:"swarm,omitempty" toml:"swarm,omitempty" yaml:"swarm,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
File *file.Provider `description:"Enable File backend with default settings." json:"file,omitempty" toml:"file,omitempty" yaml:"file,omitempty" export:"true"`
KubernetesIngress *ingress.Provider `description:"Enable Kubernetes backend with default settings." json:"kubernetesIngress,omitempty" toml:"kubernetesIngress,omitempty" yaml:"kubernetesIngress,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
KubernetesCRD *crd.Provider `description:"Enable Kubernetes backend with default settings." json:"kubernetesCRD,omitempty" toml:"kubernetesCRD,omitempty" yaml:"kubernetesCRD,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
KubernetesGateway *gateway.Provider `description:"Enable Kubernetes gateway api provider with default settings." json:"kubernetesGateway,omitempty" toml:"kubernetesGateway,omitempty" yaml:"kubernetesGateway,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
Rest *rest.Provider `description:"Enable Rest backend with default settings." json:"rest,omitempty" toml:"rest,omitempty" yaml:"rest,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
ConsulCatalog *consulcatalog.ProviderBuilder `description:"Enable ConsulCatalog backend with default settings." json:"consulCatalog,omitempty" toml:"consulCatalog,omitempty" yaml:"consulCatalog,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
Nomad *nomad.ProviderBuilder `description:"Enable Nomad backend with default settings." json:"nomad,omitempty" toml:"nomad,omitempty" yaml:"nomad,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
Ecs *ecs.Provider `description:"Enable AWS ECS backend with default settings." json:"ecs,omitempty" toml:"ecs,omitempty" yaml:"ecs,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
Consul *consul.ProviderBuilder `description:"Enable Consul backend with default settings." json:"consul,omitempty" toml:"consul,omitempty" yaml:"consul,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
Etcd *etcd.Provider `description:"Enable Etcd backend with default settings." json:"etcd,omitempty" toml:"etcd,omitempty" yaml:"etcd,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
ZooKeeper *zk.Provider `description:"Enable ZooKeeper backend with default settings." json:"zooKeeper,omitempty" toml:"zooKeeper,omitempty" yaml:"zooKeeper,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
Redis *redis.Provider `description:"Enable Redis backend with default settings." json:"redis,omitempty" toml:"redis,omitempty" yaml:"redis,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
HTTP *http.Provider `description:"Enable HTTP backend with default settings." json:"http,omitempty" toml:"http,omitempty" yaml:"http,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
File *file.Provider `description:"Enable File backend with default settings." json:"file,omitempty" toml:"file,omitempty" yaml:"file,omitempty" export:"true"`
KubernetesIngress *ingress.Provider `description:"Enable Kubernetes backend with default settings." json:"kubernetesIngress,omitempty" toml:"kubernetesIngress,omitempty" yaml:"kubernetesIngress,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
KubernetesIngressNGINX *ingressnginx.Provider `description:"Enable Kubernetes Ingress NGINX provider." json:"kubernetesIngressNGINX,omitempty" toml:"kubernetesIngressNGINX,omitempty" yaml:"kubernetesIngressNGINX,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
KubernetesCRD *crd.Provider `description:"Enable Kubernetes backend with default settings." json:"kubernetesCRD,omitempty" toml:"kubernetesCRD,omitempty" yaml:"kubernetesCRD,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
KubernetesGateway *gateway.Provider `description:"Enable Kubernetes gateway api provider with default settings." json:"kubernetesGateway,omitempty" toml:"kubernetesGateway,omitempty" yaml:"kubernetesGateway,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
Rest *rest.Provider `description:"Enable Rest backend with default settings." json:"rest,omitempty" toml:"rest,omitempty" yaml:"rest,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
ConsulCatalog *consulcatalog.ProviderBuilder `description:"Enable ConsulCatalog backend with default settings." json:"consulCatalog,omitempty" toml:"consulCatalog,omitempty" yaml:"consulCatalog,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
Nomad *nomad.ProviderBuilder `description:"Enable Nomad backend with default settings." json:"nomad,omitempty" toml:"nomad,omitempty" yaml:"nomad,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
Ecs *ecs.Provider `description:"Enable AWS ECS backend with default settings." json:"ecs,omitempty" toml:"ecs,omitempty" yaml:"ecs,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
Consul *consul.ProviderBuilder `description:"Enable Consul backend with default settings." json:"consul,omitempty" toml:"consul,omitempty" yaml:"consul,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
Etcd *etcd.Provider `description:"Enable Etcd backend with default settings." json:"etcd,omitempty" toml:"etcd,omitempty" yaml:"etcd,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
ZooKeeper *zk.Provider `description:"Enable ZooKeeper backend with default settings." json:"zooKeeper,omitempty" toml:"zooKeeper,omitempty" yaml:"zooKeeper,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
Redis *redis.Provider `description:"Enable Redis backend with default settings." json:"redis,omitempty" toml:"redis,omitempty" yaml:"redis,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
HTTP *http.Provider `description:"Enable HTTP backend with default settings." json:"http,omitempty" toml:"http,omitempty" yaml:"http,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
Plugin map[string]PluginConf `description:"Plugins configuration." json:"plugin,omitempty" toml:"plugin,omitempty" yaml:"plugin,omitempty"`
}
@ -391,6 +393,16 @@ func (c *Configuration) ValidateConfiguration() error {
}
}
if c.Providers != nil && c.Providers.KubernetesIngressNGINX != nil {
if c.Experimental == nil || !c.Experimental.KubernetesIngressNGINX {
return errors.New("the experimental KubernetesIngressNGINX feature must be enabled to use the KubernetesIngressNGINX provider")
}
if c.Providers.KubernetesIngressNGINX.WatchNamespace != "" && c.Providers.KubernetesIngressNGINX.WatchNamespaceSelector != "" {
return errors.New("watchNamespace and watchNamespaceSelector options are mutually exclusive")
}
}
if c.AccessLog != nil && c.AccessLog.OTLP != nil {
if c.Experimental == nil || !c.Experimental.OTLPLogs {
return errors.New("the experimental OTLPLogs feature must be enabled to use OTLP access logging")

View file

@ -92,6 +92,10 @@ func NewProviderAggregator(conf static.Providers) *ProviderAggregator {
p.quietAddProvider(conf.KubernetesIngress)
}
if conf.KubernetesIngressNGINX != nil {
p.quietAddProvider(conf.KubernetesIngressNGINX)
}
if conf.KubernetesCRD != nil {
p.quietAddProvider(conf.KubernetesCRD)
}

View file

@ -0,0 +1,115 @@
package ingressnginx
import (
"errors"
"reflect"
"strconv"
"strings"
netv1 "k8s.io/api/networking/v1"
)
type ingressConfig struct {
AuthType *string `annotation:"nginx.ingress.kubernetes.io/auth-type"`
AuthSecret *string `annotation:"nginx.ingress.kubernetes.io/auth-secret"`
AuthRealm *string `annotation:"nginx.ingress.kubernetes.io/auth-realm"`
AuthSecretType *string `annotation:"nginx.ingress.kubernetes.io/auth-secret-type"`
AuthURL *string `annotation:"nginx.ingress.kubernetes.io/auth-url"`
AuthResponseHeaders *string `annotation:"nginx.ingress.kubernetes.io/auth-response-headers"`
ForceSSLRedirect *bool `annotation:"nginx.ingress.kubernetes.io/force-ssl-redirect"`
SSLRedirect *bool `annotation:"nginx.ingress.kubernetes.io/ssl-redirect"`
SSLPassthrough *bool `annotation:"nginx.ingress.kubernetes.io/ssl-passthrough"`
UseRegex *bool `annotation:"nginx.ingress.kubernetes.io/use-regex"`
Affinity *string `annotation:"nginx.ingress.kubernetes.io/affinity"`
SessionCookieName *string `annotation:"nginx.ingress.kubernetes.io/session-cookie-name"`
SessionCookieSecure *bool `annotation:"nginx.ingress.kubernetes.io/session-cookie-secure"`
SessionCookiePath *string `annotation:"nginx.ingress.kubernetes.io/session-cookie-path"`
SessionCookieDomain *string `annotation:"nginx.ingress.kubernetes.io/session-cookie-domain"`
SessionCookieSameSite *string `annotation:"nginx.ingress.kubernetes.io/session-cookie-samesite"`
SessionCookieMaxAge *int `annotation:"nginx.ingress.kubernetes.io/session-cookie-max-age"`
ServiceUpstream *bool `annotation:"nginx.ingress.kubernetes.io/service-upstream"`
BackendProtocol *string `annotation:"nginx.ingress.kubernetes.io/backend-protocol"`
ProxySSLSecret *string `annotation:"nginx.ingress.kubernetes.io/proxy-ssl-secret"`
ProxySSLVerify *string `annotation:"nginx.ingress.kubernetes.io/proxy-ssl-verify"`
ProxySSLName *string `annotation:"nginx.ingress.kubernetes.io/proxy-ssl-name"`
ProxySSLServerName *string `annotation:"nginx.ingress.kubernetes.io/proxy-ssl-server-name"`
EnableCORS *bool `annotation:"nginx.ingress.kubernetes.io/enable-cors"`
EnableCORSAllowCredentials *bool `annotation:"nginx.ingress.kubernetes.io/cors-allow-credentials"`
CORSExposeHeaders *[]string `annotation:"nginx.ingress.kubernetes.io/cors-expose-headers"`
CORSAllowHeaders *[]string `annotation:"nginx.ingress.kubernetes.io/cors-allow-headers"`
CORSAllowMethods *[]string `annotation:"nginx.ingress.kubernetes.io/cors-allow-methods"`
CORSAllowOrigin *[]string `annotation:"nginx.ingress.kubernetes.io/cors-allow-origin"`
CORSMaxAge *int `annotation:"nginx.ingress.kubernetes.io/cors-max-age"`
}
// parseIngressConfig parses the annotations from an Ingress object into an ingressConfig struct.
func parseIngressConfig(ing *netv1.Ingress) (ingressConfig, error) {
cfg := ingressConfig{}
cfgType := reflect.TypeOf(cfg)
cfgValue := reflect.ValueOf(&cfg).Elem()
for i := range cfgType.NumField() {
field := cfgType.Field(i)
annotation := field.Tag.Get("annotation")
if annotation == "" {
continue
}
val, ok := ing.GetAnnotations()[annotation]
if !ok {
continue
}
switch field.Type.Elem().Kind() {
case reflect.String:
cfgValue.Field(i).Set(reflect.ValueOf(&val))
case reflect.Bool:
parsed, err := strconv.ParseBool(val)
if err == nil {
cfgValue.Field(i).Set(reflect.ValueOf(&parsed))
}
case reflect.Int:
parsed, err := strconv.Atoi(val)
if err == nil {
cfgValue.Field(i).Set(reflect.ValueOf(&parsed))
}
case reflect.Slice:
if field.Type.Elem().Elem().Kind() == reflect.String {
// Handle slice of strings
var slice []string
elements := strings.Split(val, ",")
for _, elt := range elements {
slice = append(slice, strings.TrimSpace(elt))
}
cfgValue.Field(i).Set(reflect.ValueOf(&slice))
} else {
return cfg, errors.New("unsupported slice type in annotations")
}
default:
return cfg, errors.New("unsupported kind")
}
}
return cfg, nil
}
// parseBackendProtocol parses the backend protocol annotation and returns the corresponding protocol string.
func parseBackendProtocol(bp string) string {
switch strings.ToUpper(bp) {
case "HTTPS", "GRPCS":
return "https"
case "GRPC":
return "h2c"
default:
return "http"
}
}

View file

@ -0,0 +1,76 @@
package ingressnginx
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
netv1 "k8s.io/api/networking/v1"
"k8s.io/utils/ptr"
)
func Test_parseIngressConfig(t *testing.T) {
tests := []struct {
desc string
annotations map[string]string
expected ingressConfig
}{
{
desc: "all fields set",
annotations: map[string]string{
"nginx.ingress.kubernetes.io/ssl-passthrough": "true",
"nginx.ingress.kubernetes.io/affinity": "cookie",
"nginx.ingress.kubernetes.io/session-cookie-name": "mycookie",
"nginx.ingress.kubernetes.io/session-cookie-secure": "true",
"nginx.ingress.kubernetes.io/session-cookie-path": "/foo",
"nginx.ingress.kubernetes.io/session-cookie-domain": "example.com",
"nginx.ingress.kubernetes.io/session-cookie-samesite": "Strict",
"nginx.ingress.kubernetes.io/session-cookie-max-age": "3600",
"nginx.ingress.kubernetes.io/backend-protocol": "HTTPS",
"nginx.ingress.kubernetes.io/cors-expose-headers": "foo, bar",
},
expected: ingressConfig{
SSLPassthrough: ptr.To(true),
Affinity: ptr.To("cookie"),
SessionCookieName: ptr.To("mycookie"),
SessionCookieSecure: ptr.To(true),
SessionCookiePath: ptr.To("/foo"),
SessionCookieDomain: ptr.To("example.com"),
SessionCookieSameSite: ptr.To("Strict"),
SessionCookieMaxAge: ptr.To(3600),
BackendProtocol: ptr.To("HTTPS"),
CORSExposeHeaders: ptr.To([]string{"foo", "bar"}),
},
},
{
desc: "missing fields",
annotations: map[string]string{
"nginx.ingress.kubernetes.io/ssl-passthrough": "false",
},
expected: ingressConfig{
SSLPassthrough: ptr.To(false),
},
},
{
desc: "invalid bool and int",
annotations: map[string]string{
"nginx.ingress.kubernetes.io/ssl-passthrough": "notabool",
"nginx.ingress.kubernetes.io/session-cookie-max-age (in seconds)": "notanint",
},
},
}
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
var ing netv1.Ingress
ing.SetAnnotations(test.annotations)
cfg, err := parseIngressConfig(&ing)
require.NoError(t, err)
assert.Equal(t, test.expected, cfg)
})
}
}

View file

@ -0,0 +1,384 @@
package ingressnginx
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"slices"
"time"
"github.com/rs/zerolog/log"
"github.com/traefik/traefik/v3/pkg/provider/kubernetes/k8s"
"github.com/traefik/traefik/v3/pkg/types"
traefikversion "github.com/traefik/traefik/v3/pkg/version"
corev1 "k8s.io/api/core/v1"
discoveryv1 "k8s.io/api/discovery/v1"
netv1 "k8s.io/api/networking/v1"
kerror "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/selection"
kinformers "k8s.io/client-go/informers"
kclientset "k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
)
const (
resyncPeriod = 10 * time.Minute
defaultTimeout = 5 * time.Second
)
type clientWrapper struct {
clientset kclientset.Interface
clusterScopeFactory kinformers.SharedInformerFactory
factoriesKube map[string]kinformers.SharedInformerFactory
factoriesSecret map[string]kinformers.SharedInformerFactory
factoriesIngress map[string]kinformers.SharedInformerFactory
isNamespaceAll bool
watchedNamespaces []string
ignoreIngressClasses bool
}
// newInClusterClient returns a new Provider client that is expected to run
// inside the cluster.
func newInClusterClient(endpoint string) (*clientWrapper, error) {
config, err := rest.InClusterConfig()
if err != nil {
return nil, fmt.Errorf("failed to create in-cluster configuration: %w", err)
}
if endpoint != "" {
config.Host = endpoint
}
return createClientFromConfig(config)
}
func newExternalClusterClientFromFile(file string) (*clientWrapper, error) {
configFromFlags, err := clientcmd.BuildConfigFromFlags("", file)
if err != nil {
return nil, err
}
return createClientFromConfig(configFromFlags)
}
// newExternalClusterClient returns a new Provider client that may run outside
// of the cluster.
// The endpoint parameter must not be empty.
func newExternalClusterClient(endpoint, caFilePath string, token types.FileOrContent) (*clientWrapper, error) {
if endpoint == "" {
return nil, errors.New("endpoint missing for external cluster client")
}
tokenData, err := token.Read()
if err != nil {
return nil, fmt.Errorf("read token: %w", err)
}
config := &rest.Config{
Host: endpoint,
BearerToken: string(tokenData),
}
if caFilePath != "" {
caData, err := os.ReadFile(caFilePath)
if err != nil {
return nil, fmt.Errorf("failed to read CA file %s: %w", caFilePath, err)
}
config.TLSClientConfig = rest.TLSClientConfig{CAData: caData}
}
return createClientFromConfig(config)
}
func createClientFromConfig(c *rest.Config) (*clientWrapper, error) {
c.UserAgent = fmt.Sprintf(
"%s/%s (%s/%s) kubernetes/ingress",
filepath.Base(os.Args[0]),
traefikversion.Version,
runtime.GOOS,
runtime.GOARCH,
)
clientset, err := kclientset.NewForConfig(c)
if err != nil {
return nil, err
}
return newClient(clientset), nil
}
func newClient(clientSet kclientset.Interface) *clientWrapper {
return &clientWrapper{
clientset: clientSet,
factoriesSecret: make(map[string]kinformers.SharedInformerFactory),
factoriesIngress: make(map[string]kinformers.SharedInformerFactory),
factoriesKube: make(map[string]kinformers.SharedInformerFactory),
}
}
// WatchAll starts namespace-specific controllers for all relevant kinds.
func (c *clientWrapper) WatchAll(ctx context.Context, namespace, namespaceSelector string) (<-chan interface{}, error) {
stopCh := ctx.Done()
eventCh := make(chan interface{}, 1)
eventHandler := &k8s.ResourceEventHandler{Ev: eventCh}
c.ignoreIngressClasses = false
_, err := c.clientset.NetworkingV1().IngressClasses().List(ctx, metav1.ListOptions{Limit: 1})
if err != nil {
if !kerror.IsNotFound(err) {
if kerror.IsForbidden(err) {
c.ignoreIngressClasses = true
}
}
}
if namespaceSelector != "" {
ns, err := c.clientset.CoreV1().Namespaces().List(ctx, metav1.ListOptions{LabelSelector: namespaceSelector})
if err != nil {
return nil, fmt.Errorf("listing namespaces: %w", err)
}
for _, item := range ns.Items {
c.watchedNamespaces = append(c.watchedNamespaces, item.Name)
}
} else {
c.isNamespaceAll = namespace == metav1.NamespaceAll
c.watchedNamespaces = []string{namespace}
}
notOwnedByHelm := func(opts *metav1.ListOptions) {
opts.LabelSelector = "owner!=helm"
}
for _, ns := range c.watchedNamespaces {
factoryIngress := kinformers.NewSharedInformerFactoryWithOptions(c.clientset, resyncPeriod, kinformers.WithNamespace(ns))
_, err := factoryIngress.Networking().V1().Ingresses().Informer().AddEventHandler(eventHandler)
if err != nil {
return nil, err
}
c.factoriesIngress[ns] = factoryIngress
factoryKube := kinformers.NewSharedInformerFactoryWithOptions(c.clientset, resyncPeriod, kinformers.WithNamespace(ns))
_, err = factoryKube.Core().V1().Services().Informer().AddEventHandler(eventHandler)
if err != nil {
return nil, err
}
_, err = factoryKube.Discovery().V1().EndpointSlices().Informer().AddEventHandler(eventHandler)
if err != nil {
return nil, err
}
c.factoriesKube[ns] = factoryKube
factorySecret := kinformers.NewSharedInformerFactoryWithOptions(c.clientset, resyncPeriod, kinformers.WithNamespace(ns), kinformers.WithTweakListOptions(notOwnedByHelm))
_, err = factorySecret.Core().V1().Secrets().Informer().AddEventHandler(eventHandler)
if err != nil {
return nil, err
}
c.factoriesSecret[ns] = factorySecret
}
for _, ns := range c.watchedNamespaces {
c.factoriesIngress[ns].Start(stopCh)
c.factoriesKube[ns].Start(stopCh)
c.factoriesSecret[ns].Start(stopCh)
}
for _, ns := range c.watchedNamespaces {
for t, ok := range c.factoriesIngress[ns].WaitForCacheSync(stopCh) {
if !ok {
return nil, fmt.Errorf("timed out waiting for controller caches to sync %s in namespace %q", t.String(), ns)
}
}
for t, ok := range c.factoriesKube[ns].WaitForCacheSync(stopCh) {
if !ok {
return nil, fmt.Errorf("timed out waiting for controller caches to sync %s in namespace %q", t.String(), ns)
}
}
for t, ok := range c.factoriesSecret[ns].WaitForCacheSync(stopCh) {
if !ok {
return nil, fmt.Errorf("timed out waiting for controller caches to sync %s in namespace %q", t.String(), ns)
}
}
}
c.clusterScopeFactory = kinformers.NewSharedInformerFactory(c.clientset, resyncPeriod)
if !c.ignoreIngressClasses {
_, err = c.clusterScopeFactory.Networking().V1().IngressClasses().Informer().AddEventHandler(eventHandler)
if err != nil {
return nil, err
}
}
c.clusterScopeFactory.Start(stopCh)
for t, ok := range c.clusterScopeFactory.WaitForCacheSync(stopCh) {
if !ok {
return nil, fmt.Errorf("timed out waiting for controller caches to sync %s", t.String())
}
}
return eventCh, nil
}
func (c *clientWrapper) ListIngressClasses() ([]*netv1.IngressClass, error) {
if c.ignoreIngressClasses {
return []*netv1.IngressClass{}, nil
}
return c.clusterScopeFactory.Networking().V1().IngressClasses().Lister().List(labels.Everything())
}
// ListIngresses returns all Ingresses for observed namespaces in the cluster.
func (c *clientWrapper) ListIngresses() []*netv1.Ingress {
var results []*netv1.Ingress
for ns, factory := range c.factoriesIngress {
// networking
listNew, err := factory.Networking().V1().Ingresses().Lister().List(labels.Everything())
if err != nil {
log.Error().Err(err).Msgf("Failed to list ingresses in namespace %s", ns)
continue
}
results = append(results, listNew...)
}
return results
}
// UpdateIngressStatus updates an Ingress with a provided status.
func (c *clientWrapper) UpdateIngressStatus(src *netv1.Ingress, ingStatus []netv1.IngressLoadBalancerIngress) error {
if !c.isWatchedNamespace(src.Namespace) {
return fmt.Errorf("failed to get ingress %s/%s: namespace is not within watched namespaces", src.Namespace, src.Name)
}
ing, err := c.factoriesIngress[c.lookupNamespace(src.Namespace)].Networking().V1().Ingresses().Lister().Ingresses(src.Namespace).Get(src.Name)
if err != nil {
return fmt.Errorf("failed to get ingress %s/%s: %w", src.Namespace, src.Name, err)
}
logger := log.With().Str("namespace", ing.Namespace).Str("ingress", ing.Name).Logger()
if isLoadBalancerIngressEquals(ing.Status.LoadBalancer.Ingress, ingStatus) {
logger.Debug().Msg("Skipping ingress status update")
return nil
}
ingCopy := ing.DeepCopy()
ingCopy.Status = netv1.IngressStatus{LoadBalancer: netv1.IngressLoadBalancerStatus{Ingress: ingStatus}}
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
_, err = c.clientset.NetworkingV1().Ingresses(ingCopy.Namespace).UpdateStatus(ctx, ingCopy, metav1.UpdateOptions{})
if err != nil {
return fmt.Errorf("failed to update ingress status %s/%s: %w", src.Namespace, src.Name, err)
}
logger.Info().Msg("Updated ingress status")
return nil
}
// GetService returns the named service from the given namespace.
func (c *clientWrapper) GetService(namespace, name string) (*corev1.Service, error) {
if !c.isWatchedNamespace(namespace) {
return nil, fmt.Errorf("failed to get service %s/%s: namespace is not within watched namespaces", namespace, name)
}
return c.factoriesKube[c.lookupNamespace(namespace)].Core().V1().Services().Lister().Services(namespace).Get(name)
}
// GetEndpointSlicesForService returns the EndpointSlices for the given service name in the given namespace.
func (c *clientWrapper) GetEndpointSlicesForService(namespace, serviceName string) ([]*discoveryv1.EndpointSlice, error) {
if !c.isWatchedNamespace(namespace) {
return nil, fmt.Errorf("failed to get endpointslices for service %s/%s: namespace is not within watched namespaces", namespace, serviceName)
}
serviceLabelRequirement, err := labels.NewRequirement(discoveryv1.LabelServiceName, selection.Equals, []string{serviceName})
if err != nil {
return nil, fmt.Errorf("failed to create service label selector requirement: %w", err)
}
serviceSelector := labels.NewSelector()
serviceSelector = serviceSelector.Add(*serviceLabelRequirement)
return c.factoriesKube[c.lookupNamespace(namespace)].Discovery().V1().EndpointSlices().Lister().EndpointSlices(namespace).List(serviceSelector)
}
// GetSecret returns the named secret from the given namespace.
func (c *clientWrapper) GetSecret(namespace, name string) (*corev1.Secret, error) {
if !c.isWatchedNamespace(namespace) {
return nil, fmt.Errorf("failed to get secret %s/%s: namespace is not within watched namespaces", namespace, name)
}
return c.factoriesSecret[c.lookupNamespace(namespace)].Core().V1().Secrets().Lister().Secrets(namespace).Get(name)
}
// lookupNamespace returns the lookup namespace key for the given namespace.
// When listening on all namespaces, it returns the client-go identifier ("")
// for all-namespaces. Otherwise, it returns the given namespace.
// The distinction is necessary because we index all informers on the special
// identifier iff all-namespaces are requested but receive specific namespace
// identifiers from the Kubernetes API, so we have to bridge this gap.
func (c *clientWrapper) lookupNamespace(ns string) string {
if c.isNamespaceAll {
return metav1.NamespaceAll
}
return ns
}
// isWatchedNamespace checks to ensure that the namespace is being watched before we request
// it to ensure we don't panic by requesting an out-of-watch object.
func (c *clientWrapper) isWatchedNamespace(ns string) bool {
if c.isNamespaceAll {
return true
}
return slices.Contains(c.watchedNamespaces, ns)
}
// isLoadBalancerIngressEquals returns true if the given slices are equal, false otherwise.
func isLoadBalancerIngressEquals(aSlice, bSlice []netv1.IngressLoadBalancerIngress) bool {
if len(aSlice) != len(bSlice) {
return false
}
aMap := make(map[string]struct{})
for _, aIngress := range aSlice {
aMap[aIngress.Hostname+aIngress.IP] = struct{}{}
}
for _, bIngress := range bSlice {
if _, exists := aMap[bIngress.Hostname+bIngress.IP]; !exists {
return false
}
}
return true
}
// filterIngressClass return a slice containing IngressClass matching either the annotation name or the controller.
func filterIngressClass(ingressClasses []*netv1.IngressClass, ingressClassByName bool, ingressClass, controllerClass string) []*netv1.IngressClass {
var filteredIngressClasses []*netv1.IngressClass
for _, ic := range ingressClasses {
if ingressClassByName && ic.Name == ingressClass {
return append(filteredIngressClasses, ic)
}
if ic.Spec.Controller == controllerClass {
filteredIngressClasses = append(filteredIngressClasses, ic)
continue
}
}
return filteredIngressClasses
}

View file

@ -0,0 +1,270 @@
package ingressnginx
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
discoveryv1 "k8s.io/api/discovery/v1"
netv1 "k8s.io/api/networking/v1"
kerror "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kversion "k8s.io/apimachinery/pkg/version"
discoveryfake "k8s.io/client-go/discovery/fake"
kubefake "k8s.io/client-go/kubernetes/fake"
)
func TestIsLoadBalancerIngressEquals(t *testing.T) {
testCases := []struct {
desc string
aSlice []netv1.IngressLoadBalancerIngress
bSlice []netv1.IngressLoadBalancerIngress
expectedEqual bool
}{
{
desc: "both slices are empty",
expectedEqual: true,
},
{
desc: "not the same length",
bSlice: []netv1.IngressLoadBalancerIngress{
{IP: "192.168.1.1", Hostname: "traefik"},
},
expectedEqual: false,
},
{
desc: "same ordered content",
aSlice: []netv1.IngressLoadBalancerIngress{
{IP: "192.168.1.1", Hostname: "traefik"},
},
bSlice: []netv1.IngressLoadBalancerIngress{
{IP: "192.168.1.1", Hostname: "traefik"},
},
expectedEqual: true,
},
{
desc: "same unordered content",
aSlice: []netv1.IngressLoadBalancerIngress{
{IP: "192.168.1.1", Hostname: "traefik"},
{IP: "192.168.1.2", Hostname: "traefik2"},
},
bSlice: []netv1.IngressLoadBalancerIngress{
{IP: "192.168.1.2", Hostname: "traefik2"},
{IP: "192.168.1.1", Hostname: "traefik"},
},
expectedEqual: true,
},
{
desc: "different ordered content",
aSlice: []netv1.IngressLoadBalancerIngress{
{IP: "192.168.1.1", Hostname: "traefik"},
{IP: "192.168.1.2", Hostname: "traefik2"},
},
bSlice: []netv1.IngressLoadBalancerIngress{
{IP: "192.168.1.1", Hostname: "traefik"},
{IP: "192.168.1.2", Hostname: "traefik"},
},
expectedEqual: false,
},
{
desc: "different unordered content",
aSlice: []netv1.IngressLoadBalancerIngress{
{IP: "192.168.1.1", Hostname: "traefik"},
{IP: "192.168.1.2", Hostname: "traefik2"},
},
bSlice: []netv1.IngressLoadBalancerIngress{
{IP: "192.168.1.2", Hostname: "traefik3"},
{IP: "192.168.1.1", Hostname: "traefik"},
},
expectedEqual: false,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
gotEqual := isLoadBalancerIngressEquals(test.aSlice, test.bSlice)
assert.Equal(t, test.expectedEqual, gotEqual)
})
}
}
func TestClientIgnoresHelmOwnedSecrets(t *testing.T) {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: "default",
Name: "secret",
},
}
helmSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: "default",
Name: "helm-secret",
Labels: map[string]string{
"owner": "helm",
},
},
}
kubeClient := kubefake.NewClientset(helmSecret, secret)
discovery, _ := kubeClient.Discovery().(*discoveryfake.FakeDiscovery)
discovery.FakedServerVersion = &kversion.Info{
GitVersion: "v1.19",
}
client := newClient(kubeClient)
eventCh, err := client.WatchAll(t.Context(), "", "")
require.NoError(t, err)
select {
case event := <-eventCh:
secret, ok := event.(*corev1.Secret)
require.True(t, ok)
assert.NotEqual(t, "helm-secret", secret.Name)
case <-time.After(50 * time.Millisecond):
assert.Fail(t, "expected to receive event for secret")
}
select {
case <-eventCh:
assert.Fail(t, "received more than one event")
case <-time.After(50 * time.Millisecond):
}
_, err = client.GetSecret("default", "secret")
require.NoError(t, err)
_, err = client.GetSecret("default", "helm-secret")
assert.True(t, kerror.IsNotFound(err))
}
func TestClientIgnoresEmptyEndpointSliceUpdates(t *testing.T) {
emptyEndpointSlice := &discoveryv1.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{
Name: "empty-endpointslice",
Namespace: "test",
ResourceVersion: "1244",
Annotations: map[string]string{
"test-annotation": "_",
},
},
}
samplePortName := "testing"
samplePortNumber := int32(1337)
samplePortProtocol := corev1.ProtocolTCP
sampleAddressReady := true
filledEndpointSlice := &discoveryv1.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{
Name: "filled-endpointslice",
Namespace: "test",
ResourceVersion: "1234",
},
AddressType: discoveryv1.AddressTypeIPv4,
Endpoints: []discoveryv1.Endpoint{{
Addresses: []string{"10.13.37.1"},
Conditions: discoveryv1.EndpointConditions{
Ready: &sampleAddressReady,
},
}},
Ports: []discoveryv1.EndpointPort{{
Name: &samplePortName,
Port: &samplePortNumber,
Protocol: &samplePortProtocol,
}},
}
kubeClient := kubefake.NewClientset(emptyEndpointSlice, filledEndpointSlice)
discovery, _ := kubeClient.Discovery().(*discoveryfake.FakeDiscovery)
discovery.FakedServerVersion = &kversion.Info{
GitVersion: "v1.19",
}
client := newClient(kubeClient)
eventCh, err := client.WatchAll(t.Context(), "", "")
require.NoError(t, err)
select {
case event := <-eventCh:
ep, ok := event.(*discoveryv1.EndpointSlice)
require.True(t, ok)
assert.True(t, ep.Name == "empty-endpointslice" || ep.Name == "filled-endpointslice")
case <-time.After(50 * time.Millisecond):
assert.Fail(t, "expected to receive event for endpointslices")
}
emptyEndpointSlice, err = kubeClient.DiscoveryV1().EndpointSlices("test").Get(t.Context(), "empty-endpointslice", metav1.GetOptions{})
assert.NoError(t, err)
// Update endpoint annotation and resource version (apparently not done by fake client itself)
// to show an update that should not trigger an update event on our eventCh.
// This reflects the behavior of kubernetes controllers which use endpoint annotations for leader election.
emptyEndpointSlice.Annotations["test-annotation"] = "___"
emptyEndpointSlice.ResourceVersion = "1245"
_, err = kubeClient.DiscoveryV1().EndpointSlices("test").Update(t.Context(), emptyEndpointSlice, metav1.UpdateOptions{})
require.NoError(t, err)
select {
case event := <-eventCh:
ep, ok := event.(*discoveryv1.EndpointSlice)
require.True(t, ok)
assert.Fail(t, "didn't expect to receive event for empty endpointslice update", ep.Name)
case <-time.After(50 * time.Millisecond):
}
filledEndpointSlice, err = kubeClient.DiscoveryV1().EndpointSlices("test").Get(t.Context(), "filled-endpointslice", metav1.GetOptions{})
assert.NoError(t, err)
filledEndpointSlice.Endpoints[0].Addresses[0] = "10.13.37.2"
filledEndpointSlice.ResourceVersion = "1235"
_, err = kubeClient.DiscoveryV1().EndpointSlices("test").Update(t.Context(), filledEndpointSlice, metav1.UpdateOptions{})
require.NoError(t, err)
select {
case event := <-eventCh:
ep, ok := event.(*discoveryv1.EndpointSlice)
require.True(t, ok)
assert.Equal(t, "filled-endpointslice", ep.Name)
case <-time.After(50 * time.Millisecond):
assert.Fail(t, "expected to receive event for filled endpointslice")
}
select {
case <-eventCh:
assert.Fail(t, "received more than one event")
case <-time.After(50 * time.Millisecond):
}
newPortNumber := int32(42)
filledEndpointSlice.Ports[0].Port = &newPortNumber
filledEndpointSlice.ResourceVersion = "1236"
_, err = kubeClient.DiscoveryV1().EndpointSlices("test").Update(t.Context(), filledEndpointSlice, metav1.UpdateOptions{})
require.NoError(t, err)
select {
case event := <-eventCh:
ep, ok := event.(*discoveryv1.EndpointSlice)
require.True(t, ok)
assert.Equal(t, "filled-endpointslice", ep.Name)
case <-time.After(50 * time.Millisecond):
assert.Fail(t, "expected to receive event for filled endpointslice")
}
select {
case <-eventCh:
assert.Fail(t, "received more than one event")
case <-time.After(50 * time.Millisecond):
}
}

View file

@ -0,0 +1,69 @@
package ingressnginx
import (
"errors"
corev1 "k8s.io/api/core/v1"
netv1 "k8s.io/api/networking/v1"
)
type marshaler interface {
Marshal() ([]byte, error)
}
type unmarshaler interface {
Unmarshal(data []byte) error
}
type LoadBalancerIngress interface {
corev1.LoadBalancerIngress | netv1.IngressLoadBalancerIngress
}
// convertSlice converts slice of LoadBalancerIngress to slice of LoadBalancerIngress.
// O (Bar), I (Foo) => []Bar.
func convertSlice[O LoadBalancerIngress, I LoadBalancerIngress](loadBalancerIngresses []I) ([]O, error) {
var results []O
for _, loadBalancerIngress := range loadBalancerIngresses {
mar, ok := any(&loadBalancerIngress).(marshaler)
if !ok {
// All the pointer of types related to the interface LoadBalancerIngress are compatible with the interface marshaler.
continue
}
um, err := convert[O](mar)
if err != nil {
return nil, err
}
v, ok := any(*um).(O)
if !ok {
continue
}
results = append(results, v)
}
return results, nil
}
// convert must only be used with unmarshaler and marshaler compatible types.
func convert[T any](input marshaler) (*T, error) {
data, err := input.Marshal()
if err != nil {
return nil, err
}
var output T
um, ok := any(&output).(unmarshaler)
if !ok {
return nil, errors.New("the output type doesn't implement unmarshaler interface")
}
err = um.Unmarshal(data)
if err != nil {
return nil, err
}
return &output, nil
}

View file

@ -0,0 +1,77 @@
package ingressnginx
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
netv1 "k8s.io/api/networking/v1"
"k8s.io/utils/ptr"
)
func Test_convertSlice_corev1_to_networkingv1(t *testing.T) {
g := []corev1.LoadBalancerIngress{
{
IP: "132456",
Hostname: "foo",
Ports: []corev1.PortStatus{
{
Port: 123,
Protocol: "https",
Error: ptr.To("test"),
},
},
},
}
actual, err := convertSlice[netv1.IngressLoadBalancerIngress](g)
require.NoError(t, err)
expected := []netv1.IngressLoadBalancerIngress{
{
IP: "132456",
Hostname: "foo",
Ports: []netv1.IngressPortStatus{
{
Port: 123,
Protocol: "https",
Error: ptr.To("test"),
},
},
},
}
assert.Equal(t, expected, actual)
}
func Test_convert(t *testing.T) {
g := &corev1.LoadBalancerIngress{
IP: "132456",
Hostname: "foo",
Ports: []corev1.PortStatus{
{
Port: 123,
Protocol: "https",
Error: ptr.To("test"),
},
},
}
actual, err := convert[netv1.IngressLoadBalancerIngress](g)
require.NoError(t, err)
expected := &netv1.IngressLoadBalancerIngress{
IP: "132456",
Hostname: "foo",
Ports: []netv1.IngressPortStatus{
{
Port: 123,
Protocol: "https",
Error: ptr.To("test"),
},
},
}
assert.Equal(t, expected, actual)
}

View file

@ -0,0 +1,7 @@
---
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
name: nginx
spec:
controller: k8s.io/ingress-nginx

View file

@ -0,0 +1,37 @@
---
kind: Ingress
apiVersion: networking.k8s.io/v1
metadata:
name: ingress-with-basicauth
namespace: default
annotations:
# Configuration basic authentication for the Ingress
nginx.ingress.kubernetes.io/auth-type: "basic"
nginx.ingress.kubernetes.io/auth-secret-type: "auth-file"
nginx.ingress.kubernetes.io/auth-secret: "default/basic-auth"
nginx.ingress.kubernetes.io/auth-realm: "Authentication Required"
spec:
ingressClassName: nginx
rules:
- host: whoami.localhost
http:
paths:
- path: /basicauth
pathType: Exact
backend:
service:
name: whoami
port:
number: 80
---
kind: Secret
apiVersion: v1
metadata:
name: basic-auth
namespace: default
type: Opaque
data:
# user:password
auth: dXNlcjp7U0hBfVc2cGg1TW01UHo4R2dpVUxiUGd6RzM3bWo5Zz0=

View file

@ -0,0 +1,24 @@
---
kind: Ingress
apiVersion: networking.k8s.io/v1
metadata:
name: ingress-with-forwardauth
namespace: default
annotations:
nginx.ingress.kubernetes.io/auth-url: "http://whoami.default.svc/"
nginx.ingress.kubernetes.io/auth-method: "GET"
nginx.ingress.kubernetes.io/auth-response-headers: "X-Foo"
spec:
ingressClassName: nginx
rules:
- host: whoami.localhost
http:
paths:
- path: /forwardauth
pathType: Exact
backend:
service:
name: whoami
port:
number: 80

View file

@ -0,0 +1,75 @@
---
kind: Ingress
apiVersion: networking.k8s.io/v1
metadata:
name: ingress-with-ssl-redirect
namespace: default
spec:
ingressClassName: nginx
rules:
- host: sslredirect.localhost
http:
paths:
- path: /
pathType: Exact
backend:
service:
name: whoami
port:
number: 80
tls:
- hosts:
- sslredirect.localhost
secretName: whoami-tls
---
kind: Ingress
apiVersion: networking.k8s.io/v1
metadata:
name: ingress-without-ssl-redirect
namespace: default
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "false"
spec:
ingressClassName: nginx
rules:
- host: withoutsslredirect.localhost
http:
paths:
- path: /
pathType: Exact
backend:
service:
name: whoami
port:
number: 80
tls:
- hosts:
- withoutsslredirect.localhost
secretName: whoami-tls
---
kind: Ingress
apiVersion: networking.k8s.io/v1
metadata:
name: ingress-with-force-ssl-redirect
namespace: default
annotations:
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
spec:
ingressClassName: nginx
rules:
- host: forcesslredirect.localhost
http:
paths:
- path: /
pathType: Exact
backend:
service:
name: whoami
port:
number: 80

View file

@ -0,0 +1,22 @@
---
kind: Ingress
apiVersion: networking.k8s.io/v1
metadata:
name: ingress-with-ssl-passthrough
namespace: default
annotations:
nginx.ingress.kubernetes.io/ssl-passthrough: "true"
spec:
ingressClassName: nginx
rules:
- host: passthrough.whoami.localhost
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: whoami-tls
port:
number: 443

View file

@ -0,0 +1,24 @@
---
kind: Ingress
apiVersion: networking.k8s.io/v1
metadata:
name: ingress-with-default-backend
namespace: default
spec:
defaultBackend:
service:
name: whoami-default
port:
number: 80
rules:
- http:
paths:
- path: /
pathType: Exact
backend:
service:
name: whoami
port:
number: 80

View file

@ -0,0 +1,108 @@
---
kind: Ingress
apiVersion: networking.k8s.io/v1
metadata:
name: ingress-with-default-backend2
namespace: default
# annotations:
# nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
# annotations:
## Configuration basic authentication for the Ingress
# nginx.ingress.kubernetes.io/auth-type: "basic"
# nginx.ingress.kubernetes.io/auth-secret-type: "auth-file"
# nginx.ingress.kubernetes.io/auth-secret: "default/basic-auth"
# nginx.ingress.kubernetes.io/auth-realm: "Authentication Required"
spec:
defaultBackend:
service:
name: whoami-default2
port:
number: 80
rules:
- host: dd.localhost
http:
paths:
- path: /
pathType: Exact
backend:
service:
name: whoami
port:
number: 443
#
# tls:
# - hosts:
# - dd.localhost
# secretName: whoami-tls
#
#---
#kind: Secret
#apiVersion: v1
#metadata:
# name: whoami-tls
# namespace: default
#
#type: opaque
#stringData:
# tls.crt: |
# -----BEGIN CERTIFICATE-----
# MIIEXjCCAsagAwIBAgIQAJmtU2qHBlD9D2HZFZLMeDANBgkqhkiG9w0BAQsFADCB
# jzEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMTIwMAYDVQQLDClyb21h
# aW5AY29udGFpbm91cy5ob21lIChSb21haW4gVHJpYm90dMOpKTE5MDcGA1UEAwww
# bWtjZXJ0IHJvbWFpbkBjb250YWlub3VzLmhvbWUgKFJvbWFpbiBUcmlib3R0w6kp
# MB4XDTI1MDYxMDE1NDE0NFoXDTI3MDkxMDE1NDE0NFowXzEnMCUGA1UEChMebWtj
# ZXJ0IGRldmVsb3BtZW50IGNlcnRpZmljYXRlMTQwMgYDVQQLDCtyb21haW5ATWFj
# Qm9vay1Qcm8ubG9jYWwgKFJvbWFpbiBUcmlib3R0w6kpMIIBIjANBgkqhkiG9w0B
# AQEFAAOCAQ8AMIIBCgKCAQEAq3dajz+RgY+VUXvKKtHFFVd+0URcpDRgN+SJOxP/
# 1uZG2U57DMvTiVy6zfpYo7QPzyEAUwbRTMMgxZV5oy1JPkGzV5kc08GUT3Lh1Azf
# LVPX/K1nA+k7p9+kuMsfkHVABMawRpnWo215T9pjGaTKERA2EaNvrSdq73k6raVn
# DnnmvUgWGPvxTetaLu0AVQscGyrTfQNMB8BwC+JEQJKocenJ0ve5l9/yv9543P2G
# 6UcOv71lDOBNPyltrc4sXfGC2vB1APbp80BVfkZDiF+8Gr8wGJrkd75Esp/xetFV
# yZ6NKO9ZsGZ2E14/qxfvASHGNFNQJafqhnuGbmky8AeaawIDAQABo2UwYzAOBgNV
# HQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHwYDVR0jBBgwFoAUjIHl
# 1gcu+iVVHCicC14yHQiRojgwGwYDVR0RBBQwEoIQd2hvYW1pLmxvY2FsaG9zdDAN
# BgkqhkiG9w0BAQsFAAOCAYEAo/f0ADJwnkOakCHcYCNSqRY/VzRIQSQK3wfDq3bD
# 8EDxGrGPYHOIL+u/Up4RO2/9vLEnFpWb30A8z/qZTKKD+rMuU3qTcCJ2tsB3DAIV
# T+b2GmJYjURf1gqe/NNXnzZqgkoP+bHx6iNvDr1kmc3pZshayz+FxzNmjgpbKl2G
# SgfFLnJDm7hwTC9JFoPyzb586Q0OGQKCJpDMy6pi1MAQl2RWiKyrgo1mhYnSxQmI
# qbJbxYlegRRQQPD6YEJcL5lwILVW3TXcGrK+zuMD+xWznDTBg2BxbF2umG8jmXPH
# 04gRfjlMNLEYSrNEU8EOa/lXebcxnlz6meFOgfYKmSHxL+kwjTUuppDV/qP9U+VS
# /ozJ85VS8iEx1obqZGgqgwcBKMRYzuRnW1XEScGUOK9/cs9mGoXG9uafKb7ekFQc
# wU0j0FoUVzc50WWEjCGFU/dS/2HXUXU/Rcf+uULC10ORplwrB5XdXZYxowh7T//U
# yh86E4+M0LHyZH0vUwDoBk/1
# -----END CERTIFICATE-----
#
#
# tls.key: |
# -----BEGIN PRIVATE KEY-----
# MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCrd1qPP5GBj5VR
# e8oq0cUVV37RRFykNGA35Ik7E//W5kbZTnsMy9OJXLrN+lijtA/PIQBTBtFMwyDF
# lXmjLUk+QbNXmRzTwZRPcuHUDN8tU9f8rWcD6Tun36S4yx+QdUAExrBGmdajbXlP
# 2mMZpMoREDYRo2+tJ2rveTqtpWcOeea9SBYY+/FN61ou7QBVCxwbKtN9A0wHwHAL
# 4kRAkqhx6cnS97mX3/K/3njc/YbpRw6/vWUM4E0/KW2tzixd8YLa8HUA9unzQFV+
# RkOIX7wavzAYmuR3vkSyn/F60VXJno0o71mwZnYTXj+rF+8BIcY0U1Alp+qGe4Zu
# aTLwB5prAgMBAAECggEAVNpLxnf+2c7kZd6MvYPxtA4IhCcAcYI5228NOl87TG3I
# weFEo6B6no91IlmxY9HHwQjj0DKfgQ1POnguKcJPbK+2wLLUwTYa3vZLK1TzXMsR
# J8noINda3kiei5R5mlNryvFIaqfWwCl8zzeTsy0JkkgjebcXnOjU0o17rFMeHNsH
# A3iFWWnHtJkn2OaVtOOgsyjJ9oAnGX0AE4cVp7ZZTerpaYTXzkCphbwRi00IEbCk
# 1bn7gPcBQRoxs12GJUUuy/sopQRA51PE//CnV2pkGuDFWBhFBBKYdsHaTUwmTb5P
# l6S5CuCtw44NkTPetTe2sn9DpOIlR7PmojQndmKkgQKBgQDJ6/RDueJCBxSYS6bh
# 7dTPRphJvntoJHs9Q/NNjKdQhxv0vIIdtRk88Q2qhjjlCzHb1RtD5Jsl+D+TxOdG
# wR1/E8+hdbRKv+WACywa38aBPuZSEj89bnyPyQfzs5TtzD5JsdUHT4l5Eudth6Gv
# w14dFKria8WiEd7X2GodnlZX/wKBgQDZY1QBNjAHsi7QJJSvbPKwK8RygvdNJEem
# FYxhjtHzOfUttjyDXDSGheY3/VzKi2rGgVAHLi+qbvwkURn4qT3xtV5Lpi+BLWHP
# Gwepisd9P5TrN0DGQojWjzYatN9MYRzX0JynIB+alabN2bG7kfPPsHikAA7pRxLH
# 7EwMBDGdlQKBgBqd9uoCk9e+VTGqL0py7m2QUbzO1jepL3GpBmZ/lwKffMjrHH/M
# ApKs9+81mERhEGZ5FgoCFY2Qxti0yQPjqv64XtNaz7RWzWrujhbQzrr0zqmc7Cct
# 7E+L4Xd3gbdDCCbwwTMgge+q1UTz7xVbPIm60rfcGwY9MtHjHkHfQGSDAoGBAIA/
# CAT6+dTgepuSqSDg7j+eYnOH7etVlutVVQ8M2bFbJNiF5Sc900L1ZX7seryHCUP4
# b8T8q2Qpu5iVO/QlrASXkfyhGu9jXYt4D8omtE+gnfMyEoWkJOQncqzIvd9qf0CW
# soQqAFsLJG/WmPLmRObm3hUqb6GRq3PEZIzGQJsNAoGBAJEN0ZkrIkNK+Jjd1oNB
# AnwgLA0qyAHqJxPig45Nudhb6Jw4ub/hKG9bCrLpcBM57Lue535e2HtQ5Ed22Pim
# 0m7bQkvrIQYjflW99RsfkiH5qJsiTy9O92iKgGtJAJ80vTkIggAbsnzOHlZvR0Fr
# +GhYvMt0TxpugicUqguSSUZp
# -----END PRIVATE KEY-----

View file

@ -0,0 +1,28 @@
---
kind: Ingress
apiVersion: networking.k8s.io/v1
metadata:
name: ingress-with-sticky
namespace: default
annotations:
nginx.ingress.kubernetes.io/affinity: cookie
nginx.ingress.kubernetes.io/session-cookie-name: foobar
nginx.ingress.kubernetes.io/session-cookie-secure: "true"
nginx.ingress.kubernetes.io/session-cookie-path: "/foobar"
nginx.ingress.kubernetes.io/session-cookie-domain: "foo.localhost"
nginx.ingress.kubernetes.io/session-cookie-samesite: "None"
nginx.ingress.kubernetes.io/session-cookie-max-age: "42"
spec:
ingressClassName: nginx
rules:
- host: sticky.localhost
http:
paths:
- path: /
pathType: Exact
backend:
service:
name: whoami
port:
number: 80

View file

@ -0,0 +1,37 @@
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ingress-with-proxy-ssl
namespace: default
annotations:
nginx.ingress.kubernetes.io/backend-protocol: "HTTPS" # HTTP, HTTPS, AUTO_HTTP, GRPC, GRPCS and FCGI
nginx.ingress.kubernetes.io/proxy-ssl-secret: "default/ingress-with-proxy-ssl"
nginx.ingress.kubernetes.io/proxy-ssl-verify: "on"
nginx.ingress.kubernetes.io/proxy-ssl-verify-depth: "1"
nginx.ingress.kubernetes.io/proxy-ssl-server-name: "whoami.localhost"
nginx.ingress.kubernetes.io/proxy-ssl-name: "whoami.localhost"
spec:
ingressClassName: nginx
rules:
- host: proxy-ssl.localhost
http:
paths:
- path: /
pathType: Exact
backend:
service:
name: whoami-tls
port:
number: 443
---
kind: Secret
apiVersion: v1
metadata:
namespace: default
name: ingress-with-proxy-ssl
data:
ca.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t

View file

@ -0,0 +1,28 @@
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ingress-with-cors
namespace: default
annotations:
nginx.ingress.kubernetes.io/enable-cors: "true"
nginx.ingress.kubernetes.io/cors-allow-credentials: "true"
nginx.ingress.kubernetes.io/cors-expose-headers: "X-Forwarded-For, X-Forwarded-Host"
nginx.ingress.kubernetes.io/cors-allow-headers: "X-Foo"
nginx.ingress.kubernetes.io/cors-allow-methods: "PUT, GET, POST, OPTIONS"
nginx.ingress.kubernetes.io/cors-allow-origin: "*"
nginx.ingress.kubernetes.io/cors-max-age: "42"
spec:
ingressClassName: nginx
rules:
- host: cors.localhost
http:
paths:
- path: /
pathType: Exact
backend:
service:
name: whoami
port:
number: 80

View file

@ -0,0 +1,22 @@
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ingress-with-service-upstream
namespace: default
annotations:
nginx.ingress.kubernetes.io/service-upstream: "true"
spec:
ingressClassName: nginx
rules:
- host: service-upstream.localhost
http:
paths:
- path: /
pathType: Exact
backend:
service:
name: whoami
port:
number: 80

View file

@ -0,0 +1,9 @@
kind: Secret
apiVersion: v1
metadata:
namespace: default
name: whoami-tls
data:
tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t
tls.key: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t

View file

@ -0,0 +1,80 @@
kind: Service
apiVersion: v1
metadata:
name: whoami
namespace: default
spec:
clusterIP: 10.10.10.1
ports:
- name: web2
protocol: TCP
port: 8000
targetPort: web2
- name: web
protocol: TCP
port: 80
targetPort: web
selector:
app: whoami
task: whoami
---
kind: EndpointSlice
apiVersion: discovery.k8s.io/v1
metadata:
name: whoami
namespace: default
labels:
kubernetes.io/service-name: whoami
addressType: IPv4
ports:
- name: web
port: 80
- name: web2
port: 8000
endpoints:
- addresses:
- 10.10.0.1
- 10.10.0.2
conditions:
ready: true
---
apiVersion: v1
kind: Service
metadata:
name: whoami-tls
namespace: default
spec:
ports:
- name: websecure
protocol: TCP
appProtocol: https
port: 443
targetPort: websecure
selector:
app: whoami-tls
task: whoami
---
kind: EndpointSlice
apiVersion: discovery.k8s.io/v1
metadata:
name: whoami-tls
namespace: default
labels:
kubernetes.io/service-name: whoami-tls
addressType: IPv4
ports:
- name: websecure
port: 8443
endpoints:
- addresses:
- 10.10.0.5
- 10.10.0.6
conditions:
ready: true

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,605 @@
package ingressnginx
import (
"math"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/traefik/traefik/v3/pkg/config/dynamic"
"github.com/traefik/traefik/v3/pkg/provider/kubernetes/k8s"
"github.com/traefik/traefik/v3/pkg/tls"
"github.com/traefik/traefik/v3/pkg/types"
"k8s.io/apimachinery/pkg/runtime"
kubefake "k8s.io/client-go/kubernetes/fake"
"k8s.io/utils/ptr"
)
func TestLoadIngresses(t *testing.T) {
testCases := []struct {
desc string
ingressClass string
defaultBackendServiceName string
defaultBackendServiceNamespace string
paths []string
expected *dynamic.Configuration
}{
{
desc: "Empty, no IngressClass",
paths: []string{
"services.yml",
"ingresses/01-ingress-with-basicauth.yml",
},
expected: &dynamic.Configuration{
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Services: map[string]*dynamic.TCPService{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{},
Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{},
},
},
{
desc: "Basic Auth",
paths: []string{
"services.yml",
"ingressclasses.yml",
"ingresses/01-ingress-with-basicauth.yml",
},
expected: &dynamic.Configuration{
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Services: map[string]*dynamic.TCPService{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"default-ingress-with-basicauth-rule-0-path-0": {
Rule: "Host(`whoami.localhost`) && Path(`/basicauth`)",
RuleSyntax: "default",
Middlewares: []string{"default-ingress-with-basicauth-rule-0-path-0-basic-auth"},
Service: "default-whoami-80",
},
},
Middlewares: map[string]*dynamic.Middleware{
"default-ingress-with-basicauth-rule-0-path-0-basic-auth": {
BasicAuth: &dynamic.BasicAuth{
Users: dynamic.Users{
"user:{SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g=",
},
Realm: "Authentication Required",
},
},
},
Services: map[string]*dynamic.Service{
"default-whoami-80": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{
{
URL: "http://10.10.0.1:80",
},
{
URL: "http://10.10.0.2:80",
},
},
Strategy: "wrr",
PassHostHeader: ptr.To(true),
ResponseForwarding: &dynamic.ResponseForwarding{
FlushInterval: dynamic.DefaultFlushInterval,
},
},
},
},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{},
},
},
{
desc: "Forward Auth",
paths: []string{
"services.yml",
"ingressclasses.yml",
"ingresses/02-ingress-with-forwardauth.yml",
},
expected: &dynamic.Configuration{
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Services: map[string]*dynamic.TCPService{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"default-ingress-with-forwardauth-rule-0-path-0": {
Rule: "Host(`whoami.localhost`) && Path(`/forwardauth`)",
RuleSyntax: "default",
Middlewares: []string{"default-ingress-with-forwardauth-rule-0-path-0-forward-auth"},
Service: "default-whoami-80",
},
},
Middlewares: map[string]*dynamic.Middleware{
"default-ingress-with-forwardauth-rule-0-path-0-forward-auth": {
ForwardAuth: &dynamic.ForwardAuth{
Address: "http://whoami.default.svc/",
AuthResponseHeaders: []string{"X-Foo"},
},
},
},
Services: map[string]*dynamic.Service{
"default-whoami-80": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{
{
URL: "http://10.10.0.1:80",
},
{
URL: "http://10.10.0.2:80",
},
},
Strategy: "wrr",
PassHostHeader: ptr.To(true),
ResponseForwarding: &dynamic.ResponseForwarding{
FlushInterval: dynamic.DefaultFlushInterval,
},
},
},
},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{},
},
},
{
desc: "SSL Redirect",
paths: []string{
"services.yml",
"secrets.yml",
"ingressclasses.yml",
"ingresses/03-ingress-with-ssl-redirect.yml",
},
expected: &dynamic.Configuration{
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Services: map[string]*dynamic.TCPService{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"default-ingress-with-ssl-redirect-rule-0-path-0": {
Rule: "Host(`sslredirect.localhost`) && Path(`/`)",
RuleSyntax: "default",
TLS: &dynamic.RouterTLSConfig{},
Service: "default-whoami-80",
},
"default-ingress-with-ssl-redirect-rule-0-path-0-redirect": {
Rule: "Host(`sslredirect.localhost`) && Path(`/`)",
RuleSyntax: "default",
Middlewares: []string{"default-ingress-with-ssl-redirect-rule-0-path-0-redirect-scheme"},
Service: "noop@internal",
},
"default-ingress-without-ssl-redirect-rule-0-path-0-http": {
Rule: "Host(`withoutsslredirect.localhost`) && Path(`/`)",
RuleSyntax: "default",
Service: "default-whoami-80",
},
"default-ingress-without-ssl-redirect-rule-0-path-0": {
Rule: "Host(`withoutsslredirect.localhost`) && Path(`/`)",
RuleSyntax: "default",
TLS: &dynamic.RouterTLSConfig{},
Service: "default-whoami-80",
},
"default-ingress-with-force-ssl-redirect-rule-0-path-0": {
Rule: "Host(`forcesslredirect.localhost`) && Path(`/`)",
RuleSyntax: "default",
Service: "default-whoami-80",
},
"default-ingress-with-force-ssl-redirect-rule-0-path-0-redirect": {
Rule: "Host(`forcesslredirect.localhost`) && Path(`/`)",
RuleSyntax: "default",
Middlewares: []string{"default-ingress-with-force-ssl-redirect-rule-0-path-0-redirect-scheme"},
Service: "noop@internal",
},
},
Middlewares: map[string]*dynamic.Middleware{
"default-ingress-with-ssl-redirect-rule-0-path-0-redirect-scheme": {
RedirectScheme: &dynamic.RedirectScheme{
Scheme: "https",
Permanent: true,
},
},
"default-ingress-with-force-ssl-redirect-rule-0-path-0-redirect-scheme": {
RedirectScheme: &dynamic.RedirectScheme{
Scheme: "https",
Permanent: true,
},
},
},
Services: map[string]*dynamic.Service{
"default-whoami-80": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{
{
URL: "http://10.10.0.1:80",
},
{
URL: "http://10.10.0.2:80",
},
},
Strategy: "wrr",
PassHostHeader: ptr.To(true),
ResponseForwarding: &dynamic.ResponseForwarding{
FlushInterval: dynamic.DefaultFlushInterval,
},
},
},
},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{
Certificates: []*tls.CertAndStores{
{
Certificate: tls.Certificate{
CertFile: "-----BEGIN CERTIFICATE-----",
KeyFile: "-----BEGIN CERTIFICATE-----",
},
},
},
},
},
},
{
desc: "SSL Passthrough",
paths: []string{
"services.yml",
"secrets.yml",
"ingressclasses.yml",
"ingresses/04-ingress-with-ssl-passthrough.yml",
},
expected: &dynamic.Configuration{
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{
"default-ingress-with-ssl-passthrough-passthrough-whoami-localhost": {
Rule: "HostSNI(`passthrough.whoami.localhost`)",
RuleSyntax: "default",
TLS: &dynamic.RouterTCPTLSConfig{
Passthrough: true,
},
Service: "default-whoami-tls-443",
},
},
Services: map[string]*dynamic.TCPService{
"default-whoami-tls-443": {
LoadBalancer: &dynamic.TCPServersLoadBalancer{
Servers: []dynamic.TCPServer{
{
Address: "10.10.0.5:8443",
},
{
Address: "10.10.0.6:8443",
},
},
},
},
},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{},
Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{},
},
},
{
desc: "Sticky Sessions",
paths: []string{
"services.yml",
"secrets.yml",
"ingressclasses.yml",
"ingresses/06-ingress-with-sticky.yml",
},
expected: &dynamic.Configuration{
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Services: map[string]*dynamic.TCPService{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"default-ingress-with-sticky-rule-0-path-0": {
Rule: "Host(`sticky.localhost`) && Path(`/`)",
RuleSyntax: "default",
Service: "default-whoami-80",
},
},
Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{
"default-whoami-80": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{
{
URL: "http://10.10.0.1:80",
},
{
URL: "http://10.10.0.2:80",
},
},
Strategy: "wrr",
PassHostHeader: ptr.To(true),
ResponseForwarding: &dynamic.ResponseForwarding{
FlushInterval: dynamic.DefaultFlushInterval,
},
Sticky: &dynamic.Sticky{
Cookie: &dynamic.Cookie{
Name: "foobar",
Domain: "foo.localhost",
HTTPOnly: true,
MaxAge: 42,
Path: ptr.To("/foobar"),
SameSite: "none",
Secure: true,
},
},
},
},
},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{},
},
},
{
desc: "Proxy SSL",
paths: []string{
"services.yml",
"secrets.yml",
"ingressclasses.yml",
"ingresses/07-ingress-with-proxy-ssl.yml",
},
expected: &dynamic.Configuration{
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Services: map[string]*dynamic.TCPService{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"default-ingress-with-proxy-ssl-rule-0-path-0": {
Rule: "Host(`proxy-ssl.localhost`) && Path(`/`)",
RuleSyntax: "default",
Service: "default-whoami-tls-443",
},
},
Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{
"default-whoami-tls-443": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{
{
URL: "https://10.10.0.5:8443",
},
{
URL: "https://10.10.0.6:8443",
},
},
Strategy: "wrr",
PassHostHeader: ptr.To(true),
ResponseForwarding: &dynamic.ResponseForwarding{
FlushInterval: dynamic.DefaultFlushInterval,
},
ServersTransport: "default-ingress-with-proxy-ssl",
},
},
},
ServersTransports: map[string]*dynamic.ServersTransport{
"default-ingress-with-proxy-ssl": {
ServerName: "whoami.localhost",
InsecureSkipVerify: true,
RootCAs: []types.FileOrContent{"-----BEGIN CERTIFICATE-----"},
},
},
},
TLS: &dynamic.TLSConfiguration{},
},
},
{
desc: "CORS",
paths: []string{
"services.yml",
"ingressclasses.yml",
"ingresses/08-ingress-with-cors.yml",
},
expected: &dynamic.Configuration{
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Services: map[string]*dynamic.TCPService{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"default-ingress-with-cors-rule-0-path-0": {
Rule: "Host(`cors.localhost`) && Path(`/`)",
RuleSyntax: "default",
Middlewares: []string{"default-ingress-with-cors-rule-0-path-0-cors"},
Service: "default-whoami-80",
},
},
Middlewares: map[string]*dynamic.Middleware{
"default-ingress-with-cors-rule-0-path-0-cors": {
Headers: &dynamic.Headers{
AccessControlAllowCredentials: true,
AccessControlAllowHeaders: []string{"X-Foo"},
AccessControlAllowMethods: []string{"PUT", "GET", "POST", "OPTIONS"},
AccessControlAllowOriginList: []string{"*"},
AccessControlExposeHeaders: []string{"X-Forwarded-For", "X-Forwarded-Host"},
AccessControlMaxAge: 42,
},
},
},
Services: map[string]*dynamic.Service{
"default-whoami-80": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{
{
URL: "http://10.10.0.1:80",
},
{
URL: "http://10.10.0.2:80",
},
},
Strategy: "wrr",
PassHostHeader: ptr.To(true),
ResponseForwarding: &dynamic.ResponseForwarding{
FlushInterval: dynamic.DefaultFlushInterval,
},
},
},
},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{},
},
},
{
desc: "Service Upstream",
paths: []string{
"services.yml",
"ingressclasses.yml",
"ingresses/09-ingress-with-service-upstream.yml",
},
expected: &dynamic.Configuration{
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Services: map[string]*dynamic.TCPService{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"default-ingress-with-service-upstream-rule-0-path-0": {
Rule: "Host(`service-upstream.localhost`) && Path(`/`)",
RuleSyntax: "default",
Service: "default-whoami-80",
},
},
Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{
"default-whoami-80": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{
{
URL: "http://10.10.10.1:80",
},
},
Strategy: "wrr",
PassHostHeader: ptr.To(true),
ResponseForwarding: &dynamic.ResponseForwarding{
FlushInterval: dynamic.DefaultFlushInterval,
},
},
},
},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{},
},
},
{
desc: "Default Backend",
defaultBackendServiceName: "whoami",
defaultBackendServiceNamespace: "default",
paths: []string{
"services.yml",
},
expected: &dynamic.Configuration{
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Services: map[string]*dynamic.TCPService{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"default-backend": {
Rule: "PathPrefix(`/`)",
RuleSyntax: "default",
Priority: math.MinInt32,
Service: "default-backend",
},
"default-backend-tls": {
Rule: "PathPrefix(`/`)",
RuleSyntax: "default",
Priority: math.MinInt32,
TLS: &dynamic.RouterTLSConfig{},
Service: "default-backend",
},
},
Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{
"default-backend": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{
{
URL: "http://10.10.0.1:8000",
},
{
URL: "http://10.10.0.2:8000",
},
},
Strategy: "wrr",
PassHostHeader: ptr.To(true),
ResponseForwarding: &dynamic.ResponseForwarding{
FlushInterval: dynamic.DefaultFlushInterval,
},
},
},
},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{},
},
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
k8sObjects := readResources(t, test.paths)
kubeClient := kubefake.NewClientset(k8sObjects...)
client := newClient(kubeClient)
eventCh, err := client.WatchAll(t.Context(), "", "")
require.NoError(t, err)
if len(k8sObjects) > 0 {
// just wait for the first event
<-eventCh
}
p := Provider{
k8sClient: client,
defaultBackendServiceName: test.defaultBackendServiceName,
defaultBackendServiceNamespace: test.defaultBackendServiceNamespace,
}
p.SetDefaults()
conf := p.loadConfiguration(t.Context())
assert.Equal(t, test.expected, conf)
})
}
}
func readResources(t *testing.T, paths []string) []runtime.Object {
t.Helper()
var k8sObjects []runtime.Object
for _, path := range paths {
yamlContent, err := os.ReadFile(filepath.FromSlash("./fixtures/" + path))
if err != nil {
panic(err)
}
k8sObjects = append(k8sObjects, k8s.MustParseYaml(yamlContent)...)
}
return k8sObjects
}