mirror of
https://github.com/dokku/dokku.git
synced 2026-08-29 10:08:53 +02:00
feat: route wildcard domains through traefik on k3s
Traefik matches hosts exactly, so an app serving a wildcard domain under the `traefik` ingress class had a valid certificate but silently 404d on every request. Wildcard domains now render as a `HostRegexp` rule that matches a single label, the same semantics as a Kubernetes wildcard host, so both ingress classes behave the same. Those routes carry an explicit low priority so an exact domain on any app still wins over another app's wildcard, mirroring ingress-nginx.
This commit is contained in:
@@ -281,6 +281,83 @@ func TestIngressRouteTemplateMultipleDomainsRenderOneRoutePerDomain(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngressRouteTemplateWildcardDomainUsesHostRegexp(t *testing.T) {
|
||||
values := testIngressRouteValues(true)
|
||||
web := values["processes"].(map[string]interface{})["web"].(map[string]interface{})["web"].(map[string]interface{})
|
||||
web["domains"] = []interface{}{
|
||||
map[string]interface{}{"name": "*.example.com"},
|
||||
}
|
||||
|
||||
docs := renderIngressRouteTemplate(t, values)
|
||||
if len(docs) != 2 {
|
||||
t.Fatalf("expected 2 ingress routes when tls enabled, got %d", len(docs))
|
||||
}
|
||||
|
||||
for _, name := range []string{"myapp-web-http-80-5000", "myapp-web-http-80-5000-websecure"} {
|
||||
routes := findDocByName(t, docs, name)["spec"].(map[string]interface{})["routes"].([]interface{})
|
||||
if len(routes) != 1 {
|
||||
t.Fatalf("expected %s to have 1 route, got %d", name, len(routes))
|
||||
}
|
||||
|
||||
route := routes[0].(map[string]interface{})
|
||||
if got := route["match"]; got != "HostRegexp(`{subdomain:[^.]+}.example.com`)" {
|
||||
t.Fatalf("expected %s match HostRegexp(`{subdomain:[^.]+}.example.com`), got %#v", name, got)
|
||||
}
|
||||
if got := route["priority"]; got != 1 {
|
||||
t.Fatalf("expected %s priority 1, got %#v", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngressRouteTemplateWildcardRouteYieldsToExactDomain(t *testing.T) {
|
||||
values := testIngressRouteValues(false)
|
||||
web := values["processes"].(map[string]interface{})["web"].(map[string]interface{})["web"].(map[string]interface{})
|
||||
web["domains"] = []interface{}{
|
||||
map[string]interface{}{"name": "*.example.com"},
|
||||
map[string]interface{}{"name": "app.example.com"},
|
||||
}
|
||||
|
||||
docs := renderIngressRouteTemplate(t, values)
|
||||
routes := findDocByName(t, docs, "myapp-web-http-80-5000")["spec"].(map[string]interface{})["routes"].([]interface{})
|
||||
if len(routes) != 2 {
|
||||
t.Fatalf("expected 2 routes (one per domain), got %d", len(routes))
|
||||
}
|
||||
|
||||
wildcard := routes[0].(map[string]interface{})
|
||||
if got := wildcard["match"]; got != "HostRegexp(`{subdomain:[^.]+}.example.com`)" {
|
||||
t.Fatalf("expected wildcard match HostRegexp(`{subdomain:[^.]+}.example.com`), got %#v", got)
|
||||
}
|
||||
if got := wildcard["priority"]; got != 1 {
|
||||
t.Fatalf("expected wildcard priority 1, got %#v", got)
|
||||
}
|
||||
|
||||
exact := routes[1].(map[string]interface{})
|
||||
if got := exact["match"]; got != "Host(`app.example.com`)" {
|
||||
t.Fatalf("expected exact match Host(`app.example.com`), got %#v", got)
|
||||
}
|
||||
if got, ok := exact["priority"]; ok {
|
||||
t.Fatalf("expected exact route to omit priority so traefik defaults it above the wildcard, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngressRouteTemplateNonLeadingWildcardStaysExactHost(t *testing.T) {
|
||||
values := testIngressRouteValues(false)
|
||||
web := values["processes"].(map[string]interface{})["web"].(map[string]interface{})["web"].(map[string]interface{})
|
||||
web["domains"] = []interface{}{
|
||||
map[string]interface{}{"name": "app.*.example.com"},
|
||||
}
|
||||
|
||||
docs := renderIngressRouteTemplate(t, values)
|
||||
routes := findDocByName(t, docs, "myapp-web-http-80-5000")["spec"].(map[string]interface{})["routes"].([]interface{})
|
||||
route := routes[0].(map[string]interface{})
|
||||
if got := route["match"]; got != "Host(`app.*.example.com`)" {
|
||||
t.Fatalf("expected non-leading wildcard to stay Host(`app.*.example.com`), got %#v", got)
|
||||
}
|
||||
if got, ok := route["priority"]; ok {
|
||||
t.Fatalf("expected non-leading wildcard to omit priority, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngressRouteTemplateNonTraefikIngressClassRendersNothing(t *testing.T) {
|
||||
values := testIngressRouteValues(true)
|
||||
values["global"].(map[string]interface{})["network"].(map[string]interface{})["ingress_class"] = "nginx"
|
||||
|
||||
205
plugins/scheduler-k3s/ingress_template_test.go
Normal file
205
plugins/scheduler-k3s/ingress_template_test.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package scheduler_k3s
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
"helm.sh/helm/v3/pkg/chart/loader"
|
||||
"helm.sh/helm/v3/pkg/chartutil"
|
||||
"helm.sh/helm/v3/pkg/engine"
|
||||
)
|
||||
|
||||
func renderIngressTemplate(t *testing.T, values map[string]interface{}) []map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
chartDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(chartDir, "templates"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
chartYAML := []byte("apiVersion: v2\nname: test\nversion: 0.0.1\n")
|
||||
if err := os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), chartYAML, 0o644); err != nil {
|
||||
t.Fatalf("write Chart.yaml: %v", err)
|
||||
}
|
||||
|
||||
ingressTpl, err := templates.ReadFile("templates/chart/ingress.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("read ingress template: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(chartDir, "templates", "ingress.yaml"), ingressTpl, 0o644); err != nil {
|
||||
t.Fatalf("write ingress template: %v", err)
|
||||
}
|
||||
|
||||
helpersTpl, err := templates.ReadFile("templates/chart/_helpers.tpl")
|
||||
if err != nil {
|
||||
t.Fatalf("read _helpers: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(chartDir, "templates", "_helpers.tpl"), helpersTpl, 0o644); err != nil {
|
||||
t.Fatalf("write _helpers: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := loader.Load(chartDir)
|
||||
if err != nil {
|
||||
t.Fatalf("load chart: %v", err)
|
||||
}
|
||||
|
||||
renderValues, err := chartutil.ToRenderValues(loaded, values, chartutil.ReleaseOptions{Name: "test", Namespace: "default"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ToRenderValues: %v", err)
|
||||
}
|
||||
|
||||
rendered, err := engine.Render(loaded, renderValues)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
|
||||
manifest := rendered["test/templates/ingress.yaml"]
|
||||
|
||||
var docs []map[string]interface{}
|
||||
decoder := yaml.NewDecoder(strings.NewReader(manifest))
|
||||
for {
|
||||
var doc map[string]interface{}
|
||||
if err := decoder.Decode(&doc); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
t.Fatalf("yaml decode failed: %v\nrendered:\n%s", err, manifest)
|
||||
}
|
||||
if doc != nil {
|
||||
docs = append(docs, doc)
|
||||
}
|
||||
}
|
||||
|
||||
return docs
|
||||
}
|
||||
|
||||
func testIngressValues(tlsEnabled bool, domains ...map[string]interface{}) map[string]interface{} {
|
||||
domainValues := []interface{}{}
|
||||
for _, domain := range domains {
|
||||
domainValues = append(domainValues, domain)
|
||||
}
|
||||
|
||||
web := map[string]interface{}{
|
||||
"domains": domainValues,
|
||||
"port_maps": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "http-80-5000",
|
||||
"scheme": "http",
|
||||
"container_port": 5000,
|
||||
},
|
||||
},
|
||||
"tls": map[string]interface{}{
|
||||
"enabled": tlsEnabled,
|
||||
"use_imported_cert": false,
|
||||
},
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"global": map[string]interface{}{
|
||||
"app_name": "myapp",
|
||||
"namespace": "myns",
|
||||
"network": map[string]interface{}{
|
||||
"ingress_class": "nginx",
|
||||
},
|
||||
},
|
||||
"processes": map[string]interface{}{
|
||||
"web": map[string]interface{}{
|
||||
"web": web,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ingressHost(t *testing.T, doc map[string]interface{}) string {
|
||||
t.Helper()
|
||||
|
||||
rules, ok := doc["spec"].(map[string]interface{})["rules"].([]interface{})
|
||||
if !ok || len(rules) != 1 {
|
||||
t.Fatalf("expected ingress to contain a single rule, got %#v", doc["spec"])
|
||||
}
|
||||
|
||||
host, ok := rules[0].(map[string]interface{})["host"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("expected rule to contain a string host, got %#v", rules[0])
|
||||
}
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
func TestIngressTemplateExactDomainUsesSlugForObjectName(t *testing.T) {
|
||||
values := testIngressValues(false, map[string]interface{}{"name": "app.example.com", "slug": "app-example-com"})
|
||||
|
||||
docs := renderIngressTemplate(t, values)
|
||||
if len(docs) != 1 {
|
||||
t.Fatalf("expected 1 ingress, got %d", len(docs))
|
||||
}
|
||||
|
||||
doc := findDocByName(t, docs, "myapp-web-app-example-com")
|
||||
if got := ingressHost(t, doc); got != "app.example.com" {
|
||||
t.Fatalf("expected host app.example.com, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngressTemplateWildcardDomainRendersWildcardHost(t *testing.T) {
|
||||
values := testIngressValues(true, map[string]interface{}{"name": "*.example.com", "slug": "wildcard-example-com"})
|
||||
|
||||
docs := renderIngressTemplate(t, values)
|
||||
if len(docs) != 1 {
|
||||
t.Fatalf("expected 1 ingress, got %d", len(docs))
|
||||
}
|
||||
|
||||
doc := findDocByName(t, docs, "myapp-web-wildcard-example-com")
|
||||
if got := ingressHost(t, doc); got != "*.example.com" {
|
||||
t.Fatalf("expected host *.example.com, got %q", got)
|
||||
}
|
||||
|
||||
tls, ok := doc["spec"].(map[string]interface{})["tls"].([]interface{})
|
||||
if !ok || len(tls) != 1 {
|
||||
t.Fatalf("expected ingress to contain a single tls entry, got %#v", doc["spec"])
|
||||
}
|
||||
|
||||
hosts, ok := tls[0].(map[string]interface{})["hosts"].([]interface{})
|
||||
if !ok || len(hosts) != 1 || hosts[0] != "*.example.com" {
|
||||
t.Fatalf("expected tls hosts [*.example.com], got %#v", tls[0])
|
||||
}
|
||||
if got := tls[0].(map[string]interface{})["secretName"]; got != "tls-myapp-web" {
|
||||
t.Fatalf("expected tls secretName tls-myapp-web, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngressTemplateWildcardAndApexRenderDistinctObjects(t *testing.T) {
|
||||
values := testIngressValues(false,
|
||||
map[string]interface{}{"name": "*.example.com", "slug": "wildcard-example-com"},
|
||||
map[string]interface{}{"name": "example.com", "slug": "example-com"},
|
||||
)
|
||||
|
||||
docs := renderIngressTemplate(t, values)
|
||||
if len(docs) != 2 {
|
||||
t.Fatalf("expected 2 ingresses (one per domain), got %d", len(docs))
|
||||
}
|
||||
|
||||
wildcard := findDocByName(t, docs, "myapp-web-wildcard-example-com")
|
||||
if got := ingressHost(t, wildcard); got != "*.example.com" {
|
||||
t.Fatalf("expected wildcard host *.example.com, got %q", got)
|
||||
}
|
||||
|
||||
apex := findDocByName(t, docs, "myapp-web-example-com")
|
||||
if got := ingressHost(t, apex); got != "example.com" {
|
||||
t.Fatalf("expected apex host example.com, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngressTemplateNonNginxIngressClassRendersNothing(t *testing.T) {
|
||||
values := testIngressValues(false, map[string]interface{}{"name": "app.example.com", "slug": "app-example-com"})
|
||||
values["global"].(map[string]interface{})["network"].(map[string]interface{})["ingress_class"] = "traefik"
|
||||
|
||||
docs := renderIngressTemplate(t, values)
|
||||
if len(docs) != 0 {
|
||||
t.Fatalf("expected 0 ingresses when ingress_class is not nginx, got %d: %#v", len(docs), docs)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,20 @@
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
traefik.host.match renders a domain as a Traefik router rule. Traefik v2 resolves HostRegexp
|
||||
through gorilla/mux, where the pattern is a {name:regexp} template and every literal segment is
|
||||
quoted, so a wildcard label is spelled out as a named group rather than a raw regexp. The group
|
||||
matches a single label, mirroring both DNS and Kubernetes Ingress wildcard hosts.
|
||||
*/}}
|
||||
{{- define "traefik.host.match" -}}
|
||||
{{- if hasPrefix "*." . -}}
|
||||
HostRegexp(`{subdomain:[^.]+}.{{ trimPrefix "*." . }}`)
|
||||
{{- else -}}
|
||||
Host(`{{ . }}`)
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "primary.port" -}}
|
||||
{{- $found := dict -}}
|
||||
{{- range $idx, $port_map := . -}}
|
||||
|
||||
@@ -39,7 +39,10 @@ spec:
|
||||
routes:
|
||||
{{- range $ddx, $domain := $config.web.domains }}
|
||||
- kind: Rule
|
||||
match: Host(`{{ $domain.name }}`)
|
||||
match: {{ include "traefik.host.match" $domain.name }}
|
||||
{{- if hasPrefix "*." $domain.name }}
|
||||
priority: 1
|
||||
{{- end }}
|
||||
middlewares:
|
||||
- name: {{ $.Values.global.app_name}}-{{ $processName }}-compression
|
||||
namespace: {{ $.Values.global.namespace }}
|
||||
|
||||
Reference in New Issue
Block a user