Merge pull request #8262 from dokku/7257-import-certificate-into-k8s-app

Use certificates imported by certs plugin when deploying via scheduler-k3s
This commit is contained in:
Jose Diaz-Gonzalez
2026-01-08 12:46:30 -05:00
committed by GitHub
16 changed files with 899 additions and 38 deletions

24
plugins/certs/certs-get Executable file
View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
source "$PLUGIN_AVAILABLE_PATH/certs/functions"
set -eo pipefail
[[ $DOKKU_TRACE ]] && set -x
trigger-certs-certs-get() {
declare desc="returns certificate or key content"
declare trigger="certs-get"
declare APP="$1" KEY_TYPE="$2"
local APP_SSL_PATH="$DOKKU_ROOT/$APP/tls"
if [[ "$KEY_TYPE" != "key" ]] && [[ "$KEY_TYPE" != "crt" ]]; then
return 1
fi
if ! is_ssl_enabled "$APP"; then
return 1
fi
cat "$APP_SSL_PATH/server.$KEY_TYPE"
}
trigger-certs-certs-get "$@"

View File

@@ -6,3 +6,4 @@
/install
/post-*
/report
/core-*

View File

@@ -1,5 +1,5 @@
SUBCOMMANDS = subcommands/annotations:set subcommands/autoscaling-auth:set subcommands/autoscaling-auth:report subcommands/cluster:add subcommands/cluster:list subcommands/cluster:remove subcommands/ensure-charts subcommands/initialize subcommands/labels:set subcommands/profiles:add subcommands/profiles:list subcommands/profiles:remove subcommands/report subcommands/set subcommands/show-kubeconfig subcommands/uninstall
TRIGGERS = triggers/core-post-deploy triggers/core-post-extract triggers/install triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-create triggers/post-delete triggers/report triggers/scheduler-app-status triggers/scheduler-deploy triggers/scheduler-enter triggers/scheduler-is-deployed triggers/scheduler-logs triggers/scheduler-proxy-config triggers/scheduler-proxy-logs triggers/scheduler-post-delete triggers/scheduler-run triggers/scheduler-run-list triggers/scheduler-stop triggers/scheduler-cron-write
TRIGGERS = triggers/core-post-deploy triggers/core-post-extract triggers/install triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-certs-update triggers/post-certs-remove triggers/post-create triggers/post-delete triggers/report triggers/scheduler-app-status triggers/scheduler-deploy triggers/scheduler-enter triggers/scheduler-is-deployed triggers/scheduler-logs triggers/scheduler-proxy-config triggers/scheduler-proxy-logs triggers/scheduler-post-delete triggers/scheduler-run triggers/scheduler-run-list triggers/scheduler-stop triggers/scheduler-cron-write
BUILD = commands subcommands triggers
PLUGIN_NAME = scheduler-k3s

View File

