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

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