Files
dokku/plugins/scheduler-k3s/functions_test.go
Jose Diaz-Gonzalez d134e75371 feat: support manually managed cert issuers on k3s
The `cert-issuer-name` and `cert-issuer-kind` properties point an app's generated `Certificate` at a cert-manager issuer created outside of Dokku, allowing certificates to be issued through solvers the built-in letsencrypt integration cannot use, such as `dns01` for wildcard certificates. Setting an issuer enables https on its own, as a manually managed issuer has no email for Dokku to configure. An imported certificate still takes precedence, and `letsencrypt-server false` remains the single off switch. Dokku warns before a build starts when the referenced issuer is absent from the cluster, without blocking the deploy. Wildcard domains no longer collide with their apex domain when generating ingress names, and `letsencrypt-server` values are now validated when set rather than at deploy time.
2026-08-08 15:16:12 -04:00

684 lines
18 KiB
Go

package scheduler_k3s
import (
"strings"
"testing"
"github.com/dokku/dokku/plugins/common"
corev1 "k8s.io/api/core/v1"
)
func TestNeedsImagePullSecretsPrune(t *testing.T) {
cases := []struct {
name string
live []corev1.LocalObjectReference
keepSet map[string]struct{}
want bool
}{
{
name: "empty live, empty keep",
live: []corev1.LocalObjectReference{},
keepSet: map[string]struct{}{},
want: false,
},
{
name: "matches keep set exactly",
live: []corev1.LocalObjectReference{{Name: "pull-secret-foo"}},
keepSet: map[string]struct{}{
"pull-secret-foo": {},
},
want: false,
},
{
name: "leaked entries beyond keep set",
live: []corev1.LocalObjectReference{
{Name: "ims-foo.111"},
{Name: "ims-foo.222"},
{Name: "pull-secret-foo"},
},
keepSet: map[string]struct{}{
"pull-secret-foo": {},
},
want: true,
},
{
name: "live missing the keep entry",
live: []corev1.LocalObjectReference{
{Name: "ims-foo.111"},
},
keepSet: map[string]struct{}{
"pull-secret-foo": {},
},
want: true,
},
{
name: "live populated, keep set empty",
live: []corev1.LocalObjectReference{{Name: "ims-foo.111"}},
keepSet: map[string]struct{}{},
want: true,
},
{
name: "empty live, keep set populated",
live: []corev1.LocalObjectReference{},
keepSet: map[string]struct{}{"pull-secret-foo": {}},
want: true,
},
{
name: "user override only, no leaked entries",
live: []corev1.LocalObjectReference{{Name: "my-custom-secret"}},
keepSet: map[string]struct{}{
"my-custom-secret": {},
},
want: false,
},
{
name: "user override with leaked dokku-managed entries",
live: []corev1.LocalObjectReference{
{Name: "ims-foo.111"},
{Name: "my-custom-secret"},
},
keepSet: map[string]struct{}{
"my-custom-secret": {},
},
want: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := needsImagePullSecretsPrune(tc.live, tc.keepSet)
if got != tc.want {
t.Errorf("needsImagePullSecretsPrune() = %v, want %v", got, tc.want)
}
})
}
}
func TestInitializeInstallerArgs(t *testing.T) {
cases := []struct {
name string
input InitializeInstallerArgsInput
wantPairs [][2]string
wantAbsent []string
}{
{
name: "no kubelet args",
input: InitializeInstallerArgsInput{
IngressClass: "traefik",
NodeName: "ip-10-0-0-1-abc",
Token: "sometoken",
},
wantAbsent: []string{"--kubelet-arg", "--node-taint"},
},
{
name: "single kubelet arg",
input: InitializeInstallerArgsInput{
IngressClass: "traefik",
KubeletArgs: []string{"allowed-unsafe-sysctls=net.ipv4.tcp_rmem"},
NodeName: "ip-10-0-0-1-abc",
Token: "sometoken",
},
wantPairs: [][2]string{
{"--kubelet-arg", "allowed-unsafe-sysctls=net.ipv4.tcp_rmem"},
},
},
{
name: "multiple kubelet args each get their own flag",
input: InitializeInstallerArgsInput{
IngressClass: "traefik",
KubeletArgs: []string{"allowed-unsafe-sysctls=net.ipv4.tcp_rmem", "max-pods=150"},
NodeName: "ip-10-0-0-1-abc",
Token: "sometoken",
},
wantPairs: [][2]string{
{"--kubelet-arg", "allowed-unsafe-sysctls=net.ipv4.tcp_rmem"},
{"--kubelet-arg", "max-pods=150"},
},
},
{
name: "kubelet args coexist with taint scheduling",
input: InitializeInstallerArgsInput{
IngressClass: "nginx",
KubeletArgs: []string{"max-pods=150"},
NodeName: "ip-10-0-0-1-abc",
TaintScheduling: true,
Token: "sometoken",
},
wantPairs: [][2]string{
{"--node-taint", "CriticalAddonsOnly=true:NoSchedule"},
{"--kubelet-arg", "max-pods=150"},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := initializeInstallerArgs(tc.input)
for _, pair := range tc.wantPairs {
if !containsArgPair(got, pair[0], pair[1]) {
t.Errorf("initializeInstallerArgs() missing %q %q, got %v", pair[0], pair[1], got)
}
}
for _, absent := range tc.wantAbsent {
for _, arg := range got {
if arg == absent {
t.Errorf("initializeInstallerArgs() unexpectedly contains %q, got %v", absent, got)
}
}
}
})
}
}
func containsArgPair(args []string, flag string, value string) bool {
for i := 0; i < len(args)-1; i++ {
if args[i] == flag && args[i+1] == value {
return true
}
}
return false
}
func TestNodeLabels(t *testing.T) {
cases := []struct {
name string
role string
profileName string
wantKey string
wantValue string
wantProfile bool
}{
{
name: "server without a profile",
role: "server",
profileName: "",
wantKey: "svccontroller.k3s.cattle.io/enablelb",
wantValue: "true",
wantProfile: false,
},
{
name: "worker without a profile",
role: "worker",
profileName: "",
wantKey: "node-role.kubernetes.io/worker",
wantValue: "worker",
wantProfile: false,
},
{
name: "worker with a profile",
role: "worker",
profileName: "edge-workers",
wantKey: "node-role.kubernetes.io/worker",
wantValue: "worker",
wantProfile: true,
},
{
name: "server with a profile",
role: "server",
profileName: "control-plane",
wantKey: "svccontroller.k3s.cattle.io/enablelb",
wantValue: "true",
wantProfile: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := nodeLabels(tc.role, tc.profileName)
if got[tc.wantKey] != tc.wantValue {
t.Errorf("nodeLabels() role label = %q, want %q", got[tc.wantKey], tc.wantValue)
}
profile, ok := got[NodeProfileLabel]
if ok != tc.wantProfile {
t.Errorf("nodeLabels() has %s = %v, want %v", NodeProfileLabel, ok, tc.wantProfile)
}
if tc.wantProfile && profile != tc.profileName {
t.Errorf("nodeLabels() %s = %q, want %q", NodeProfileLabel, profile, tc.profileName)
}
})
}
}
func TestNodeLabelsDoesNotMutatePackageLabels(t *testing.T) {
serverBefore := len(ServerLabels)
workerBefore := len(WorkerLabels)
nodeLabels("server", "control-plane")
nodeLabels("worker", "edge-workers")
if len(ServerLabels) != serverBefore {
t.Errorf("nodeLabels() mutated ServerLabels: len = %d, want %d", len(ServerLabels), serverBefore)
}
if len(WorkerLabels) != workerBefore {
t.Errorf("nodeLabels() mutated WorkerLabels: len = %d, want %d", len(WorkerLabels), workerBefore)
}
}
func TestIsNamespacedSysctl(t *testing.T) {
cases := []struct {
name string
want bool
}{
{name: "net.core.somaxconn", want: true},
{name: "net.ipv4.tcp_rmem", want: true},
{name: "kernel.shm_rmid_forced", want: true},
{name: "kernel.shmmax", want: true},
{name: "kernel.msgmax", want: true},
{name: "kernel.sem", want: true},
{name: "fs.mqueue.msg_max", want: true},
{name: "vm.max_map_count", want: false},
{name: "vm.swappiness", want: false},
{name: "kernel.pid_max", want: false},
{name: "kernel.semaphore", want: false},
{name: "fs.file-max", want: false},
{name: "", want: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isNamespacedSysctl(tc.name); got != tc.want {
t.Errorf("isNamespacedSysctl(%q) = %v, want %v", tc.name, got, tc.want)
}
})
}
}
func TestParseSysctls(t *testing.T) {
cases := []struct {
name string
values []string
want []Sysctl
wantErr bool
}{
{
name: "empty input",
values: []string{},
want: []Sysctl{},
},
{
name: "single namespaced sysctl",
values: []string{"net.core.somaxconn=1024"},
want: []Sysctl{{Name: "net.core.somaxconn", Value: "1024"}},
},
{
name: "sorted by name regardless of input order",
values: []string{"net.core.somaxconn=1024", "kernel.sem=250", "fs.mqueue.msg_max=20"},
want: []Sysctl{
{Name: "fs.mqueue.msg_max", Value: "20"},
{Name: "kernel.sem", Value: "250"},
{Name: "net.core.somaxconn", Value: "1024"},
},
},
{
name: "value containing an equals sign is preserved",
values: []string{"net.ipv4.tcp_rmem=4096 87380 6291456"},
want: []Sysctl{{Name: "net.ipv4.tcp_rmem", Value: "4096 87380 6291456"}},
},
{
name: "non-namespaced sysctl is rejected",
values: []string{"vm.max_map_count=262144"},
wantErr: true,
},
{
name: "missing equals sign is rejected",
values: []string{"net.core.somaxconn"},
wantErr: true,
},
{
name: "empty name is rejected",
values: []string{"=1024"},
wantErr: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseSysctls(tc.values)
if tc.wantErr {
if err == nil {
t.Fatalf("parseSysctls(%v) expected an error, got %v", tc.values, got)
}
return
}
if err != nil {
t.Fatalf("parseSysctls(%v) unexpected error: %v", tc.values, err)
}
if len(got) != len(tc.want) {
t.Fatalf("parseSysctls(%v) = %v, want %v", tc.values, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("parseSysctls(%v)[%d] = %v, want %v", tc.values, i, got[i], tc.want[i])
}
}
})
}
}
func TestNormalizeCertIssuerKind(t *testing.T) {
cases := []struct {
name string
value string
want string
wantErr bool
}{
{name: "empty is left unset", value: "", want: ""},
{name: "canonical issuer", value: "Issuer", want: CertIssuerKindIssuer},
{name: "canonical cluster issuer", value: "ClusterIssuer", want: CertIssuerKindClusterIssuer},
{name: "lowercase issuer is canonicalized", value: "issuer", want: CertIssuerKindIssuer},
{name: "lowercase cluster issuer is canonicalized", value: "clusterissuer", want: CertIssuerKindClusterIssuer},
{name: "uppercase issuer is canonicalized", value: "ISSUER", want: CertIssuerKindIssuer},
{name: "unknown kind", value: "Certificate", wantErr: true},
{name: "typo", value: "ClusterIssue", wantErr: true},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := normalizeCertIssuerKind(tc.value)
if tc.wantErr {
if err == nil {
t.Fatalf("normalizeCertIssuerKind(%q) expected an error, got %q", tc.value, got)
}
return
}
if err != nil {
t.Fatalf("normalizeCertIssuerKind(%q) unexpected error: %v", tc.value, err)
}
if got != tc.want {
t.Errorf("normalizeCertIssuerKind(%q) = %q, want %q", tc.value, got, tc.want)
}
})
}
}
func TestValidateCertIssuerName(t *testing.T) {
cases := []struct {
name string
value string
wantErr bool
}{
{name: "empty unsets the property", value: ""},
{name: "reserved disabled value", value: CertIssuerNameDisabled},
{name: "simple name", value: "acme-dns"},
{name: "dotted name", value: "my.issuer.example"},
{name: "underscores are not legal kubernetes names", value: "my_own_issuer_name", wantErr: true},
{name: "uppercase is not a legal kubernetes name", value: "AcmeDNS", wantErr: true},
{name: "leading dash", value: "-acme-dns", wantErr: true},
{name: "trailing dash", value: "acme-dns-", wantErr: true},
{name: "too long", value: strings.Repeat("a", 254), wantErr: true},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := validateCertIssuerName(tc.value)
if tc.wantErr && err == nil {
t.Fatalf("validateCertIssuerName(%q) expected an error", tc.value)
}
if !tc.wantErr && err != nil {
t.Fatalf("validateCertIssuerName(%q) unexpected error: %v", tc.value, err)
}
})
}
}
func TestValidateLetsencryptServer(t *testing.T) {
cases := []struct {
name string
value string
wantErr bool
}{
{name: "empty unsets the property", value: ""},
{name: "prod", value: "prod"},
{name: "production", value: "production"},
{name: "stag", value: "stag"},
{name: "staging", value: "staging"},
{name: "disabled", value: LetsencryptServerDisabled},
{name: "typo", value: "prodd", wantErr: true},
{name: "issuer name is not a server", value: "my-own-issuer", wantErr: true},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := validateLetsencryptServer(tc.value)
if tc.wantErr && err == nil {
t.Fatalf("validateLetsencryptServer(%q) expected an error", tc.value)
}
if !tc.wantErr && err != nil {
t.Fatalf("validateLetsencryptServer(%q) unexpected error: %v", tc.value, err)
}
})
}
}
func TestDomainSlug(t *testing.T) {
cases := []struct {
name string
domain string
want string
}{
{name: "plain domain", domain: "example.com", want: "example-com"},
{name: "wildcard domain keeps a distinct slug", domain: "*.example.com", want: "wildcard-example-com"},
{name: "subdomain", domain: "app.example.com", want: "app-example-com"},
{name: "wildcard subdomain", domain: "*.app.example.com", want: "wildcard-app-example-com"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := domainSlug(tc.domain); got != tc.want {
t.Errorf("domainSlug(%q) = %q, want %q", tc.domain, got, tc.want)
}
})
}
}
func TestDomainSlugDistinguishesWildcardFromApex(t *testing.T) {
// A kubernetes wildcard host matches exactly one label, so *.example.com does not
// cover example.com and both are commonly added to the same app. The generated
// ingress name is derived from this slug, so a collision would render two objects
// with the same name and fail the helm install.
if domainSlug("*.example.com") == domainSlug("example.com") {
t.Fatalf("domainSlug collides for *.example.com and example.com: %q", domainSlug("example.com"))
}
}
func TestResolveAppTLSConfig(t *testing.T) {
cases := []struct {
name string
appProperties map[string]string
globalProperties map[string]string
importedCertExists bool
want AppTLSConfig
wantErr bool
}{
{
name: "defaults to the shared production ClusterIssuer once a global email is set",
globalProperties: map[string]string{
"letsencrypt-email-prod": "ops@dokku.me",
},
want: AppTLSConfig{
Enabled: true,
IssuerKind: CertIssuerKindClusterIssuer,
IssuerName: "letsencrypt-prod",
},
},
{
name: "no letsencrypt email leaves tls disabled",
want: AppTLSConfig{
IssuerKind: CertIssuerKindClusterIssuer,
IssuerName: "letsencrypt-prod",
},
},
{
name: "an app level email renders a namespaced Issuer",
appProperties: map[string]string{
"letsencrypt-server": "staging",
"letsencrypt-email-stag": "app@dokku.me",
},
want: AppTLSConfig{
Enabled: true,
IssuerKind: CertIssuerKindIssuer,
IssuerName: "myapp-letsencrypt-stag",
Issuer: AppIssuer{
Email: "app@dokku.me",
Enabled: true,
IngressClass: DefaultIngressClass,
Name: "myapp-letsencrypt-stag",
Server: LetsencryptServerStag,
},
},
},
{
name: "an imported certificate wins over a custom issuer",
importedCertExists: true,
appProperties: map[string]string{
"cert-issuer-name": "acme-dns",
},
want: AppTLSConfig{
Enabled: true,
UseImportedCert: true,
},
},
{
name: "letsencrypt-server false disables a globally configured custom issuer",
appProperties: map[string]string{
"letsencrypt-server": LetsencryptServerDisabled,
},
globalProperties: map[string]string{
"cert-issuer-name": "acme-dns",
},
want: AppTLSConfig{},
},
{
name: "a custom issuer enables tls with no letsencrypt email anywhere",
appProperties: map[string]string{
"cert-issuer-name": "acme-dns",
},
want: AppTLSConfig{
Enabled: true,
IssuerKind: CertIssuerKindClusterIssuer,
IssuerName: "acme-dns",
UsesCustomIssuer: true,
},
},
{
name: "a namespaced custom issuer renders no Dokku managed issuer",
appProperties: map[string]string{
"cert-issuer-name": "acme-dns",
"cert-issuer-kind": CertIssuerKindIssuer,
},
want: AppTLSConfig{
Enabled: true,
IssuerKind: CertIssuerKindIssuer,
IssuerName: "acme-dns",
UsesCustomIssuer: true,
},
},
{
name: "a custom issuer beats a fully configured letsencrypt setup",
appProperties: map[string]string{
"cert-issuer-name": "acme-dns",
"letsencrypt-email-prod": "app@dokku.me",
},
want: AppTLSConfig{
Enabled: true,
IssuerKind: CertIssuerKindClusterIssuer,
IssuerName: "acme-dns",
UsesCustomIssuer: true,
},
},
{
name: "an app level custom issuer overrides the global one",
appProperties: map[string]string{
"cert-issuer-name": "app-issuer",
},
globalProperties: map[string]string{
"cert-issuer-name": "global-issuer",
},
want: AppTLSConfig{
Enabled: true,
IssuerKind: CertIssuerKindClusterIssuer,
IssuerName: "app-issuer",
UsesCustomIssuer: true,
},
},
{
name: "the reserved disabled value falls back to letsencrypt",
appProperties: map[string]string{
"cert-issuer-name": CertIssuerNameDisabled,
},
globalProperties: map[string]string{
"cert-issuer-name": "global-issuer",
"letsencrypt-email-prod": "ops@dokku.me",
},
want: AppTLSConfig{
Enabled: true,
IssuerKind: CertIssuerKindClusterIssuer,
IssuerName: "letsencrypt-prod",
},
},
{
name: "an invalid letsencrypt server still errors",
appProperties: map[string]string{
"letsencrypt-server": "prodd",
},
wantErr: true,
},
{
name: "an invalid stored cert issuer kind errors",
appProperties: map[string]string{
"cert-issuer-name": "acme-dns",
"cert-issuer-kind": "Certificate",
},
wantErr: true,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
setupReportTest(t, "myapp")
for key, value := range tc.appProperties {
if err := common.PropertyWrite("scheduler-k3s", "myapp", key, value); err != nil {
t.Fatalf("PropertyWrite(%q): %v", key, err)
}
}
for key, value := range tc.globalProperties {
if err := common.PropertyWrite("scheduler-k3s", "--global", key, value); err != nil {
t.Fatalf("PropertyWrite(--global, %q): %v", key, err)
}
}
got, err := resolveAppTLSConfig(ResolveAppTLSConfigInput{
AppName: "myapp",
ImportedCertExists: tc.importedCertExists,
})
if tc.wantErr {
if err == nil {
t.Fatalf("resolveAppTLSConfig() expected an error, got %+v", got)
}
return
}
if err != nil {
t.Fatalf("resolveAppTLSConfig() unexpected error: %v", err)
}
if got != tc.want {
t.Errorf("resolveAppTLSConfig() = %+v, want %+v", got, tc.want)
}
})
}
}