Files
dokku/plugins/scheduler-k3s/preview.go
Jose Diaz-Gonzalez 1d2c7424aa fix: do not require a local image for k3s deploys
Kubernetes pulls the app image itself, so a k3s host is free to reap its local copy while the workload keeps running, which the `registry` plugin already does on its own. Deploys, restarts, `dokku run`, and in-cluster cron no longer assert that the image is present locally, falling back to the metadata recorded in the app's current Helm release. A `ps:restart` naming a single process type now rolls only that process type's pods rather than silently redeploying every one. Apps with an `app.json` postdeploy task still require the image locally, as that task runs on the Dokku host.
2026-08-12 02:14:56 -04:00

196 lines
6.1 KiB
Go

package scheduler_k3s
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"github.com/dokku/dokku/plugins/common"
"github.com/dokku/dokku/plugins/scheduler-k3s/internal/helmdiff"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/chart/loader"
"helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/release"
"helm.sh/helm/v3/pkg/storage/driver"
)
// CommandPreview prints a unified diff between the manifests currently stored
// in the live Helm release for an app and the manifests that the next deploy
// would roll out. Only the main app helm release is compared; the auxiliary
// config-secret, image-pull-secret, and TLS-secret releases are out of scope.
//
// diffContext controls the number of unchanged lines shown around each change;
// pass -1 to print the full resource.
func CommandPreview(appName string, diffContext int, showSecrets bool, showSecretsDecoded bool) error {
if err := common.VerifyAppName(appName); err != nil {
return err
}
scheduler := common.PropertyGetDefault("scheduler", appName, "selected", "")
globalScheduler := common.PropertyGetDefault("scheduler", "--global", "selected", "docker-local")
if scheduler == "" {
scheduler = globalScheduler
}
if scheduler != "k3s" {
return fmt.Errorf("App %s is not configured to use the k3s scheduler", appName)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
signals := make(chan os.Signal, 1)
signal.Notify(signals, os.Interrupt, syscall.SIGHUP,
syscall.SIGINT,
syscall.SIGQUIT,
syscall.SIGTERM)
go func() {
<-signals
cancel()
}()
warnMissingCertIssuer(ctx, appName)
namespace := getComputedNamespace(appName)
helmAgent, err := NewHelmAgent(namespace, DevNullPrinter)
if err != nil {
return fmt.Errorf("Error creating helm agent: %w", err)
}
currentRelease, err := fetchCurrentRelease(helmAgent, appName)
if err != nil {
return err
}
buildOpts := BuildOptions{AllowMissingImage: true}
if currentRelease != nil {
buildOpts.OverrideDeploymentID = deploymentIDFromRelease(currentRelease)
buildOpts.FallbackImageMetadata = imageMetadataFromValues(releaseValues(helmAgent, appName))
}
chartResult, err := BuildAppChart(ctx, appName, "", buildOpts)
if err != nil {
return err
}
defer os.RemoveAll(chartResult.ChartDir)
chartRequested, err := loadChartForPreview(chartResult.ChartPath)
if err != nil {
return err
}
var currentManifest, proposedManifest string
if currentRelease != nil {
currentManifest = currentRelease.Manifest
proposedManifest, err = renderUpgradeDryRun(ctx, helmAgent, chartResult, chartRequested)
} else {
proposedManifest, err = renderInstallDryRun(ctx, helmAgent, chartResult, chartRequested)
}
if err != nil {
return err
}
options := &helmdiff.Options{
OutputFormat: "diff",
OutputContext: diffContext,
ShowSecrets: showSecrets,
ShowSecretsDecoded: showSecretsDecoded,
}
current := helmdiff.Parse([]byte(currentManifest), chartResult.Namespace, true)
proposed := helmdiff.Parse([]byte(proposedManifest), chartResult.Namespace, true)
helmdiff.Manifests(current, proposed, options, os.Stdout)
return nil
}
func loadChartForPreview(chartPath string) (*chart.Chart, error) {
settings := cli.New()
opts := action.ChartPathOptions{}
located, err := opts.LocateChart(chartPath, settings)
if err != nil {
return nil, fmt.Errorf("Error locating chart: %w", err)
}
chartRequested, err := loader.Load(located)
if err != nil {
return nil, fmt.Errorf("Error loading chart: %w", err)
}
return chartRequested, nil
}
func fetchCurrentRelease(helmAgent *HelmAgent, releaseName string) (*release.Release, error) {
getAction := action.NewGet(helmAgent.Configuration)
rel, err := getAction.Run(releaseName)
if err != nil {
if errors.Is(err, driver.ErrReleaseNotFound) {
return nil, nil
}
return nil, fmt.Errorf("Error fetching current release: %w", err)
}
return rel, nil
}
// deploymentIDFromRelease extracts the deployment id encoded in a release's
// chart version. BuildAppChart writes the chart version as "0.0.<id>"
// (chart.go); the parsed id lets CommandPreview pin the previewed manifests
// to the currently-deployed value so the `app.kubernetes.io/version`
// annotation does not churn in the diff. Returns 0 when the version does not
// match the expected shape - the caller then falls back to the timestamp.
func deploymentIDFromRelease(rel *release.Release) int64 {
if rel == nil || rel.Chart == nil || rel.Chart.Metadata == nil {
return 0
}
const prefix = "0.0."
v := rel.Chart.Metadata.Version
if !strings.HasPrefix(v, prefix) {
return 0
}
id, err := strconv.ParseInt(strings.TrimPrefix(v, prefix), 10, 64)
if err != nil {
return 0
}
return id
}
func renderUpgradeDryRun(ctx context.Context, helmAgent *HelmAgent, chartResult BuildResult, chartRequested *chart.Chart) (string, error) {
kustomizeRenderer := KustomizeRenderer{
ReleaseName: chartResult.ReleaseName,
KustomizeRootPath: chartResult.KustomizeRootPath,
}
client := action.NewUpgrade(helmAgent.Configuration)
client.DryRun = true
client.DryRunOption = "client"
client.Namespace = chartResult.Namespace
client.PostRenderer = &kustomizeRenderer
rel, err := client.RunWithContext(ctx, chartResult.ReleaseName, chartRequested, map[string]interface{}{})
if err != nil {
return "", fmt.Errorf("Error rendering upgrade dry-run: %w", err)
}
return rel.Manifest, nil
}
func renderInstallDryRun(ctx context.Context, helmAgent *HelmAgent, chartResult BuildResult, chartRequested *chart.Chart) (string, error) {
kustomizeRenderer := KustomizeRenderer{
ReleaseName: chartResult.ReleaseName,
KustomizeRootPath: chartResult.KustomizeRootPath,
}
client := action.NewInstall(helmAgent.Configuration)
client.DryRun = true
client.ClientOnly = true
client.Namespace = chartResult.Namespace
client.ReleaseName = chartResult.ReleaseName
client.PostRenderer = &kustomizeRenderer
rel, err := client.RunWithContext(ctx, chartRequested, map[string]interface{}{})
if err != nil {
return "", fmt.Errorf("Error rendering install dry-run: %w", err)
}
return rel.Manifest, nil
}