mirror of
https://github.com/dokku/dokku.git
synced 2026-08-29 10:08:53 +02:00
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:
@@ -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 |
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
1
tests.mk
1
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 $$?
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user