Files
dokku/plugins/scheduler-k3s/tls.go

333 lines
9.5 KiB
Go

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
}