@@ -1262,6 +1262,67 @@ func getGlobalGlobalToken() string {
return common.PropertyGet("scheduler-k3s", "--global", "token")
}
func getDeployedAppImageTag(appName string) (string, error) {
appValues, err := helmValuesForApp(appName)
if err != nil {
return "", err
}
if appValues.Global.Image.Name == "" {
return "", fmt.Errorf("image name not found in helm release")
}
return appValues.Global.Image.Name, nil
}
func helmValuesForApp(appName string) (AppValues, error) {
namespace := getComputedNamespace(appName)
helmAgent, err := NewHelmAgent(namespace, DevNullPrinter)
if err != nil {
return AppValues{}, fmt.Errorf("error creating helm agent: %w", err)
}
exists, err := helmAgent.ChartExists(appName)
if err != nil {
return AppValues{}, fmt.Errorf("error checking if chart exists: %w", err)
}
if !exists {
return AppValues{}, fmt.Errorf("app %s is not deployed", appName)
}
values, err := helmAgent.GetValues(appName)
if err != nil {
return AppValues{}, fmt.Errorf("error getting helm values: %w", err)
}
b, err := yaml.Marshal(values)
if err != nil {
return AppValues{}, fmt.Errorf("error marshaling helm values: %w", err)
}
var appValues AppValues
if err := yaml.Unmarshal(b, &appValues); err != nil {
return AppValues{}, fmt.Errorf("error unmarshaling helm values: %w", err)
}
return appValues, nil
}
func isAppDeployed(appName string) bool {
namespace := getComputedNamespace(appName)
helmAgent, err := NewHelmAgent(namespace, DevNullPrinter)
if err != nil {
return false
}
exists, err := helmAgent.ChartExists(appName)
if err != nil {
return false
}
return exists
}
func getProcessHealtchecks(healthchecks []appjson.Healthcheck, primaryPort int32) ProcessHealthchecks {
if len(healthchecks) == 0 {
return ProcessHealthchecks{}

View File

@@ -42,6 +42,12 @@ func main() {
case "post-create":
appName := flag.Arg(0)
err = scheduler_k3s.TriggerPostCreate(appName)
case "post-certs-update":
appName := flag.Arg(0)
err = scheduler_k3s.TriggerPostCertsUpdate(appName)
case "post-certs-remove":
appName := flag.Arg(0)
err = scheduler_k3s.TriggerPostCertsRemove(appName)
case "post-delete":
appName := flag.Arg(0)
err = scheduler_k3s.TriggerPostDelete(appName)

View File

@@ -336,8 +336,9 @@ func (a NameSorter) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a NameSorter) Less(i, j int) bool { return a[i].Name < a[j].Name }
type ProcessTls struct {
Enabled bool `yaml:"enabled"`
IssuerName string `yaml:"issuer_name"`
Enabled bool `yaml:"enabled"`
IssuerName string `yaml:"issuer_name"`
UseImportedCert bool `yaml:"use_imported_cert"`
}
type ClusterIssuer struct {

View File

@@ -4,7 +4,7 @@
{{- continue }}
{{- end }}
{{- if and $config.web.tls.enabled $config.web.domains }}
{{- if and $config.web.tls.enabled (not $config.web.tls.use_imported_cert) $config.web.domains }}
---
apiVersion: cert-manager.io/v1
kind: Certificate

View File

@@ -53,7 +53,11 @@ spec:
{{- end }}
{{- if $config.web.tls.enabled }}
tls:
{{- if $config.web.tls.use_imported_cert }}
secretName: tls-{{ $.Values.global.app_name }}
{{- else }}
secretName: tls-{{ $.Values.global.app_name }}-{{ $processName }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -38,7 +38,11 @@ spec:
tls:
- hosts:
- {{ $domain.name | quote }}
{{- if $config.web.tls.use_imported_cert }}
secretName: tls-{{ $.Values.global.app_name }}
{{- else }}
secretName: tls-{{ $.Values.global.app_name }}-{{ $processName }}
{{- end }}
{{- end }}
rules:
- host: {{ $domain.name | quote }}

View File

@@ -0,0 +1,16 @@
apiVersion: v1
kind: Secret
type: kubernetes.io/tls
metadata:
name: tls-{{ .Values.global.app_name }}
namespace: {{ .Values.global.namespace }}
labels:
app.kubernetes.io/name: tls-{{ .Values.global.app_name }}
app.kubernetes.io/part-of: {{ .Values.global.app_name }}
dokku.com/cert-source: "imported"
dokku.com/cert-checksum: {{ .Values.global.cert_checksum | quote }}
annotations:
dokku.com/managed: "true"
data:
tls.crt: {{ .Values.global.tls_crt }}
tls.key: {{ .Values.global.tls_key }}

View File

@@ -0,0 +1,332 @@
package scheduler_k3s
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/dokku/dokku/plugins/common"
)
// TLSSecretValues contains the values for a TLS secret helm chart
type TLSSecretValues struct {
Global TLSSecretGlobalValues `yaml:"global"`
}
// TLSSecretGlobalValues contains the global values for TLS secret
type TLSSecretGlobalValues struct {
AppName string `yaml:"app_name"`
Namespace string `yaml:"namespace"`
CertChecksum string `yaml:"cert_checksum"`
TLSCrt string `yaml:"tls_crt"`
TLSKey string `yaml:"tls_key"`
}
// GetCertContent retrieves certificate content via the certs-get trigger
func GetCertContent(appName string, keyType string) (string, error) {
result, err := common.CallPlugnTrigger(common.PlugnTriggerInput{
Trigger: "certs-get",
Args: []string{appName, keyType},
})
if err != nil {
return "", fmt.Errorf("failed to get cert content: %w", err)
}
if result.ExitCode != 0 {
return "", fmt.Errorf("certs-get trigger returned non-zero exit code")
}
return result.StdoutContents(), nil
}
// CertsExist checks if certificates exist for an app
func CertsExist(appName string) bool {
result, err := common.CallPlugnTrigger(common.PlugnTriggerInput{
Trigger: "certs-exists",
Args: []string{appName},
})
if err != nil {
return false
}
return strings.TrimSpace(result.StdoutContents()) == "true"
}
// ComputeCertChecksum computes sha224 of combined cert+key content
// SHA224 produces a 56-character hex string which fits within Kubernetes label limit of 63 chars
func ComputeCertChecksum(certContent, keyContent string) string {
combined := certContent + keyContent
hash := sha256.Sum224([]byte(combined))
return hex.EncodeToString(hash[:])
}
// GetTLSSecretReleaseName returns the helm release name for TLS secret
func GetTLSSecretReleaseName(appName string) string {
return fmt.Sprintf("tls-%s", appName)
}
// CreateOrUpdateTLSSecret creates or updates a TLS secret helm chart for an app
func CreateOrUpdateTLSSecret(ctx context.Context, appName string) error {
if err := isKubernetesAvailable(); err != nil {
common.LogDebug("kubernetes not available, skipping TLS secret creation")
return nil
}
scheduler := common.PropertyGetDefault("scheduler", appName, "selected", "")
globalScheduler := common.PropertyGetDefault("scheduler", "--global", "selected", "docker-local")
if scheduler == "" {
scheduler = globalScheduler
}
if scheduler != "k3s" {
common.LogDebug("app does not use k3s scheduler, skipping TLS secret creation")
return nil
}
certContent, err := GetCertContent(appName, "crt")
if err != nil {
return fmt.Errorf("failed to get certificate: %w", err)
}
keyContent, err := GetCertContent(appName, "key")
if err != nil {
return fmt.Errorf("failed to get key: %w", err)
}
checksum := ComputeCertChecksum(certContent, keyContent)
chartDir, err := os.MkdirTemp("", "dokku-tls-chart-")
if err != nil {
return fmt.Errorf("error creating chart directory: %w", err)
}
defer os.RemoveAll(chartDir)
if err := os.MkdirAll(filepath.Join(chartDir, "templates"), os.FileMode(0755)); err != nil {
return fmt.Errorf("error creating chart templates directory: %w", err)
}
namespace := getComputedNamespace(appName)
releaseName := GetTLSSecretReleaseName(appName)
chart := &Chart{
ApiVersion: "v2",
AppVersion: "1.0.0",
Name: releaseName,
Icon: "https://dokku.com/assets/dokku-logo.svg",
Version: "0.0.1",
}
err = writeYaml(WriteYamlInput{
Object: chart,
Path: filepath.Join(chartDir, "Chart.yaml"),
})
if err != nil {
return fmt.Errorf("error writing chart: %w", err)
}
b, err := templates.ReadFile("templates/tls-secret-chart/templates/tls-secret.yaml")
if err != nil {
return fmt.Errorf("error reading tls-secret template: %w", err)
}
filename := filepath.Join(chartDir, "templates", "tls-secret.yaml")
err = os.WriteFile(filename, b, os.FileMode(0644))
if err != nil {
return fmt.Errorf("error writing tls-secret template: %w", err)
}
if os.Getenv("DOKKU_TRACE") == "1" {
common.CatFile(filename)
}
values := &TLSSecretValues{
Global: TLSSecretGlobalValues{
AppName: appName,
Namespace: namespace,
CertChecksum: checksum,
TLSCrt: base64.StdEncoding.EncodeToString([]byte(certContent)),
TLSKey: base64.StdEncoding.EncodeToString([]byte(keyContent)),
},
}
err = writeYaml(WriteYamlInput{
Object: values,
Path: filepath.Join(chartDir, "values.yaml"),
})
if err != nil {
return fmt.Errorf("error writing values: %w", err)
}
if err := createKubernetesNamespace(ctx, namespace); err != nil {
return fmt.Errorf("error creating namespace: %w", err)
}
helmAgent, err := NewHelmAgent(namespace, DeployLogPrinter)
if err != nil {
return fmt.Errorf("error creating helm agent: %w", err)
}
chartPath, err := filepath.Abs(chartDir)
if err != nil {
return fmt.Errorf("error getting chart path: %w", err)
}
common.LogInfo1(fmt.Sprintf("Installing TLS certificate for %s", appName))
err = helmAgent.InstallOrUpgradeChart(ctx, ChartInput{
ChartPath: chartPath,
Namespace: namespace,
ReleaseName: releaseName,
Wait: true,
})
if err != nil {
return fmt.Errorf("error installing TLS secret chart: %w", err)
}
if err := common.PropertyWrite("scheduler-k3s", appName, "tls-cert-imported", "true"); err != nil {
return fmt.Errorf("error setting tls-cert-imported property: %w", err)
}
return nil
}
// DeleteTLSSecret deletes the TLS secret helm chart for an app
func DeleteTLSSecret(ctx context.Context, appName string) (bool, error) {
if err := isKubernetesAvailable(); err != nil {
common.LogDebug("kubernetes not available, skipping TLS secret deletion")
return false, nil
}
namespace := getComputedNamespace(appName)
releaseName := GetTLSSecretReleaseName(appName)
helmAgent, err := NewHelmAgent(namespace, DeployLogPrinter)
if err != nil {
return false, fmt.Errorf("error creating helm agent: %w", err)
}
exists, err := helmAgent.ChartExists(releaseName)
if err != nil {
return false, fmt.Errorf("error checking if TLS secret chart exists: %w", err)
}
if !exists {
if err := common.PropertyDelete("scheduler-k3s", appName, "tls-cert-imported"); err != nil {
common.LogDebug(fmt.Sprintf("error clearing tls-cert-imported property: %v", err))
}
return false, nil
}
common.LogInfo1(fmt.Sprintf("Removing TLS certificate for %s", appName))
err = helmAgent.UninstallChart(releaseName)
if err != nil {
return false, fmt.Errorf("error uninstalling TLS secret chart: %w", err)
}
if err := common.PropertyDelete("scheduler-k3s", appName, "tls-cert-imported"); err != nil {
common.LogDebug(fmt.Sprintf("error clearing tls-cert-imported property: %v", err))
}
return true, nil
}
// HasImportedTLSCert checks if an app has an imported TLS certificate
func HasImportedTLSCert(appName string) bool {
value := common.PropertyGetDefault("scheduler-k3s", appName, "tls-cert-imported", "")
return value == "true"
}
// TLSSecretExists checks if the TLS secret helm release exists in k8s
func TLSSecretExists(ctx context.Context, appName string) (bool, error) {
namespace := getComputedNamespace(appName)
releaseName := GetTLSSecretReleaseName(appName)
helmAgent, err := NewHelmAgent(namespace, DevNullPrinter)
if err != nil {
return false, fmt.Errorf("error creating helm agent: %w", err)
}
return helmAgent.ChartExists(releaseName)
}
// TLSSecretNeedsUpdate checks if the TLS secret needs updating by comparing checksums
func TLSSecretNeedsUpdate(ctx context.Context, appName string) (bool, error) {
clientset, err := NewKubernetesClient()
if err != nil {
return false, fmt.Errorf("error creating kubernetes client: %w", err)
}
namespace := getComputedNamespace(appName)
secretName := fmt.Sprintf("tls-%s", appName)
secret, err := clientset.GetSecret(ctx, GetSecretInput{
Name: secretName,
Namespace: namespace,
})
if err != nil {
return true, nil
}
existingChecksum, ok := secret.Labels["dokku.com/cert-checksum"]
if !ok {
return true, nil
}
certContent, err := GetCertContent(appName, "crt")
if err != nil {
return false, err
}
keyContent, err := GetCertContent(appName, "key")
if err != nil {
return false, err
}
currentChecksum := ComputeCertChecksum(certContent, keyContent)
return existingChecksum != currentChecksum, nil
}
// syncExistingCertificates syncs certificates for all apps using k3s scheduler
func syncExistingCertificates() error {
if err := isKubernetesAvailable(); err != nil {
common.LogDebug("kubernetes not available, skipping certificate sync")
return nil
}
ctx := context.Background()
apps, err := common.DokkuApps()
if err != nil {
return fmt.Errorf("failed to list apps: %w", err)
}
for _, appName := range apps {
scheduler := common.PropertyGetDefault("scheduler", appName, "selected", "")
globalScheduler := common.PropertyGetDefault("scheduler", "--global", "selected", "docker-local")
if scheduler == "" {
scheduler = globalScheduler
}
if scheduler != "k3s" {
continue
}
if !CertsExist(appName) {
continue
}
needsUpdate, err := TLSSecretNeedsUpdate(ctx, appName)
if err != nil {
common.LogDebug(fmt.Sprintf("Error checking TLS secret for %s: %v", appName, err))
continue
}
if needsUpdate {
common.LogInfo1(fmt.Sprintf("Syncing TLS certificate for %s", appName))
if err := CreateOrUpdateTLSSecret(ctx, appName); err != nil {
common.LogWarn(fmt.Sprintf("Failed to sync TLS certificate for %s: %v", appName, err))
}
}
}
return nil
}

View File

@@ -42,8 +42,6 @@ func TriggerCorePostDeploy(appName string) error {
{Path: "kustomization", IsDirectory: true},
},
})
return nil
}
// TriggerCorePostExtract moves a configured kustomize root path to be in the app root dir
@@ -76,9 +74,81 @@ func TriggerInstall() error {
return err
}
if err := syncExistingCertificates(); err != nil {
common.LogWarn(fmt.Sprintf("Warning: failed to sync existing certificates: %v", err))
}
return nil
}
// TriggerPostCertsUpdate handles post-certs-update trigger
func TriggerPostCertsUpdate(appName string) error {
scheduler := common.PropertyGetDefault("scheduler", appName, "selected", "")
globalScheduler := common.PropertyGetDefault("scheduler", "--global", "selected", "docker-local")
if scheduler == "" {
scheduler = globalScheduler
}
if scheduler != "k3s" {
return nil
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
common.LogInfo1(fmt.Sprintf("Syncing TLS certificate for %s to kubernetes", appName))
if err := CreateOrUpdateTLSSecret(ctx, appName); err != nil {
return err
}
if !isAppDeployed(appName) {
return nil
}
imageTag, err := getDeployedAppImageTag(appName)
if err != nil {
return nil
}
common.LogInfo1(fmt.Sprintf("Triggering redeploy for %s to update ingress configuration", appName))
return TriggerSchedulerDeploy("k3s", appName, imageTag)
}
// TriggerPostCertsRemove handles post-certs-remove trigger
func TriggerPostCertsRemove(appName string) error {
scheduler := common.PropertyGetDefault("scheduler", appName, "selected", "")
globalScheduler := common.PropertyGetDefault("scheduler", "--global", "selected", "docker-local")
if scheduler == "" {
scheduler = globalScheduler
}
if scheduler != "k3s" {
return nil
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
deleted, err := DeleteTLSSecret(ctx, appName)
if err != nil {
return err
}
if !deleted {
return nil
}
if !isAppDeployed(appName) {
return nil
}
imageTag, err := getDeployedAppImageTag(appName)
if err != nil {
return nil
}
common.LogInfo1(fmt.Sprintf("Triggering redeploy for %s to update ingress configuration", appName))
return TriggerSchedulerDeploy("k3s", appName, imageTag)
}
// TriggerPostAppCloneSetup creates new scheduler-k3s files
func TriggerPostAppCloneSetup(oldAppName string, newAppName string) error {
err := common.PropertyClone("scheduler-k3s", oldAppName, newAppName)
@@ -284,24 +354,41 @@ func TriggerSchedulerDeploy(scheduler string, appName string, imageTag string) e
return fmt.Errorf("Error loading environment for deployment: %w", err)
}
server := getComputedLetsencryptServer(appName)
letsencryptEmailStag := getGlobalLetsencryptEmailStag()
letsencryptEmailProd := getGlobalLetsencryptEmailProd()
tlsEnabled := false
// Check for imported TLS certificate first
importedCertExists := false
if HasImportedTLSCert(appName) {
exists, err := TLSSecretExists(ctx, appName)
if err == nil && exists {
importedCertExists = true
}
}
// Determine TLS configuration
tlsEnabled := false
issuerName := ""
switch server {
case "prod", "production":
issuerName = "letsencrypt-prod"
tlsEnabled = letsencryptEmailProd != ""
case "stag", "staging":
issuerName = "letsencrypt-stag"
tlsEnabled = letsencryptEmailStag != ""
case "false":
issuerName = ""
tlsEnabled = false
default:
return fmt.Errorf("Invalid letsencrypt server config: %s", server)
useImportedCert := false
if importedCertExists {
tlsEnabled = true
useImportedCert = true
} else {
server := getComputedLetsencryptServer(appName)
letsencryptEmailStag := getGlobalLetsencryptEmailStag()
letsencryptEmailProd := getGlobalLetsencryptEmailProd()
switch server {
case "prod", "production":
issuerName = "letsencrypt-prod"
tlsEnabled = letsencryptEmailProd != ""
case "stag", "staging":
issuerName = "letsencrypt-stag"
tlsEnabled = letsencryptEmailStag != ""
case "false":
issuerName = ""
tlsEnabled = false
default:
return fmt.Errorf("Invalid letsencrypt server config: %s", server)
}
}
chartDir, err := os.MkdirTemp("", "dokku-chart-")
@@ -577,8 +664,9 @@ func TriggerSchedulerDeploy(scheduler string, appName string, imageTag string) e
Domains: domainValues,
PortMaps: []ProcessPortMap{},
TLS: ProcessTls{
Enabled: tlsEnabled,
IssuerName: issuerName,
Enabled: tlsEnabled,
IssuerName: issuerName,
UseImportedCert: useImportedCert,
},
}
@@ -883,20 +971,7 @@ func TriggerSchedulerIsDeployed(scheduler string, appName string) error {
return nil
}
// check if there are any helm revisions for the specified appName
helmAgent, err := NewHelmAgent(getComputedNamespace(appName), DeployLogPrinter)
if err != nil {
return fmt.Errorf("Error creating helm agent: %w", err)
}
revisions, err := helmAgent.ListRevisions(ListRevisionsInput{
ReleaseName: appName,
})
if err != nil {
return fmt.Errorf("Error listing helm revisions: %w", err)
}
if len(revisions) > 0 {
if isAppDeployed(appName) {
return nil
}