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.
251 lines
7.9 KiB
Go
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"])
|
|
}
|
|
})
|
|
}
|
|
}
|