Files
dokku/plugins/scheduler-k3s/certificate_template_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

251 lines
7.9 KiB
Go

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"
)
// renderCertificateChart renders the certificate.yaml and issuer.yaml templates
// together and returns the parsed documents for each, keyed by template file name.
func renderCertificateChart(t *testing.T, values map[string]interface{}) map[string][]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)
}
for _, name := range []string{"certificate", "issuer", "_helpers"} {
ext := ".yaml"
if name == "_helpers" {
ext = ".tpl"
}
tpl, err := templates.ReadFile("templates/chart/" + name + ext)
if err != nil {
t.Fatalf("read %s template: %v", name, err)
}
if err := os.WriteFile(filepath.Join(chartDir, "templates", name+ext), tpl, 0o644); err != nil {
t.Fatalf("write %s template: %v", name, 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)
}
result := map[string][]map[string]interface{}{}
for _, name := range []string{"certificate", "issuer"} {
manifest := rendered["test/templates/"+name+".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 of %s failed: %v\nrendered:\n%s", name, err, manifest)
}
if doc != nil {
docs = append(docs, doc)
}
}
result[name] = docs
}
return result
}
func testCertificateValues(issuerKind string, issuerName string, issuer map[string]interface{}) map[string]interface{} {
global := map[string]interface{}{
"app_name": "myapp",
"namespace": "myns",
}
if issuer != nil {
global["issuer"] = issuer
}
web := map[string]interface{}{
"domains": []interface{}{
map[string]interface{}{"name": "app.example.com"},
},
"tls": map[string]interface{}{
"enabled": true,
"use_imported_cert": false,
"issuer_kind": issuerKind,
"issuer_name": issuerName,
},
}
return map[string]interface{}{
"global": global,
"processes": map[string]interface{}{
"web": map[string]interface{}{
"web": web,
},
},
}
}
func certificateIssuerRef(t *testing.T, docs map[string][]map[string]interface{}) map[string]interface{} {
t.Helper()
certs := docs["certificate"]
if len(certs) != 1 {
t.Fatalf("expected 1 certificate document, got %d: %#v", len(certs), certs)
}
spec, ok := certs[0]["spec"].(map[string]interface{})
if !ok {
t.Fatalf("expected certificate spec, got %#v", certs[0]["spec"])
}
issuerRef, ok := spec["issuerRef"].(map[string]interface{})
if !ok {
t.Fatalf("expected certificate issuerRef, got %#v", spec["issuerRef"])
}
return issuerRef
}
func TestCertificateTemplateUsesSharedClusterIssuerByDefault(t *testing.T) {
docs := renderCertificateChart(t, testCertificateValues("ClusterIssuer", "letsencrypt-prod", map[string]interface{}{"enabled": false}))
issuerRef := certificateIssuerRef(t, docs)
if issuerRef["kind"] != "ClusterIssuer" {
t.Fatalf("expected issuerRef.kind ClusterIssuer, got %#v", issuerRef["kind"])
}
if issuerRef["name"] != "letsencrypt-prod" {
t.Fatalf("expected issuerRef.name letsencrypt-prod, got %#v", issuerRef["name"])
}
if len(docs["issuer"]) != 0 {
t.Fatalf("expected no Issuer document for shared ClusterIssuer, got %#v", docs["issuer"])
}
}
func TestCertificateTemplateRendersWithoutIssuerValues(t *testing.T) {
// Reproduces the deploy failure where global.issuer is absent from values.yaml
// (the common case, no per-app email). The chart must still render without a
// nil-pointer error, use the shared ClusterIssuer, and emit no Issuer document.
docs := renderCertificateChart(t, testCertificateValues("ClusterIssuer", "letsencrypt-prod", nil))
issuerRef := certificateIssuerRef(t, docs)
if issuerRef["kind"] != "ClusterIssuer" {
t.Fatalf("expected issuerRef.kind ClusterIssuer, got %#v", issuerRef["kind"])
}
if issuerRef["name"] != "letsencrypt-prod" {
t.Fatalf("expected issuerRef.name letsencrypt-prod, got %#v", issuerRef["name"])
}
if len(docs["issuer"]) != 0 {
t.Fatalf("expected no Issuer document when global.issuer is absent, got %#v", docs["issuer"])
}
}
func TestCertificateTemplateDefaultsKindWhenIssuerKindEmpty(t *testing.T) {
docs := renderCertificateChart(t, testCertificateValues("", "letsencrypt-prod", map[string]interface{}{"enabled": false}))
issuerRef := certificateIssuerRef(t, docs)
if issuerRef["kind"] != "ClusterIssuer" {
t.Fatalf("expected empty issuer_kind to default to ClusterIssuer, got %#v", issuerRef["kind"])
}
}
func TestCertificateTemplateRendersPerAppNamespacedIssuer(t *testing.T) {
issuer := map[string]interface{}{
"enabled": true,
"name": "myapp-letsencrypt-stag",
"email": "app@dokku.me",
"server": LetsencryptServerStag,
"ingress_class": "nginx",
}
docs := renderCertificateChart(t, testCertificateValues("Issuer", "myapp-letsencrypt-stag", issuer))
issuerRef := certificateIssuerRef(t, docs)
if issuerRef["kind"] != "Issuer" {
t.Fatalf("expected issuerRef.kind Issuer, got %#v", issuerRef["kind"])
}
if issuerRef["name"] != "myapp-letsencrypt-stag" {
t.Fatalf("expected issuerRef.name myapp-letsencrypt-stag, got %#v", issuerRef["name"])
}
issuers := docs["issuer"]
if len(issuers) != 1 {
t.Fatalf("expected 1 Issuer document, got %d: %#v", len(issuers), issuers)
}
if issuers[0]["kind"] != "Issuer" {
t.Fatalf("expected kind Issuer, got %#v", issuers[0]["kind"])
}
metadata, _ := issuers[0]["metadata"].(map[string]interface{})
if metadata["name"] != "myapp-letsencrypt-stag" {
t.Fatalf("expected Issuer name myapp-letsencrypt-stag, got %#v", metadata["name"])
}
if metadata["namespace"] != "myns" {
t.Fatalf("expected Issuer namespace myns, got %#v", metadata["namespace"])
}
spec, _ := issuers[0]["spec"].(map[string]interface{})
acme, _ := spec["acme"].(map[string]interface{})
if acme["email"] != "app@dokku.me" {
t.Fatalf("expected Issuer acme email app@dokku.me, got %#v", acme["email"])
}
if acme["server"] != LetsencryptServerStag {
t.Fatalf("expected Issuer acme server %q, got %#v", LetsencryptServerStag, acme["server"])
}
}
func TestCertificateTemplateUsesManuallyManagedIssuer(t *testing.T) {
// A manually managed issuer is owned by the operator, so global.issuer is absent
// from values.yaml and Dokku must emit no Issuer of its own for either kind.
cases := []struct {
name string
kind string
}{
{name: "cluster scoped", kind: CertIssuerKindClusterIssuer},
{name: "namespaced", kind: CertIssuerKindIssuer},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
docs := renderCertificateChart(t, testCertificateValues(tc.kind, "acme-dns", nil))
issuerRef := certificateIssuerRef(t, docs)
if issuerRef["kind"] != tc.kind {
t.Fatalf("expected issuerRef.kind %s, got %#v", tc.kind, issuerRef["kind"])
}
if issuerRef["name"] != "acme-dns" {
t.Fatalf("expected issuerRef.name acme-dns, got %#v", issuerRef["name"])
}
if len(docs["issuer"]) != 0 {
t.Fatalf("expected no Issuer document for a manually managed issuer, got %#v", docs["issuer"])
}
})
}
}