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.
This commit is contained in:
Jose Diaz-Gonzalez
2026-08-08 15:16:12 -04:00
parent 2da48f4f86
commit d134e75371
14 changed files with 980 additions and 42 deletions

View File

@@ -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-<app-name>` 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 |

View File

@@ -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"])
}
})
}
}

View File

@@ -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,

View File

@@ -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.

View File

@@ -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)
}
})
}
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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.

View File

@@ -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{

View File

@@ -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{

View File

@@ -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 $$?

View File

@@ -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
}

View File

@@ -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