From d134e753712fe6545c754dfa7c579a7dc79e7f48 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 8 Aug 2026 15:16:12 -0400 Subject: [PATCH] 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. --- docs/deployment/schedulers/k3s.md | 79 ++++- .../certificate_template_test.go | 32 ++ plugins/scheduler-k3s/chart.go | 48 +-- plugins/scheduler-k3s/functions.go | 237 ++++++++++++- plugins/scheduler-k3s/functions_test.go | 324 ++++++++++++++++++ plugins/scheduler-k3s/k8s.go | 43 +++ plugins/scheduler-k3s/preview.go | 2 + plugins/scheduler-k3s/report.go | 34 ++ plugins/scheduler-k3s/scheduler_k3s.go | 19 + plugins/scheduler-k3s/subcommands.go | 17 + plugins/scheduler-k3s/triggers.go | 10 + tests.mk | 1 + tests/unit/scheduler-k3s-certs-deploy-2.bats | 133 +++++++ tests/unit/scheduler-k3s-report.bats | 43 ++- 14 files changed, 980 insertions(+), 42 deletions(-) diff --git a/docs/deployment/schedulers/k3s.md b/docs/deployment/schedulers/k3s.md index 9f03685fa..dad1ee926 100644 --- a/docs/deployment/schedulers/k3s.md +++ b/docs/deployment/schedulers/k3s.md @@ -409,7 +409,7 @@ The default value may be set by passing an empty value for the option. dokku scheduler-k3s:set --global letsencrypt-server staging ``` -Letsencrypt can be completely disabled for a given app by setting the `letsencrypt-server` to `false` +Automatic certificate issuance can be completely disabled for a given app by setting the `letsencrypt-server` to `false`. This is the single off switch for the app, and also disables a [manually managed issuer](#using-a-manually-managed-cert-manager-issuer). ```shell dokku scheduler-k3s:set node-js-app letsencrypt-server false @@ -421,6 +421,75 @@ The server can also be disabled globally, and then conditionally enabled on a pe dokku scheduler-k3s:set --global letsencrypt-server false ``` +Values are validated when the property is set, so a typo fails immediately rather than breaking the next deploy. The valid values are `prod`, `production`, `stag`, `staging`, and `false`. + +#### Using a manually managed cert-manager issuer + +Dokku's built-in letsencrypt integration uses an `http01` solver, which cannot issue wildcard certificates and cannot satisfy providers that require `dns01`. For those cases, create a cert-manager `Issuer` or `ClusterIssuer` yourself and point an app at it with the `cert-issuer-name` property. + +```shell +dokku scheduler-k3s:set node-js-app cert-issuer-name acme-dns +``` + +Dokku does not create, modify, or delete the issuer - it only references it from the app's generated `Certificate`. Any cert-manager issuer works, including non-ACME ones such as `selfSigned`, `ca`, or `vault`. + +The issuer kind defaults to `ClusterIssuer`. To reference a namespaced `Issuer`, set the `cert-issuer-kind` property: + +```shell +dokku scheduler-k3s:set node-js-app cert-issuer-kind Issuer +``` + +> [!WARNING] +> A namespaced `Issuer` must exist in the same namespace as the app, as configured by the `namespace` property. cert-manager cannot reference an `Issuer` across namespaces. + +Unlike the letsencrypt integration, no email property is required - setting `cert-issuer-name` is itself what enables https for the app. Certificates are requested for every domain attached to the app. + +Both properties can also be set globally, which enables https for every app that has domains, no imported certificate, and no `letsencrypt-server false`: + +```shell +dokku scheduler-k3s:set --global cert-issuer-name acme-dns +``` + +Certificate sources are resolved in the following order: + +1. A certificate imported via the `certs` plugin. +2. `letsencrypt-server` set to `false`, which disables issuance entirely. +3. `cert-issuer-name`, resolved app-first and then globally. +4. The built-in letsencrypt integration. + +Because an empty app-level property falls back to the global value, an app cannot return to the built-in letsencrypt integration by unsetting `cert-issuer-name` while a global value is configured. Set the app's `cert-issuer-name` to the reserved value `false` instead: + +```shell +dokku scheduler-k3s:set node-js-app cert-issuer-name false +``` + +As a consequence, an issuer literally named `false` cannot be referenced. + +The default value may be set by passing an empty value for the option, which falls the app back to the global value: + +```shell +dokku scheduler-k3s:set node-js-app cert-issuer-name +``` + +If the named issuer does not exist in the cluster, Dokku emits a warning before the build starts. The warning never blocks a deploy, since the issuer may be managed independently and applied later. When a certificate fails to issue, inspect it directly: + +```shell +kubectl describe certificate node-js-app-web -n default +``` + +##### Wildcard certificates + +A `dns01` issuer can issue wildcard certificates. Add the wildcard as a domain on the app, and it will be included in the generated `Certificate`: + +```shell +dokku domains:add node-js-app '*.node-js-app.com' +``` + +> [!WARNING] +> Wildcard domains require the `nginx` ingress class. The Traefik integration matches hosts exactly and will not route requests for a wildcard domain. + +Note that a Kubernetes wildcard host matches exactly one label, so `*.node-js-app.com` does not cover `node-js-app.com`. Add both domains if the apex should also be served. + #### Using imported SSL certificates SSL certificates imported via the `certs` plugin can be used with the k3s scheduler. When a certificate is imported, it is automatically synced to Kubernetes as a TLS secret and will be used for the app's ingress configuration. @@ -435,9 +504,9 @@ When a certificate is imported: - A Kubernetes TLS secret named `tls-` is created in the app's namespace - The ingress configuration is updated to use the imported certificate -- Automatic Let's Encrypt certificate generation is disabled for the app +- Automatic certificate generation is disabled for the app -Imported certificates take precedence over Let's Encrypt certificates. If you have both an imported certificate and Let's Encrypt configured, the imported certificate will be used. +Imported certificates take precedence over both Let's Encrypt and a [manually managed issuer](#using-a-manually-managed-cert-manager-issuer). If you have an imported certificate alongside either of those, the imported certificate will be used. To remove an imported certificate: @@ -1054,6 +1123,8 @@ If unspecified for any task, the default reservation will be `.1` CPU and `128Mi | Property | Scope | Default | Report flags | Description | |---|---|---|---|---| +| `cert-issuer-kind` | app + global | `ClusterIssuer` | `--scheduler-k3s-cert-issuer-kind`, `--scheduler-k3s-global-cert-issuer-kind`, `--scheduler-k3s-computed-cert-issuer-kind` | Kind of the manually managed cert-manager issuer referenced by `cert-issuer-name`, either `Issuer` or `ClusterIssuer` | +| `cert-issuer-name` | app + global | none | `--scheduler-k3s-cert-issuer-name`, `--scheduler-k3s-global-cert-issuer-name`, `--scheduler-k3s-computed-cert-issuer-name` | Name of a manually managed cert-manager issuer to request certificates from, taking precedence over the letsencrypt integration. Set to `false` to opt an app out of a global value | | `deploy-timeout` | app + global | `300s` | `--scheduler-k3s-deploy-timeout`, `--scheduler-k3s-global-deploy-timeout`, `--scheduler-k3s-computed-deploy-timeout` | Timeout for a single helm install/upgrade cycle | | `image-pull-secrets` | app + global | none | `--scheduler-k3s-image-pull-secrets`, `--scheduler-k3s-global-image-pull-secrets`, `--scheduler-k3s-computed-image-pull-secrets` | Comma-separated list of Kubernetes secret names used to pull private images | | `ingress-class` | global only | `nginx` | `--scheduler-k3s-global-ingress-class`, `--scheduler-k3s-computed-ingress-class` | IngressClass name used for app ingresses (e.g. `nginx`, `traefik`) | @@ -1062,7 +1133,7 @@ If unspecified for any task, the default reservation will be `.1` CPU and `128Mi | `kustomize-root-path` | app + global | `config/kustomize` | `--scheduler-k3s-kustomize-root-path`, `--scheduler-k3s-global-kustomize-root-path`, `--scheduler-k3s-computed-kustomize-root-path` | Path within the app to a kustomize root applied after the helm install | | `letsencrypt-email-prod` | app + global | none | `--scheduler-k3s-letsencrypt-email-prod`, `--scheduler-k3s-global-letsencrypt-email-prod`, `--scheduler-k3s-computed-letsencrypt-email-prod` | Contact email for production certificates. App-level values render a per-app namespaced Issuer; otherwise the shared production ClusterIssuer is used | | `letsencrypt-email-stag` | app + global | none | `--scheduler-k3s-letsencrypt-email-stag`, `--scheduler-k3s-global-letsencrypt-email-stag`, `--scheduler-k3s-computed-letsencrypt-email-stag` | Contact email for staging certificates. App-level values render a per-app namespaced Issuer; otherwise the shared staging ClusterIssuer is used | -| `letsencrypt-server` | app + global | `prod` | `--scheduler-k3s-letsencrypt-server`, `--scheduler-k3s-global-letsencrypt-server`, `--scheduler-k3s-computed-letsencrypt-server` | ACME directory (`prod` or `staging`) used for app certificates | +| `letsencrypt-server` | app + global | `prod` | `--scheduler-k3s-letsencrypt-server`, `--scheduler-k3s-global-letsencrypt-server`, `--scheduler-k3s-computed-letsencrypt-server` | ACME directory (`prod` or `staging`) used for app certificates, or `false` to disable all automatic certificate issuance | | `namespace` | app + global | `default` | `--scheduler-k3s-namespace`, `--scheduler-k3s-global-namespace`, `--scheduler-k3s-computed-namespace` | Kubernetes namespace into which the app's resources are installed | | `network-interface` | global only | `eth0` | `--scheduler-k3s-global-network-interface`, `--scheduler-k3s-computed-network-interface` | Host network interface used by k3s | | `node-sysctls-image` | global only | `busybox:1.36` | `--scheduler-k3s-global-node-sysctls-image` | Image used to apply node-level sysctls, override for air-gapped clusters | diff --git a/plugins/scheduler-k3s/certificate_template_test.go b/plugins/scheduler-k3s/certificate_template_test.go index 08d49d7e2..b5be82aa7 100644 --- a/plugins/scheduler-k3s/certificate_template_test.go +++ b/plugins/scheduler-k3s/certificate_template_test.go @@ -216,3 +216,35 @@ func TestCertificateTemplateRendersPerAppNamespacedIssuer(t *testing.T) { 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"]) + } + }) + } +} diff --git a/plugins/scheduler-k3s/chart.go b/plugins/scheduler-k3s/chart.go index 0509a81a5..f7b51f86a 100644 --- a/plugins/scheduler-k3s/chart.go +++ b/plugins/scheduler-k3s/chart.go @@ -16,7 +16,6 @@ import ( "github.com/dokku/dokku/plugins/config" "github.com/dokku/dokku/plugins/cron" "github.com/dokku/dokku/plugins/registry" - "github.com/gosimple/slug" "github.com/kballard/go-shellquote" ) @@ -118,33 +117,12 @@ func BuildAppChart(ctx context.Context, appName, imageTag string, opts BuildOpti } } - tlsEnabled := false - issuerKind := "" - issuerName := "" - useImportedCert := false - appIssuer := AppIssuer{} - - if importedCertExists { - tlsEnabled = true - useImportedCert = true - } else { - server := getComputedLetsencryptServer(appName) - - switch server { - case "prod", "production": - computedEmail := getComputedLetsencryptEmailProd(appName) - tlsEnabled = computedEmail != "" - issuerKind, issuerName, appIssuer = resolveLetsencryptIssuer(appName, "letsencrypt-prod", getLetsencryptEmailProd(appName), computedEmail, LetsencryptServerProd) - case "stag", "staging": - computedEmail := getComputedLetsencryptEmailStag(appName) - tlsEnabled = computedEmail != "" - issuerKind, issuerName, appIssuer = resolveLetsencryptIssuer(appName, "letsencrypt-stag", getLetsencryptEmailStag(appName), computedEmail, LetsencryptServerStag) - case "false": - issuerName = "" - tlsEnabled = false - default: - return result, fmt.Errorf("Invalid letsencrypt server config: %s", server) - } + tlsConfig, err := resolveAppTLSConfig(ResolveAppTLSConfigInput{ + AppName: appName, + ImportedCertExists: importedCertExists, + }) + if err != nil { + return result, err } chartDir, err := os.MkdirTemp("", "dokku-chart-") @@ -299,7 +277,7 @@ func BuildAppChart(ctx context.Context, appName, imageTag string, opts BuildOpti Type: imageSourceType, WorkingDir: workingDir, }, - Issuer: appIssuer, + Issuer: tlsConfig.Issuer, Labels: globalLabels, Namespace: namespace, Network: GlobalNetwork{ @@ -415,7 +393,7 @@ func BuildAppChart(ctx context.Context, appName, imageTag string, opts BuildOpti for _, domain := range domains { domainValues = append(domainValues, ProcessDomains{ Name: domain, - Slug: slug.Make(domain), + Slug: domainSlug(domain), }) } @@ -423,10 +401,10 @@ func BuildAppChart(ctx context.Context, appName, imageTag string, opts BuildOpti Domains: domainValues, PortMaps: []ProcessPortMap{}, TLS: ProcessTls{ - Enabled: tlsEnabled, - IssuerKind: issuerKind, - IssuerName: issuerName, - UseImportedCert: useImportedCert, + Enabled: tlsConfig.Enabled, + IssuerKind: tlsConfig.IssuerKind, + IssuerName: tlsConfig.IssuerName, + UseImportedCert: tlsConfig.UseImportedCert, }, } @@ -449,7 +427,7 @@ func BuildAppChart(ctx context.Context, appName, imageTag string, opts BuildOpti for _, portMap := range processValues.Web.PortMaps { _, httpOk := portMaps[fmt.Sprintf("http-80-%d", portMap.ContainerPort)] _, httpsOk := portMaps[fmt.Sprintf("https-443-%d", portMap.ContainerPort)] - if portMap.Scheme == "http" && !httpsOk && tlsEnabled { + if portMap.Scheme == "http" && !httpsOk && tlsConfig.Enabled { processValues.Web.PortMaps = append(processValues.Web.PortMaps, ProcessPortMap{ ContainerPort: portMap.ContainerPort, HostPort: 443, diff --git a/plugins/scheduler-k3s/functions.go b/plugins/scheduler-k3s/functions.go index d024ab6ef..65007b280 100644 --- a/plugins/scheduler-k3s/functions.go +++ b/plugins/scheduler-k3s/functions.go @@ -24,6 +24,7 @@ import ( "github.com/dokku/dokku/plugins/logs" nginxvhosts "github.com/dokku/dokku/plugins/nginx-vhosts" resty "github.com/go-resty/resty/v2" + "github.com/gosimple/slug" kedav1alpha1 "github.com/kedacore/keda/v2/apis/keda/v1alpha1" "golang.org/x/sync/errgroup" "gopkg.in/yaml.v3" @@ -33,6 +34,7 @@ import ( k8serrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/kubernetes/pkg/client/conditions" "k8s.io/utils/ptr" @@ -1213,6 +1215,86 @@ func getLabel(appName string, processType string, resourceType string) (map[stri return common.PropertyMapGet("scheduler-k3s", appName, fmt.Sprintf("labels.%s.%s", processType, resourceType)) } +func getCertIssuerName(appName string) string { + return common.PropertyGet("scheduler-k3s", appName, "cert-issuer-name") +} + +func getGlobalCertIssuerName() string { + return common.PropertyGet("scheduler-k3s", "--global", "cert-issuer-name") +} + +func getComputedCertIssuerName(appName string) string { + certIssuerName := getCertIssuerName(appName) + if certIssuerName == "" { + certIssuerName = getGlobalCertIssuerName() + } + + return certIssuerName +} + +func getCertIssuerKind(appName string) string { + return common.PropertyGet("scheduler-k3s", appName, "cert-issuer-kind") +} + +func getGlobalCertIssuerKind() string { + return common.PropertyGet("scheduler-k3s", "--global", "cert-issuer-kind") +} + +func getComputedCertIssuerKind(appName string) string { + certIssuerKind := getCertIssuerKind(appName) + if certIssuerKind == "" { + certIssuerKind = getGlobalCertIssuerKind() + } + if certIssuerKind == "" { + certIssuerKind = CertIssuerKindClusterIssuer + } + + return certIssuerKind +} + +// normalizeCertIssuerKind validates a cert-issuer-kind value case-insensitively and +// returns it in the casing cert-manager expects, as the value is interpolated +// directly into the rendered Certificate manifest. An empty value is left as-is so +// the property can still be unset. +func normalizeCertIssuerKind(value string) (string, error) { + switch strings.ToLower(value) { + case "": + return "", nil + case strings.ToLower(CertIssuerKindIssuer): + return CertIssuerKindIssuer, nil + case strings.ToLower(CertIssuerKindClusterIssuer): + return CertIssuerKindClusterIssuer, nil + } + + return "", fmt.Errorf("Invalid cert-issuer-kind: %s, valid values are %s and %s", value, CertIssuerKindIssuer, CertIssuerKindClusterIssuer) +} + +// validateCertIssuerName ensures a cert-issuer-name refers to a legal Kubernetes +// object name. An empty value unsets the property and the reserved disabled value +// opts an app out of a globally configured issuer, so neither is name-checked. +func validateCertIssuerName(value string) error { + if value == "" || value == CertIssuerNameDisabled { + return nil + } + + if errs := validation.IsDNS1123Subdomain(value); len(errs) > 0 { + return fmt.Errorf("Invalid cert-issuer-name: %s, %s", value, strings.Join(errs, ", ")) + } + + return nil +} + +// validateLetsencryptServer ensures a letsencrypt-server value is one the deploy can +// act on, so a typo fails when it is set rather than breaking every subsequent deploy. +func validateLetsencryptServer(value string) error { + switch value { + case "", "prod", "production", "stag", "staging", LetsencryptServerDisabled: + return nil + } + + return fmt.Errorf("Invalid letsencrypt-server: %s, valid values are prod, production, stag, staging, and %s", value, LetsencryptServerDisabled) +} + func getLetsencryptServer(appName string) string { return common.PropertyGet("scheduler-k3s", appName, "letsencrypt-server") } @@ -1277,7 +1359,7 @@ func resolveLetsencryptIssuer(appName string, clusterIssuerName string, appEmail } issuerName := fmt.Sprintf("%s-%s", appName, clusterIssuerName) - return "Issuer", issuerName, AppIssuer{ + return CertIssuerKindIssuer, issuerName, AppIssuer{ Email: computedEmail, Enabled: true, IngressClass: getComputedIngressClass(), @@ -1286,6 +1368,159 @@ func resolveLetsencryptIssuer(appName string, clusterIssuerName string, appEmail } } +// AppTLSConfig is the resolved certificate configuration for an app's web process. +type AppTLSConfig struct { + // Enabled is whether the app serves https and has a certificate source + Enabled bool + + // Issuer is a namespaced cert-manager Issuer for Dokku to render into the app's + // own chart. It is zero-valued whenever Dokku manages no issuer for the app. + Issuer AppIssuer + + // IssuerKind is the cert-manager kind the app's Certificate references + IssuerKind string + + // IssuerName is the cert-manager issuer name the app's Certificate references + IssuerName string + + // UseImportedCert is whether the app serves a certificate imported via the certs plugin + UseImportedCert bool + + // UsesCustomIssuer is whether IssuerName refers to an issuer the operator manages + // rather than one Dokku creates + UsesCustomIssuer bool +} + +// ResolveAppTLSConfigInput contains the inputs to resolveAppTLSConfig +type ResolveAppTLSConfigInput struct { + // AppName is the app being resolved + AppName string + + // ImportedCertExists is whether a certificate imported via the certs plugin is + // available for the app + ImportedCertExists bool +} + +// resolveAppTLSConfig determines which certificate source an app's web process uses. +// An imported certificate wins outright, then the letsencrypt-server kill switch, +// then a manually managed issuer named by cert-issuer-name, and finally the built-in +// letsencrypt flow. It reads only properties so it can be exercised without a cluster. +func resolveAppTLSConfig(input ResolveAppTLSConfigInput) (AppTLSConfig, error) { + appName := input.AppName + if input.ImportedCertExists { + return AppTLSConfig{ + Enabled: true, + UseImportedCert: true, + }, nil + } + + server := getComputedLetsencryptServer(appName) + if server == LetsencryptServerDisabled { + return AppTLSConfig{}, nil + } + + certIssuerName := getComputedCertIssuerName(appName) + if certIssuerName != "" && certIssuerName != CertIssuerNameDisabled { + certIssuerKind, err := normalizeCertIssuerKind(getComputedCertIssuerKind(appName)) + if err != nil { + return AppTLSConfig{}, err + } + + return AppTLSConfig{ + Enabled: true, + IssuerKind: certIssuerKind, + IssuerName: certIssuerName, + UsesCustomIssuer: true, + }, nil + } + + switch server { + case "prod", "production": + computedEmail := getComputedLetsencryptEmailProd(appName) + issuerKind, issuerName, appIssuer := resolveLetsencryptIssuer(appName, "letsencrypt-prod", getLetsencryptEmailProd(appName), computedEmail, LetsencryptServerProd) + return AppTLSConfig{ + Enabled: computedEmail != "", + Issuer: appIssuer, + IssuerKind: issuerKind, + IssuerName: issuerName, + }, nil + case "stag", "staging": + computedEmail := getComputedLetsencryptEmailStag(appName) + issuerKind, issuerName, appIssuer := resolveLetsencryptIssuer(appName, "letsencrypt-stag", getLetsencryptEmailStag(appName), computedEmail, LetsencryptServerStag) + return AppTLSConfig{ + Enabled: computedEmail != "", + Issuer: appIssuer, + IssuerKind: issuerKind, + IssuerName: issuerName, + }, nil + } + + return AppTLSConfig{}, fmt.Errorf("Invalid letsencrypt server config: %s", server) +} + +// warnMissingCertIssuer warns when an app references a manually managed cert-manager +// issuer that is not present in the cluster. Without it a deploy succeeds while +// cert-manager sits on IssuerNotFound and the ingress serves its default certificate. +// Every failure is swallowed: this is advisory only and must never interrupt a deploy. +func warnMissingCertIssuer(ctx context.Context, appName string) { + tlsConfig, err := resolveAppTLSConfig(ResolveAppTLSConfigInput{ + AppName: appName, + ImportedCertExists: HasImportedTLSCert(appName), + }) + if err != nil { + common.LogDebug(fmt.Sprintf("Skipping cert issuer check for %s: %v", appName, err)) + return + } + + if !tlsConfig.UsesCustomIssuer { + return + } + + if err := isKubernetesAvailable(); err != nil { + common.LogDebug(fmt.Sprintf("Skipping cert issuer check for %s: %v", appName, err)) + return + } + + clientset, err := NewKubernetesClient() + if err != nil { + common.LogDebug(fmt.Sprintf("Skipping cert issuer check for %s: %v", appName, err)) + return + } + + namespace := getComputedNamespace(appName) + exists, err := clientset.CertIssuerExists(ctx, CertIssuerExistsInput{ + Kind: tlsConfig.IssuerKind, + Name: tlsConfig.IssuerName, + Namespace: namespace, + }) + if err != nil { + common.LogDebug(fmt.Sprintf("Skipping cert issuer check for %s: %v", appName, err)) + return + } + + if exists { + return + } + + if tlsConfig.IssuerKind == CertIssuerKindIssuer { + common.LogWarn(fmt.Sprintf("Issuer %s not found in namespace %s, certificates will not be issued for %s", tlsConfig.IssuerName, namespace, appName)) + return + } + + common.LogWarn(fmt.Sprintf("ClusterIssuer %s not found, certificates will not be issued for %s", tlsConfig.IssuerName, appName)) +} + +// domainSlug renders a domain as a Kubernetes object name suffix. A leading wildcard +// label is spelled out rather than stripped, so that a wildcard domain and its parent +// do not collapse onto the same generated name. +func domainSlug(domain string) string { + if strings.HasPrefix(domain, "*.") { + return fmt.Sprintf("wildcard-%s", slug.Make(strings.TrimPrefix(domain, "*."))) + } + + return slug.Make(domain) +} + // nodeLabels returns the labels to apply to a node joining the cluster, including // the node profile label when the node was added with a named profile. The returned // map is always a fresh copy so callers cannot mutate ServerLabels or WorkerLabels. diff --git a/plugins/scheduler-k3s/functions_test.go b/plugins/scheduler-k3s/functions_test.go index 6edcfbf6c..aad7ab8c6 100644 --- a/plugins/scheduler-k3s/functions_test.go +++ b/plugins/scheduler-k3s/functions_test.go @@ -1,8 +1,10 @@ package scheduler_k3s import ( + "strings" "testing" + "github.com/dokku/dokku/plugins/common" corev1 "k8s.io/api/core/v1" ) @@ -357,3 +359,325 @@ func TestParseSysctls(t *testing.T) { }) } } + +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) + } + }) + } +} diff --git a/plugins/scheduler-k3s/k8s.go b/plugins/scheduler-k3s/k8s.go index 8f3e338b8..664217d3f 100644 --- a/plugins/scheduler-k3s/k8s.go +++ b/plugins/scheduler-k3s/k8s.go @@ -25,6 +25,7 @@ import ( batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -684,6 +685,48 @@ func (k KubernetesClient) GetSecret(ctx context.Context, input GetSecretInput) ( return *secret, err } +// CertIssuerExistsInput contains all the information needed to look up a cert-manager issuer +type CertIssuerExistsInput struct { + // Kind is the cert-manager issuer kind, either Issuer or ClusterIssuer + Kind string + + // Name is the cert-manager issuer name + Name string + + // Namespace is the namespace to search for a namespaced Issuer. It is ignored + // for a cluster-scoped ClusterIssuer. + Namespace string +} + +// CertIssuerExists reports whether a cert-manager issuer of the given kind and name +// exists. A missing issuer returns false with no error, while an unreachable cluster, +// an absent cert-manager CRD, or insufficient permissions return an error so callers +// can distinguish "not there" from "could not tell". +func (k KubernetesClient) CertIssuerExists(ctx context.Context, input CertIssuerExistsInput) (bool, error) { + resource := "clusterissuers" + namespace := "" + if input.Kind == CertIssuerKindIssuer { + resource = "issuers" + namespace = input.Namespace + } + + gvr := schema.GroupVersionResource{ + Group: "cert-manager.io", + Version: "v1", + Resource: resource, + } + + _, err := k.DynamicClient.Resource(gvr).Namespace(namespace).Get(ctx, input.Name, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + return false, nil + } + return false, err + } + + return true, nil +} + // LabelNodeInput contains all the information needed to label a Kubernetes node type LabelNodeInput struct { // Name is the Kubernetes node name diff --git a/plugins/scheduler-k3s/preview.go b/plugins/scheduler-k3s/preview.go index 6dd1fecf4..6cf10ee9c 100644 --- a/plugins/scheduler-k3s/preview.go +++ b/plugins/scheduler-k3s/preview.go @@ -53,6 +53,8 @@ func CommandPreview(appName string, diffContext int, showSecrets bool, showSecre cancel() }() + warnMissingCertIssuer(ctx, appName) + namespace := getComputedNamespace(appName) helmAgent, err := NewHelmAgent(namespace, DevNullPrinter) if err != nil { diff --git a/plugins/scheduler-k3s/report.go b/plugins/scheduler-k3s/report.go index aee3c564c..d88f0177d 100644 --- a/plugins/scheduler-k3s/report.go +++ b/plugins/scheduler-k3s/report.go @@ -40,6 +40,10 @@ func ReportSingleApp(appName string, format string, infoFlag string) error { } flags = map[string]common.ReportFunc{ + "--scheduler-k3s-computed-cert-issuer-kind": reportComputedCertIssuerKind, + "--scheduler-k3s-global-cert-issuer-kind": reportGlobalCertIssuerKind, + "--scheduler-k3s-computed-cert-issuer-name": reportComputedCertIssuerName, + "--scheduler-k3s-global-cert-issuer-name": reportGlobalCertIssuerName, "--scheduler-k3s-computed-deploy-timeout": reportComputedDeployTimeout, "--scheduler-k3s-global-deploy-timeout": reportGlobalDeployTimeout, "--scheduler-k3s-computed-image-pull-secrets": reportComputedImagePullSecrets, @@ -72,6 +76,12 @@ func ReportSingleApp(appName string, format string, infoFlag string) error { } } else { flags = map[string]common.ReportFunc{ + "--scheduler-k3s-computed-cert-issuer-kind": reportComputedCertIssuerKind, + "--scheduler-k3s-cert-issuer-kind": reportCertIssuerKind, + "--scheduler-k3s-global-cert-issuer-kind": reportGlobalCertIssuerKind, + "--scheduler-k3s-computed-cert-issuer-name": reportComputedCertIssuerName, + "--scheduler-k3s-cert-issuer-name": reportCertIssuerName, + "--scheduler-k3s-global-cert-issuer-name": reportGlobalCertIssuerName, "--scheduler-k3s-computed-deploy-timeout": reportComputedDeployTimeout, "--scheduler-k3s-deploy-timeout": reportDeployTimeout, "--scheduler-k3s-global-deploy-timeout": reportGlobalDeployTimeout, @@ -651,6 +661,30 @@ func reportGlobalKubeContext(appName string) string { return getGlobalKubeContext() } +func reportComputedCertIssuerKind(appName string) string { + return getComputedCertIssuerKind(appName) +} + +func reportCertIssuerKind(appName string) string { + return getCertIssuerKind(appName) +} + +func reportGlobalCertIssuerKind(appName string) string { + return getGlobalCertIssuerKind() +} + +func reportComputedCertIssuerName(appName string) string { + return getComputedCertIssuerName(appName) +} + +func reportCertIssuerName(appName string) string { + return getCertIssuerName(appName) +} + +func reportGlobalCertIssuerName(appName string) string { + return getGlobalCertIssuerName() +} + func reportComputedLetsencryptServer(appName string) string { return getComputedLetsencryptServer(appName) } diff --git a/plugins/scheduler-k3s/scheduler_k3s.go b/plugins/scheduler-k3s/scheduler_k3s.go index 49040dbb0..dd3783436 100644 --- a/plugins/scheduler-k3s/scheduler_k3s.go +++ b/plugins/scheduler-k3s/scheduler_k3s.go @@ -18,6 +18,8 @@ import ( var ( // DefaultProperties is a map of all valid k3s properties with corresponding default property values DefaultProperties = map[string]string{ + "cert-issuer-kind": "", + "cert-issuer-name": "", "deploy-timeout": "", "letsencrypt-email-prod": "", "letsencrypt-email-stag": "", @@ -31,6 +33,8 @@ var ( // GlobalProperties is a map of all valid global k3s properties GlobalProperties = map[string]bool{ + "cert-issuer-kind": true, + "cert-issuer-name": true, "deploy-timeout": true, "image-pull-secrets": true, "ingress-class": true, @@ -58,6 +62,21 @@ const TriggerAuthPropertyPrefix = "trigger-auth." const LetsencryptServerProd = "https://acme-v02.api.letsencrypt.org/directory" const LetsencryptServerStag = "https://acme-staging-v02.api.letsencrypt.org/directory" +// CertIssuerKindIssuer is the namespaced cert-manager issuer kind +const CertIssuerKindIssuer = "Issuer" + +// CertIssuerKindClusterIssuer is the cluster-scoped cert-manager issuer kind, and +// the default kind used when an app references a manually managed issuer +const CertIssuerKindClusterIssuer = "ClusterIssuer" + +// CertIssuerNameDisabled is the reserved cert-issuer-name value that opts a single +// app out of a globally configured manually managed issuer +const CertIssuerNameDisabled = "false" + +// LetsencryptServerDisabled is the letsencrypt-server value that disables all +// automatic certificate issuance for an app +const LetsencryptServerDisabled = "false" + // AnnotationResourceTypes lists the kubernetes resource types that scheduler-k3s // supports user-provided annotations for. The order here is also the iteration order // used when rendering reports. diff --git a/plugins/scheduler-k3s/subcommands.go b/plugins/scheduler-k3s/subcommands.go index 1ac72d72c..65f40ca0b 100644 --- a/plugins/scheduler-k3s/subcommands.go +++ b/plugins/scheduler-k3s/subcommands.go @@ -1435,6 +1435,23 @@ func CommandSet(appName string, property string, value string) error { return nil } + switch property { + case "cert-issuer-kind": + normalized, err := normalizeCertIssuerKind(value) + if err != nil { + return err + } + value = normalized + case "cert-issuer-name": + if err := validateCertIssuerName(value); err != nil { + return err + } + case "letsencrypt-server": + if err := validateLetsencryptServer(value); err != nil { + return err + } + } + common.CommandPropertySet("scheduler-k3s", appName, property, value, validProperties, globalProperties) letsencryptProperties := map[string]bool{ diff --git a/plugins/scheduler-k3s/triggers.go b/plugins/scheduler-k3s/triggers.go index ad235a21b..49807d2fa 100644 --- a/plugins/scheduler-k3s/triggers.go +++ b/plugins/scheduler-k3s/triggers.go @@ -41,7 +41,17 @@ func TriggerCorePostDeploy(appName string) error { } // TriggerCorePostExtract moves a configured kustomize root path to be in the app root dir +// and warns about a missing cert issuer before any build work is performed func TriggerCorePostExtract(appName string, sourceWorkDir string) error { + scheduler := common.PropertyGetDefault("scheduler", appName, "selected", "") + globalScheduler := common.PropertyGetDefault("scheduler", "--global", "selected", "docker-local") + if scheduler == "" { + scheduler = globalScheduler + } + if scheduler == "k3s" { + warnMissingCertIssuer(context.Background(), appName) + } + destination := common.GetAppDataDirectory("scheduler-k3s", appName) kustomizeRootPath := getComputedKustomizeRootPath(appName) return common.CorePostExtract(common.CorePostExtractInput{ diff --git a/tests.mk b/tests.mk index c4ad7c2c3..be7eb8a6b 100644 --- a/tests.mk +++ b/tests.mk @@ -184,6 +184,7 @@ go-tests: @$(MAKE) go-test-plugin PLUGIN_NAME=docker-options @$(MAKE) go-test-plugin PLUGIN_NAME=network @$(MAKE) go-test-plugin PLUGIN_NAME=buildpacks + @$(MAKE) go-test-plugin PLUGIN_NAME=scheduler-k3s go-test-plugin: cd plugins/$(PLUGIN_NAME) && go get github.com/onsi/gomega && DOKKU_ROOT=/home/dokku DOKKU_LIB_ROOT=/var/lib/dokku go test -v -p 1 -race -mod=readonly || exit $$? diff --git a/tests/unit/scheduler-k3s-certs-deploy-2.bats b/tests/unit/scheduler-k3s-certs-deploy-2.bats index fc7a5b8c5..e4b8e4709 100644 --- a/tests/unit/scheduler-k3s-certs-deploy-2.bats +++ b/tests/unit/scheduler-k3s-certs-deploy-2.bats @@ -12,6 +12,7 @@ setup() { } teardown() { + dokku scheduler-k3s:set --global cert-issuer-name >/dev/null 2>/dev/null || true global_teardown dokku nginx:start uninstall_k3s || true @@ -72,3 +73,135 @@ teardown() { assert_success assert_output "app@dokku.me" } + +@test "(scheduler-k3s:certs) a manually managed cert issuer issues the app certificate" { + if [[ -z "$DOCKERHUB_USERNAME" ]] || [[ -z "$DOCKERHUB_TOKEN" ]]; then + skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN" + fi + + INGRESS_CLASS=nginx install_k3s + + run /bin/bash -c "dokku apps:create $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku domains:set $TEST_APP $TEST_APP.dokku.me" + echo "output: $output" + echo "status: $status" + assert_success + + # no letsencrypt email is ever set, so tls is enabled purely by cert-issuer-name + run /bin/bash -c "dokku scheduler-k3s:set $TEST_APP cert-issuer-name dokku-test-selfsigned" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku scheduler-k3s:preview $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "ClusterIssuer dokku-test-selfsigned not found" + + create_selfsigned_cluster_issuer + + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "kubectl get certificate ${TEST_APP}-web -n default -o jsonpath='{.spec.issuerRef.kind}'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "ClusterIssuer" + + run /bin/bash -c "kubectl get certificate ${TEST_APP}-web -n default -o jsonpath='{.spec.issuerRef.name}'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "dokku-test-selfsigned" + + # a selfSigned issuer issues immediately, so the secret proves end-to-end issuance + run /bin/bash -c "kubectl wait --for=condition=Ready certificate/${TEST_APP}-web -n default --timeout=120s" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "kubectl get secret tls-${TEST_APP}-web -n default -o jsonpath='{.type}'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "kubernetes.io/tls" + + run /bin/bash -c "kubectl get ingress ${TEST_APP}-web-${TEST_APP}-dokku-me -n default -o jsonpath='{.spec.tls[0].secretName}'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "tls-${TEST_APP}-web" +} + +@test "(scheduler-k3s:certs) letsencrypt-server false disables a global cert issuer" { + if [[ -z "$DOCKERHUB_USERNAME" ]] || [[ -z "$DOCKERHUB_TOKEN" ]]; then + skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN" + fi + + INGRESS_CLASS=nginx install_k3s + + create_selfsigned_cluster_issuer + + run /bin/bash -c "dokku scheduler-k3s:set --global cert-issuer-name dokku-test-selfsigned" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku apps:create $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku scheduler-k3s:set $TEST_APP letsencrypt-server false" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku domains:set $TEST_APP $TEST_APP.dokku.me" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app python "dokku@$DOKKU_DOMAIN:$TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "kubectl get certificate ${TEST_APP}-web -n default" + echo "output: $output" + echo "status: $status" + assert_failure + + run /bin/bash -c "kubectl get ingress ${TEST_APP}-web-${TEST_APP}-dokku-me -n default -o jsonpath='{.spec.tls}'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "" +} + +create_selfsigned_cluster_issuer() { + declare desc="creates a selfSigned ClusterIssuer that issues certificates without acme" + + local manifest="${BATS_TMPDIR:-/tmp}/dokku-test-selfsigned.yaml" + cat >"$manifest" <<'EOF' +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: dokku-test-selfsigned +spec: + selfSigned: {} +EOF + + run /bin/bash -c "kubectl apply -f $manifest" + echo "output: $output" + echo "status: $status" + assert_success +} diff --git a/tests/unit/scheduler-k3s-report.bats b/tests/unit/scheduler-k3s-report.bats index fed4e8255..f70d6fd6c 100644 --- a/tests/unit/scheduler-k3s-report.bats +++ b/tests/unit/scheduler-k3s-report.bats @@ -7,7 +7,7 @@ setup() { } teardown() { - for prop in deploy-timeout image-pull-secrets ingress-class kubeconfig-path kube-context kustomize-root-path namespace network-interface rollback-on-failure shm-size token; do + for prop in cert-issuer-kind cert-issuer-name deploy-timeout image-pull-secrets ingress-class kubeconfig-path kube-context kustomize-root-path namespace network-interface rollback-on-failure shm-size token; do dokku scheduler-k3s:set --global "$prop" >/dev/null 2>/dev/null || true done global_teardown @@ -55,10 +55,11 @@ assert_k3s_global_unset_set() { assert_k3s_global_unset_set "ingress-class" "nginx" "traefik" assert_k3s_global_unset_set "network-interface" "eth0" "eth1" assert_k3s_global_unset_set "kubeconfig-path" "/etc/rancher/k3s/k3s.yaml" "/tmp/custom-kubeconfig.yaml" + assert_k3s_global_unset_set "cert-issuer-kind" "ClusterIssuer" "Issuer" } @test "(scheduler-k3s:report --global) empty-default properties expose computed sibling" { - for prop in image-pull-secrets shm-size kube-context; do + for prop in image-pull-secrets shm-size kube-context cert-issuer-name; do run /bin/bash -c "dokku scheduler-k3s:set --global $prop" assert_success @@ -77,6 +78,16 @@ assert_k3s_global_unset_set() { assert_success run /bin/bash -c "dokku scheduler-k3s:set --global kube-context my-context" assert_success + run /bin/bash -c "dokku scheduler-k3s:set --global cert-issuer-name acme-dns" + assert_success + + run /bin/bash -c "dokku scheduler-k3s:report --global --format json | jq -r '.\"scheduler-k3s-global-cert-issuer-name\"'" + assert_success + assert_output "acme-dns" + + run /bin/bash -c "dokku scheduler-k3s:report --global --format json | jq -r '.\"scheduler-k3s-computed-cert-issuer-name\"'" + assert_success + assert_output "acme-dns" run /bin/bash -c "dokku scheduler-k3s:report --global --format json | jq -r '.\"scheduler-k3s-global-image-pull-secrets\"'" assert_success @@ -123,6 +134,34 @@ assert_k3s_global_unset_set() { assert_output "" } +@test "(scheduler-k3s:set) rejects invalid cert issuer and letsencrypt server values" { + run /bin/bash -c "dokku apps:create $TEST_APP" + assert_success + + run /bin/bash -c "dokku scheduler-k3s:set $TEST_APP cert-issuer-kind Certificate" + assert_failure + assert_output_contains "Invalid cert-issuer-kind" + + run /bin/bash -c "dokku scheduler-k3s:set $TEST_APP cert-issuer-name my_own_issuer_name" + assert_failure + assert_output_contains "Invalid cert-issuer-name" + + run /bin/bash -c "dokku scheduler-k3s:set $TEST_APP letsencrypt-server prodd" + assert_failure + assert_output_contains "Invalid letsencrypt-server" + + run /bin/bash -c "dokku scheduler-k3s:report $TEST_APP --format json | jq -r '.\"scheduler-k3s-cert-issuer-kind\"'" + assert_success + assert_output "" + + run /bin/bash -c "dokku scheduler-k3s:set $TEST_APP cert-issuer-kind clusterissuer" + assert_success + + run /bin/bash -c "dokku scheduler-k3s:report $TEST_APP --format json | jq -r '.\"scheduler-k3s-cert-issuer-kind\"'" + assert_success + assert_output "ClusterIssuer" +} + @test "(scheduler-k3s:report) app-level letsencrypt email overrides global and falls back when unset" { run /bin/bash -c "dokku apps:create $TEST_APP" assert_success