fix: honor the pinned chart version when upgrading
`scheduler-k3s:ensure-charts` announced the pinned chart version but installed whatever the chart repository had published most recently, and an affected cluster could not be brought back in line because a version mismatch was reported and then skipped. Charts now install at the version dokku pins, and a release that has drifted is reconciled back to it. Dokku also no longer registers helm repositories it never read, and the previously undocumented `ensure-charts` and `initialize` flags now appear in the help output.
This commit is contained in:
@@ -16,8 +16,8 @@ scheduler-k3s:charts:set <chart-name.property> (<value>) # Set or clear a chart-
|
||||
scheduler-k3s:cluster:add [--profile PROFILE] [--role ROLE] [--insecure-allow-unknown-hosts] [--server-ip SERVER_IP] [--taint-scheduling] [--kubelet-args KUBELET_ARGS] <ssh://user@host:port> # Adds a server node to a Dokku-managed cluster
|
||||
scheduler-k3s:cluster:list [--format json|stdout] # Lists all nodes in a Dokku-managed cluster
|
||||
scheduler-k3s:cluster:remove [node-id] # Removes client node to a Dokku-managed cluster
|
||||
scheduler-k3s:ensure-charts # Ensures the k3s charts are installed
|
||||
scheduler-k3s:initialize [--server-ip SERVER_IP] [--taint-scheduling] [--kubelet-args KUBELET_ARGS] # Initializes a cluster
|
||||
scheduler-k3s:ensure-charts [--force] [--charts CHART_NAMES] # Ensures the k3s charts are installed
|
||||
scheduler-k3s:initialize [--server-ip SERVER_IP] [--ingress-class INGRESS_CLASS] [--taint-scheduling] [--kubelet-args KUBELET_ARGS] # Initializes a cluster
|
||||
scheduler-k3s:labels:clear <app|--global> [--process-type PROCESS_TYPE] [--resource-type RESOURCE_TYPE] # Clear all labels for an app or a single process-type/resource-type scope
|
||||
scheduler-k3s:labels:set <app|--global> <property> (<value>) [--process-type PROCESS_TYPE] <--resource-type RESOURCE_TYPE> # Set or clear a label for a given app/process-type/resource-type combination
|
||||
scheduler-k3s:labels:set --replace <app|--global> <key=value> [<key=value> ...] [--process-type PROCESS_TYPE] <--resource-type RESOURCE_TYPE> # Replace the entire label map for a given app/process-type/resource-type combination
|
||||
@@ -1100,18 +1100,20 @@ The default value for the `kube-context` is an empty string, and will result in
|
||||
Dokku includes a number of helm charts by default with settings that are optimized for Dokku. That said, it may be useful to further customize the charts for a given environment. Chart overrides are managed via the `scheduler-k3s:charts:set` command, which takes a `<chart-name>.<property>` argument and a value. Chart overrides are always global because helm charts are not managed on a per-app basis.
|
||||
|
||||
```shell
|
||||
dokku scheduler-k3s:charts:set cert-manager.version 1.13.3
|
||||
dokku scheduler-k3s:charts:set cert-manager.installCRDs false
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Properties follow dot-notation, and are expanded according to Helm's internal logic. See the [Helm documentation](https://helm.sh/docs/helm/helm_install/#helm-install) for `helm install` for further details.
|
||||
|
||||
Chart overrides are values passed to a chart, not the version of the chart itself. Dokku pins the version of every chart it manages, and those versions are not user-configurable. The `scheduler-k3s:ensure-charts` command reconciles each installed release back to the pinned version, upgrading or downgrading a release that has drifted away from it.
|
||||
|
||||
Property names may contain `/` (e.g. for Kubernetes-style annotation keys such as `service.annotations.prometheus.io/scrape`) and values may span multiple lines; both are preserved verbatim.
|
||||
|
||||
To unset a chart property, omit the value from the `scheduler-k3s:charts:set` call:
|
||||
|
||||
```shell
|
||||
dokku scheduler-k3s:charts:set cert-manager.version
|
||||
dokku scheduler-k3s:charts:set cert-manager.installCRDs
|
||||
```
|
||||
|
||||
Configured chart overrides can be inspected with the `scheduler-k3s:charts:report` command. Without an argument, it lists every chart known to Dokku along with any configured overrides; passing a chart name scopes the report to that chart:
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -2142,19 +2143,68 @@ func hasKustomizeDirectory(appName string) bool {
|
||||
return common.DirectoryExists(directory)
|
||||
}
|
||||
|
||||
func installHelmCharts(ctx context.Context, clientset KubernetesClient, shouldInstall func(HelmChart) bool) error {
|
||||
for _, repo := range HelmRepositories {
|
||||
helmAgent, err := NewHelmAgent("default", DeployLogPrinter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Error creating helm agent: %w", err)
|
||||
}
|
||||
// ChartInstallDecisionInput is the input for the chartInstallDecision function
|
||||
type ChartInstallDecisionInput struct {
|
||||
// Chart is the chart being evaluated
|
||||
Chart HelmChart
|
||||
|
||||
err = helmAgent.AddRepository(ctx, AddRepositoryInput(repo))
|
||||
if err != nil {
|
||||
return fmt.Errorf("Error adding helm repository %s: %w", repo.Name, err)
|
||||
// ForceChartNames is the list of release names to install regardless of state
|
||||
ForceChartNames []string
|
||||
|
||||
// ForceInstall is whether every chart should be installed regardless of state
|
||||
ForceInstall bool
|
||||
|
||||
// IngressClass is the ingress class configured for the cluster
|
||||
IngressClass string
|
||||
|
||||
// InstalledRevision is the helm release currently installed for the chart
|
||||
InstalledRevision Release
|
||||
}
|
||||
|
||||
// ChartInstallDecision reports whether a chart should be installed and why
|
||||
type ChartInstallDecision struct {
|
||||
// Install is whether the chart should be installed
|
||||
Install bool
|
||||
|
||||
// Reason describes why the chart is being installed or skipped
|
||||
Reason string
|
||||
}
|
||||
|
||||
// chartInstallDecision reports whether a chart should be installed, and why. A
|
||||
// chart whose installed release has drifted from the pinned version is
|
||||
// reinstalled so that the pinned version is what ends up on the cluster.
|
||||
func chartInstallDecision(input ChartInstallDecisionInput) ChartInstallDecision {
|
||||
if input.Chart.ChartPath == "traefik" && input.IngressClass == "nginx" {
|
||||
return ChartInstallDecision{Install: false, Reason: "Skipping chart due to ingress-class mismatch"}
|
||||
}
|
||||
|
||||
if input.Chart.ChartPath == "ingress-nginx" && input.IngressClass == "traefik" {
|
||||
return ChartInstallDecision{Install: false, Reason: "Skipping chart due to ingress-class mismatch"}
|
||||
}
|
||||
|
||||
if input.ForceInstall {
|
||||
return ChartInstallDecision{Install: true, Reason: "Force installing chart"}
|
||||
}
|
||||
|
||||
if slices.Contains(input.ForceChartNames, input.Chart.ReleaseName) {
|
||||
return ChartInstallDecision{Install: true, Reason: "Force installing chart due to flag"}
|
||||
}
|
||||
|
||||
if input.InstalledRevision.Name == "" {
|
||||
return ChartInstallDecision{Install: true, Reason: "Installing missing chart"}
|
||||
}
|
||||
|
||||
if input.InstalledRevision.Version != input.Chart.Version {
|
||||
return ChartInstallDecision{
|
||||
Install: true,
|
||||
Reason: fmt.Sprintf("Installing chart due to version mismatch: %s != %s", input.InstalledRevision.Version, input.Chart.Version),
|
||||
}
|
||||
}
|
||||
|
||||
return ChartInstallDecision{Install: false, Reason: "Skipping chart: already installed"}
|
||||
}
|
||||
|
||||
func installHelmCharts(ctx context.Context, clientset KubernetesClient, shouldInstall func(HelmChart) bool) error {
|
||||
for _, chart := range HelmCharts {
|
||||
if !shouldInstall(chart) {
|
||||
continue
|
||||
|
||||
@@ -723,3 +723,131 @@ func TestAttachExitError(t *testing.T) {
|
||||
t.Errorf("attachExitError(zero code error) = %v, want the original error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartInstallDecision(t *testing.T) {
|
||||
traefik := HelmChart{ChartPath: "traefik", ReleaseName: "traefik", Version: "26.0.0"}
|
||||
ingressNginx := HelmChart{ChartPath: "ingress-nginx", ReleaseName: "ingress-nginx", Version: "4.15.1"}
|
||||
installed := Release{Name: "traefik", Version: "26.0.0"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
input ChartInstallDecisionInput
|
||||
wantInstall bool
|
||||
wantReason string
|
||||
}{
|
||||
{
|
||||
name: "traefik is skipped when the ingress class is nginx",
|
||||
input: ChartInstallDecisionInput{
|
||||
Chart: traefik,
|
||||
ForceInstall: true,
|
||||
IngressClass: "nginx",
|
||||
},
|
||||
wantInstall: false,
|
||||
wantReason: "Skipping chart due to ingress-class mismatch",
|
||||
},
|
||||
{
|
||||
name: "ingress-nginx is skipped when the ingress class is traefik",
|
||||
input: ChartInstallDecisionInput{
|
||||
Chart: ingressNginx,
|
||||
ForceInstall: true,
|
||||
IngressClass: "traefik",
|
||||
},
|
||||
wantInstall: false,
|
||||
wantReason: "Skipping chart due to ingress-class mismatch",
|
||||
},
|
||||
{
|
||||
name: "force installs an up to date chart",
|
||||
input: ChartInstallDecisionInput{
|
||||
Chart: traefik,
|
||||
ForceInstall: true,
|
||||
IngressClass: "traefik",
|
||||
InstalledRevision: installed,
|
||||
},
|
||||
wantInstall: true,
|
||||
wantReason: "Force installing chart",
|
||||
},
|
||||
{
|
||||
name: "a named chart installs even when up to date",
|
||||
input: ChartInstallDecisionInput{
|
||||
Chart: traefik,
|
||||
ForceChartNames: []string{"traefik"},
|
||||
IngressClass: "traefik",
|
||||
InstalledRevision: installed,
|
||||
},
|
||||
wantInstall: true,
|
||||
wantReason: "Force installing chart due to flag",
|
||||
},
|
||||
{
|
||||
name: "a chart named for another release is left alone",
|
||||
input: ChartInstallDecisionInput{
|
||||
Chart: traefik,
|
||||
ForceChartNames: []string{"vector"},
|
||||
IngressClass: "traefik",
|
||||
InstalledRevision: installed,
|
||||
},
|
||||
wantInstall: false,
|
||||
wantReason: "Skipping chart: already installed",
|
||||
},
|
||||
{
|
||||
name: "a missing release is installed",
|
||||
input: ChartInstallDecisionInput{
|
||||
Chart: traefik,
|
||||
IngressClass: "traefik",
|
||||
},
|
||||
wantInstall: true,
|
||||
wantReason: "Installing missing chart",
|
||||
},
|
||||
{
|
||||
name: "a release behind the pinned version is upgraded",
|
||||
input: ChartInstallDecisionInput{
|
||||
Chart: traefik,
|
||||
IngressClass: "traefik",
|
||||
InstalledRevision: Release{Name: "traefik", Version: "25.0.0"},
|
||||
},
|
||||
wantInstall: true,
|
||||
wantReason: "Installing chart due to version mismatch: 25.0.0 != 26.0.0",
|
||||
},
|
||||
{
|
||||
name: "a release ahead of the pinned version is reconciled back",
|
||||
input: ChartInstallDecisionInput{
|
||||
Chart: traefik,
|
||||
IngressClass: "traefik",
|
||||
InstalledRevision: Release{Name: "traefik", Version: "41.6.0"},
|
||||
},
|
||||
wantInstall: true,
|
||||
wantReason: "Installing chart due to version mismatch: 41.6.0 != 26.0.0",
|
||||
},
|
||||
{
|
||||
name: "the mismatch reason reports the chart version, not the app version",
|
||||
input: ChartInstallDecisionInput{
|
||||
Chart: traefik,
|
||||
IngressClass: "traefik",
|
||||
InstalledRevision: Release{Name: "traefik", AppVersion: "v2.11.0", Version: "41.6.0"},
|
||||
},
|
||||
wantInstall: true,
|
||||
wantReason: "Installing chart due to version mismatch: 41.6.0 != 26.0.0",
|
||||
},
|
||||
{
|
||||
name: "a release at the pinned version is skipped",
|
||||
input: ChartInstallDecisionInput{
|
||||
Chart: traefik,
|
||||
IngressClass: "traefik",
|
||||
InstalledRevision: installed,
|
||||
},
|
||||
wantInstall: false,
|
||||
wantReason: "Skipping chart: already installed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := chartInstallDecision(tc.input)
|
||||
if got.Install != tc.wantInstall {
|
||||
t.Errorf("Install = %t, want %t", got.Install, tc.wantInstall)
|
||||
}
|
||||
if got.Reason != tc.wantReason {
|
||||
t.Errorf("Reason = %q, want %q", got.Reason, tc.wantReason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ require (
|
||||
github.com/fluxcd/pkg/kustomize v1.40.0
|
||||
github.com/go-openapi/jsonpointer v1.0.1
|
||||
github.com/go-resty/resty/v2 v2.17.2
|
||||
github.com/gofrs/flock v0.13.1
|
||||
github.com/gosimple/slug v1.15.0
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
||||
github.com/kedacore/keda/v2 v2.20.2
|
||||
|
||||
@@ -167,8 +167,6 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo=
|
||||
github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE=
|
||||
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -15,16 +14,12 @@ import (
|
||||
|
||||
"github.com/dokku/dokku/plugins/common"
|
||||
"github.com/fluxcd/pkg/kustomize/filesys"
|
||||
"github.com/gofrs/flock"
|
||||
"gopkg.in/yaml.v3"
|
||||
"helm.sh/helm/v3/pkg/action"
|
||||
"helm.sh/helm/v3/pkg/chart/loader"
|
||||
"helm.sh/helm/v3/pkg/cli"
|
||||
"helm.sh/helm/v3/pkg/getter"
|
||||
"helm.sh/helm/v3/pkg/kube"
|
||||
"helm.sh/helm/v3/pkg/postrender"
|
||||
"helm.sh/helm/v3/pkg/release"
|
||||
"helm.sh/helm/v3/pkg/repo"
|
||||
"helm.sh/helm/v3/pkg/storage/driver"
|
||||
"sigs.k8s.io/kustomize/api/konfig"
|
||||
"sigs.k8s.io/kustomize/api/krusty"
|
||||
@@ -93,6 +88,19 @@ type Release struct {
|
||||
Version string
|
||||
}
|
||||
|
||||
// applyChartPathOptions pins the repository and the version from input onto the
|
||||
// chart path options used to locate a chart. Both the install and the upgrade
|
||||
// path must do this, otherwise helm resolves the newest chart published to the
|
||||
// repository instead of the version dokku pins.
|
||||
func applyChartPathOptions(options *action.ChartPathOptions, input ChartInput) {
|
||||
if input.RepoURL != "" {
|
||||
options.RepoURL = input.RepoURL
|
||||
}
|
||||
if input.Version != "" {
|
||||
options.Version = input.Version
|
||||
}
|
||||
}
|
||||
|
||||
type HelmAgent struct {
|
||||
Configuration *action.Configuration
|
||||
Namespace string
|
||||
@@ -121,69 +129,6 @@ func NewHelmAgent(namespace string, logger action.DebugLog) (*HelmAgent, error)
|
||||
}, nil
|
||||
}
|
||||
|
||||
type AddRepositoryInput struct {
|
||||
Name string
|
||||
URL string
|
||||
}
|
||||
|
||||
func (h *HelmAgent) AddRepository(ctx context.Context, helmRepo AddRepositoryInput) error {
|
||||
settings := cli.New()
|
||||
repoFile := settings.RepositoryConfig
|
||||
|
||||
err := os.MkdirAll(filepath.Dir(repoFile), os.ModePerm)
|
||||
if err != nil && !os.IsExist(err) {
|
||||
return fmt.Errorf("Error creating repository directory: %w", err)
|
||||
}
|
||||
|
||||
fileLock := flock.New(strings.Replace(repoFile, filepath.Ext(repoFile), ".lock", 1))
|
||||
locked, err := fileLock.TryLockContext(ctx, time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Error acquiring file lock: %w", err)
|
||||
}
|
||||
|
||||
if !locked {
|
||||
return fmt.Errorf("Could not acquire file lock")
|
||||
}
|
||||
|
||||
defer fileLock.Unlock() // nolint: errcheck
|
||||
|
||||
b, err := os.ReadFile(repoFile)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("Error reading repository file: %w", err)
|
||||
}
|
||||
|
||||
var f repo.File
|
||||
if err := yaml.Unmarshal(b, &f); err != nil {
|
||||
return fmt.Errorf("Error unmarshalling yaml: %w", err)
|
||||
}
|
||||
|
||||
if f.Has(helmRepo.Name) {
|
||||
return nil
|
||||
}
|
||||
|
||||
c := repo.Entry{
|
||||
Name: helmRepo.Name,
|
||||
URL: helmRepo.URL,
|
||||
}
|
||||
|
||||
r, err := repo.NewChartRepository(&c, getter.All(settings))
|
||||
if err != nil {
|
||||
return fmt.Errorf("Error creating chart repository: %w", err)
|
||||
}
|
||||
|
||||
if _, err := r.DownloadIndexFile(); err != nil {
|
||||
return fmt.Errorf("Specified repository '%q' is not a valid chart repository or cannot be reached: %w", helmRepo.URL, err)
|
||||
}
|
||||
|
||||
f.Update(&c)
|
||||
|
||||
if err := f.WriteFile(repoFile, 0644); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *HelmAgent) ChartExists(releaseName string) (bool, error) {
|
||||
if releaseName == "" {
|
||||
return false, fmt.Errorf("Release name is required")
|
||||
@@ -270,7 +215,6 @@ func (h *HelmAgent) InstallChart(ctx context.Context, input ChartInput) error {
|
||||
|
||||
client := action.NewInstall(h.Configuration)
|
||||
client.Atomic = false
|
||||
client.ChartPathOptions = action.ChartPathOptions{}
|
||||
client.CreateNamespace = true
|
||||
client.DryRun = false
|
||||
if os.Getenv("DOKKU_TRACE") == "1" {
|
||||
@@ -285,14 +229,9 @@ func (h *HelmAgent) InstallChart(ctx context.Context, input ChartInput) error {
|
||||
client.Timeout = input.Timeout
|
||||
client.Wait = input.Wait
|
||||
|
||||
settings := cli.New()
|
||||
if input.RepoURL != "" {
|
||||
client.ChartPathOptions.RepoURL = input.RepoURL
|
||||
}
|
||||
if input.Version != "" {
|
||||
client.ChartPathOptions.Version = input.Version
|
||||
}
|
||||
applyChartPathOptions(&client.ChartPathOptions, input)
|
||||
|
||||
settings := cli.New()
|
||||
chart, err := client.ChartPathOptions.LocateChart(input.ChartPath, settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Error locating chart: %w", err)
|
||||
@@ -324,7 +263,7 @@ func (h *HelmAgent) InstalledRevision(releaseName string) (Release, error) {
|
||||
return Release{}, nil
|
||||
}
|
||||
|
||||
return revisions[0], nil
|
||||
return revisions[len(revisions)-1], nil
|
||||
}
|
||||
|
||||
type ListRevisionsInput struct {
|
||||
@@ -332,11 +271,24 @@ type ListRevisionsInput struct {
|
||||
Max int
|
||||
}
|
||||
|
||||
// selectRevisions sorts releases ascending by revision and keeps at most the
|
||||
// most recent max entries. A max of zero keeps every revision. helm's history
|
||||
// action ignores its own Max field and hands back the entire ledger unsorted,
|
||||
// so both the ordering and the truncation happen here.
|
||||
func selectRevisions(releases []Release, max int) []Release {
|
||||
sort.Slice(releases, func(i, j int) bool {
|
||||
return releases[i].Revision < releases[j].Revision
|
||||
})
|
||||
|
||||
if max > 0 && len(releases) > max {
|
||||
return releases[len(releases)-max:]
|
||||
}
|
||||
|
||||
return releases
|
||||
}
|
||||
|
||||
func (h *HelmAgent) ListRevisions(input ListRevisionsInput) ([]Release, error) {
|
||||
client := action.NewHistory(h.Configuration)
|
||||
if input.Max > 0 {
|
||||
client.Max = input.Max
|
||||
}
|
||||
|
||||
releases := []Release{}
|
||||
response, err := client.Run(input.ReleaseName)
|
||||
@@ -364,11 +316,7 @@ func (h *HelmAgent) ListRevisions(input ListRevisionsInput) ([]Release, error) {
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(releases, func(i, j int) bool {
|
||||
return releases[i].Revision < releases[j].Revision
|
||||
})
|
||||
|
||||
return releases, nil
|
||||
return selectRevisions(releases, input.Max), nil
|
||||
}
|
||||
|
||||
func (h *HelmAgent) UpgradeChart(ctx context.Context, input ChartInput) error {
|
||||
@@ -394,7 +342,6 @@ func (h *HelmAgent) UpgradeChart(ctx context.Context, input ChartInput) error {
|
||||
|
||||
client := action.NewUpgrade(h.Configuration)
|
||||
client.Atomic = input.RollbackOnFailure
|
||||
client.ChartPathOptions = action.ChartPathOptions{}
|
||||
client.CleanupOnFail = true
|
||||
client.MaxHistory = 10
|
||||
if os.Getenv("DOKKU_TRACE") == "1" {
|
||||
@@ -407,9 +354,8 @@ func (h *HelmAgent) UpgradeChart(ctx context.Context, input ChartInput) error {
|
||||
client.Namespace = namespace
|
||||
client.Timeout = input.Timeout
|
||||
client.Wait = input.Wait
|
||||
if input.RepoURL != "" {
|
||||
client.RepoURL = input.RepoURL
|
||||
}
|
||||
|
||||
applyChartPathOptions(&client.ChartPathOptions, input)
|
||||
|
||||
settings := cli.New()
|
||||
chart, err := client.ChartPathOptions.LocateChart(input.ChartPath, settings)
|
||||
|
||||
147
plugins/scheduler-k3s/helm_test.go
Normal file
147
plugins/scheduler-k3s/helm_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package scheduler_k3s
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"helm.sh/helm/v3/pkg/action"
|
||||
)
|
||||
|
||||
func TestApplyChartPathOptions(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
options action.ChartPathOptions
|
||||
input ChartInput
|
||||
wantRepoURL string
|
||||
wantVersion string
|
||||
}{
|
||||
{
|
||||
name: "pins both the repository and the version",
|
||||
input: ChartInput{RepoURL: "https://helm.traefik.io/traefik", Version: "26.0.0"},
|
||||
wantRepoURL: "https://helm.traefik.io/traefik",
|
||||
wantVersion: "26.0.0",
|
||||
},
|
||||
{
|
||||
name: "an empty version leaves the field unset",
|
||||
input: ChartInput{RepoURL: "https://helm.traefik.io/traefik"},
|
||||
wantRepoURL: "https://helm.traefik.io/traefik",
|
||||
wantVersion: "",
|
||||
},
|
||||
{
|
||||
name: "an empty repository leaves the field unset",
|
||||
input: ChartInput{Version: "26.0.0"},
|
||||
wantRepoURL: "",
|
||||
wantVersion: "26.0.0",
|
||||
},
|
||||
{
|
||||
name: "an empty input leaves both fields unset",
|
||||
input: ChartInput{},
|
||||
wantRepoURL: "",
|
||||
wantVersion: "",
|
||||
},
|
||||
{
|
||||
name: "existing values are preserved when the input is empty",
|
||||
options: action.ChartPathOptions{RepoURL: "https://charts.jetstack.io", Version: "v1.13.3"},
|
||||
input: ChartInput{},
|
||||
wantRepoURL: "https://charts.jetstack.io",
|
||||
wantVersion: "v1.13.3",
|
||||
},
|
||||
{
|
||||
name: "existing values are overridden by the input",
|
||||
options: action.ChartPathOptions{RepoURL: "https://charts.jetstack.io", Version: "v1.13.3"},
|
||||
input: ChartInput{RepoURL: "https://helm.traefik.io/traefik", Version: "26.0.0"},
|
||||
wantRepoURL: "https://helm.traefik.io/traefik",
|
||||
wantVersion: "26.0.0",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
options := tc.options
|
||||
applyChartPathOptions(&options, tc.input)
|
||||
|
||||
if options.RepoURL != tc.wantRepoURL {
|
||||
t.Errorf("RepoURL = %q, want %q", options.RepoURL, tc.wantRepoURL)
|
||||
}
|
||||
if options.Version != tc.wantVersion {
|
||||
t.Errorf("Version = %q, want %q", options.Version, tc.wantVersion)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func makeRevisions(revisions ...int) []Release {
|
||||
out := make([]Release, 0, len(revisions))
|
||||
for _, revision := range revisions {
|
||||
out = append(out, Release{Name: "traefik", Revision: revision})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func revisionNumbers(releases []Release) []int {
|
||||
out := make([]int, 0, len(releases))
|
||||
for _, release := range releases {
|
||||
out = append(out, release.Revision)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func equalInts(a, b []int) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestSelectRevisions(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
releases []Release
|
||||
max int
|
||||
want []int
|
||||
}{
|
||||
{
|
||||
name: "sorts an unordered ledger ascending",
|
||||
releases: makeRevisions(3, 1, 2),
|
||||
max: 0,
|
||||
want: []int{1, 2, 3},
|
||||
},
|
||||
{
|
||||
name: "a max of one keeps the newest revision",
|
||||
releases: makeRevisions(3, 1, 2),
|
||||
max: 1,
|
||||
want: []int{3},
|
||||
},
|
||||
{
|
||||
name: "a max below the ledger size keeps the newest revisions",
|
||||
releases: makeRevisions(4, 1, 3, 2),
|
||||
max: 2,
|
||||
want: []int{3, 4},
|
||||
},
|
||||
{
|
||||
name: "a max above the ledger size keeps everything",
|
||||
releases: makeRevisions(2, 1),
|
||||
max: 10,
|
||||
want: []int{1, 2},
|
||||
},
|
||||
{
|
||||
name: "an empty ledger stays empty",
|
||||
releases: []Release{},
|
||||
max: 1,
|
||||
want: []int{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := revisionNumbers(selectRevisions(tc.releases, tc.max))
|
||||
if !equalInts(got, tc.want) {
|
||||
t.Errorf("selectRevisions() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -219,26 +219,6 @@ var HelmCharts = []HelmChart{
|
||||
},
|
||||
}
|
||||
|
||||
type HelmRepository struct {
|
||||
Name string
|
||||
URL string
|
||||
}
|
||||
|
||||
var HelmRepositories = []HelmRepository{
|
||||
{
|
||||
Name: "jetstack",
|
||||
URL: "https://charts.jetstack.io",
|
||||
},
|
||||
{
|
||||
Name: "longhorn",
|
||||
URL: "https://charts.longhorn.io",
|
||||
},
|
||||
{
|
||||
Name: "traefik",
|
||||
URL: "https://helm.traefik.io/traefik",
|
||||
},
|
||||
}
|
||||
|
||||
// NodeProfile is a profile for a node in the k3s cluster
|
||||
type NodeProfile struct {
|
||||
// Name is the name of the node profile
|
||||
|
||||
@@ -29,8 +29,8 @@ Additional commands:`
|
||||
scheduler-k3s:cluster:add [--profile PROFILE] [--role ROLE] [--insecure-allow-unknown-hosts] [--server-ip SERVER_IP] [--taint-scheduling] [--kubelet-args KUBELET_ARGS] <ssh://user@host:port>, Adds a server node to a Dokku-managed cluster
|
||||
scheduler-k3s:cluster:list [--format json|stdout], Lists all nodes in a Dokku-managed cluster
|
||||
scheduler-k3s:cluster:remove [node-id], Removes client node to a Dokku-managed cluster
|
||||
scheduler-k3s:ensure-charts, Ensures the k3s charts are installed
|
||||
scheduler-k3s:initialize [--server-ip SERVER_IP] [--taint-scheduling] [--kubelet-args KUBELET_ARGS], Initializes a cluster
|
||||
scheduler-k3s:ensure-charts [--force] [--charts CHART_NAMES], Ensures the k3s charts are installed
|
||||
scheduler-k3s:initialize [--server-ip SERVER_IP] [--ingress-class INGRESS_CLASS] [--taint-scheduling] [--kubelet-args KUBELET_ARGS], Initializes a cluster
|
||||
scheduler-k3s:labels:clear <app|--global> [--process-type PROCESS_TYPE] [--resource-type RESOURCE_TYPE], Clear all labels for an app or a single process-type/resource-type scope
|
||||
scheduler-k3s:labels:set <app|--global> <property> (<value>) [--process-type PROCESS_TYPE] <--resource-type RESOURCE_TYPE>, Set or clear a label for a given app/process-type/resource-type combination
|
||||
scheduler-k3s:labels:set --replace <app|--global> <key=value> [<key=value> ...] [--process-type PROCESS_TYPE] <--resource-type RESOURCE_TYPE>, Replace the entire label map for a given app/process-type/resource-type combination
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -572,15 +571,11 @@ func CommandInitialize(ingressClass string, serverIP string, taintScheduling boo
|
||||
|
||||
common.LogInfo2Quiet("Installing helm charts")
|
||||
err = installHelmCharts(ctx, clientset, func(chart HelmChart) bool {
|
||||
if chart.ChartPath == "traefik" && ingressClass == "nginx" {
|
||||
return false
|
||||
}
|
||||
|
||||
if chart.ChartPath == "ingress-nginx" && ingressClass == "traefik" {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
return chartInstallDecision(ChartInstallDecisionInput{
|
||||
Chart: chart,
|
||||
ForceInstall: true,
|
||||
IngressClass: ingressClass,
|
||||
}).Install
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to install helm charts: %w", err)
|
||||
@@ -1269,25 +1264,6 @@ func CommandEnsureCharts(forceInstall bool, forceChartNames []string) error {
|
||||
common.LogInfo2Quiet("Installing helm charts")
|
||||
err = installHelmCharts(ctx, clientset, func(chart HelmChart) bool {
|
||||
common.LogInfo1(fmt.Sprintf("Processing chart %s@%s", chart.ReleaseName, chart.Version))
|
||||
if chart.ChartPath == "traefik" && ingressClass == "nginx" {
|
||||
common.LogVerbose("Skipping chart due to ingress-class mismatch")
|
||||
return false
|
||||
}
|
||||
|
||||
if chart.ChartPath == "ingress-nginx" && ingressClass == "traefik" {
|
||||
common.LogVerbose("Skipping chart due to ingress-class mismatch")
|
||||
return false
|
||||
}
|
||||
|
||||
if forceInstall {
|
||||
common.LogVerbose("Force installing chart")
|
||||
return true
|
||||
}
|
||||
|
||||
if len(forceChartNames) > 0 && slices.Contains(forceChartNames, chart.ReleaseName) {
|
||||
common.LogVerbose("Force installing chart due to flag")
|
||||
return true
|
||||
}
|
||||
|
||||
helmAgent := namespacedHelmAgents[chart.Namespace]
|
||||
latestRevision, err := helmAgent.InstalledRevision(chart.ReleaseName)
|
||||
@@ -1296,18 +1272,16 @@ func CommandEnsureCharts(forceInstall bool, forceChartNames []string) error {
|
||||
return false
|
||||
}
|
||||
|
||||
if latestRevision.Name == "" {
|
||||
common.LogVerbose("Installing missing chart")
|
||||
return true
|
||||
}
|
||||
decision := chartInstallDecision(ChartInstallDecisionInput{
|
||||
Chart: chart,
|
||||
ForceChartNames: forceChartNames,
|
||||
ForceInstall: forceInstall,
|
||||
IngressClass: ingressClass,
|
||||
InstalledRevision: latestRevision,
|
||||
})
|
||||
common.LogVerbose(decision.Reason)
|
||||
|
||||
if latestRevision.Version != chart.Version {
|
||||
common.LogVerbose(fmt.Sprintf("Installing chart due to version mismatch: %s != %s", latestRevision.AppVersion, chart.Version))
|
||||
return false
|
||||
}
|
||||
|
||||
common.LogVerbose("Skipping chart: already installed")
|
||||
return false
|
||||
return decision.Install
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to install helm charts: %w", err)
|
||||
|
||||
65
tests/unit/scheduler-k3s-ensure-charts.bats
Normal file
65
tests/unit/scheduler-k3s-ensure-charts.bats
Normal file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bats
|
||||
|
||||
load test_helper
|
||||
|
||||
setup() {
|
||||
uninstall_k3s || true
|
||||
global_setup
|
||||
dokku nginx:stop
|
||||
export KUBECONFIG="/etc/rancher/k3s/k3s.yaml"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
global_teardown
|
||||
dokku nginx:start
|
||||
uninstall_k3s || true
|
||||
}
|
||||
|
||||
@test "(scheduler-k3s:ensure-charts) upgrades an existing release to the pinned chart version" {
|
||||
if [[ -z "$DOCKERHUB_USERNAME" ]] || [[ -z "$DOCKERHUB_TOKEN" ]]; then
|
||||
skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN"
|
||||
fi
|
||||
|
||||
install_k3s
|
||||
|
||||
run /bin/bash -c "helm list -n traefik -o json | jq -r '.[] | select(.name == \"traefik\") | .chart'"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_success
|
||||
assert_output "traefik-26.0.0"
|
||||
|
||||
run /bin/bash -c "helm list -n traefik -o json | jq -r '.[] | select(.name == \"traefik\") | .revision'"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_success
|
||||
local initial_revision="$output"
|
||||
|
||||
run /bin/bash -c "dokku scheduler-k3s:ensure-charts --charts traefik"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_success
|
||||
|
||||
run /bin/bash -c "helm list -n traefik -o json | jq -r '.[] | select(.name == \"traefik\") | .chart'"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_success
|
||||
assert_output "traefik-26.0.0"
|
||||
|
||||
run /bin/bash -c "helm list -n traefik -o json | jq -r '.[] | select(.name == \"traefik\") | .revision'"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_success
|
||||
local upgraded_revision="$output"
|
||||
[[ "$upgraded_revision" -gt "$initial_revision" ]]
|
||||
|
||||
run /bin/bash -c "dokku scheduler-k3s:ensure-charts"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_success
|
||||
|
||||
run /bin/bash -c "helm list -n traefik -o json | jq -r '.[] | select(.name == \"traefik\") | .revision'"
|
||||
echo "output: $output"
|
||||
echo "status: $status"
|
||||
assert_success
|
||||
assert_output "$upgraded_revision"
|
||||
}
|
||||
Reference in New Issue
Block a user