diff --git a/Makefile b/Makefile index e3e6cb538..4cf779438 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ BINDIR := $(CURDIR)/bin INSTALL_PATH ?= /usr/local/bin DIST_DIRS := find * -type d -exec -TARGETS := darwin/amd64 darwin/arm64 linux/amd64 linux/386 linux/arm linux/arm64 linux/ppc64le linux/s390x linux/riscv64 windows/amd64 windows/arm64 -TARGET_OBJS ?= darwin-amd64.tar.gz darwin-amd64.tar.gz.sha256 darwin-amd64.tar.gz.sha256sum darwin-arm64.tar.gz darwin-arm64.tar.gz.sha256 darwin-arm64.tar.gz.sha256sum linux-amd64.tar.gz linux-amd64.tar.gz.sha256 linux-amd64.tar.gz.sha256sum linux-386.tar.gz linux-386.tar.gz.sha256 linux-386.tar.gz.sha256sum linux-arm.tar.gz linux-arm.tar.gz.sha256 linux-arm.tar.gz.sha256sum linux-arm64.tar.gz linux-arm64.tar.gz.sha256 linux-arm64.tar.gz.sha256sum linux-ppc64le.tar.gz linux-ppc64le.tar.gz.sha256 linux-ppc64le.tar.gz.sha256sum linux-s390x.tar.gz linux-s390x.tar.gz.sha256 linux-s390x.tar.gz.sha256sum linux-riscv64.tar.gz linux-riscv64.tar.gz.sha256 linux-riscv64.tar.gz.sha256sum windows-amd64.zip windows-amd64.zip.sha256 windows-amd64.zip.sha256sum windows-arm64.zip windows-arm64.zip.sha256 windows-arm64.zip.sha256sum +TARGETS := darwin/amd64 darwin/arm64 linux/amd64 linux/386 linux/arm linux/arm64 linux/loong64 linux/ppc64le linux/s390x linux/riscv64 windows/amd64 windows/arm64 +TARGET_OBJS ?= darwin-amd64.tar.gz darwin-amd64.tar.gz.sha256 darwin-amd64.tar.gz.sha256sum darwin-arm64.tar.gz darwin-arm64.tar.gz.sha256 darwin-arm64.tar.gz.sha256sum linux-amd64.tar.gz linux-amd64.tar.gz.sha256 linux-amd64.tar.gz.sha256sum linux-386.tar.gz linux-386.tar.gz.sha256 linux-386.tar.gz.sha256sum linux-arm.tar.gz linux-arm.tar.gz.sha256 linux-arm.tar.gz.sha256sum linux-arm64.tar.gz linux-arm64.tar.gz.sha256 linux-arm64.tar.gz.sha256sum linux-loong64.tar.gz linux-loong64.tar.gz.sha256 linux-loong64.tar.gz.sha256sum linux-ppc64le.tar.gz linux-ppc64le.tar.gz.sha256 linux-ppc64le.tar.gz.sha256sum linux-s390x.tar.gz linux-s390x.tar.gz.sha256 linux-s390x.tar.gz.sha256sum linux-riscv64.tar.gz linux-riscv64.tar.gz.sha256 linux-riscv64.tar.gz.sha256sum windows-amd64.zip windows-amd64.zip.sha256 windows-amd64.zip.sha256sum windows-arm64.zip windows-arm64.zip.sha256 windows-arm64.zip.sha256sum BINNAME ?= helm GOBIN = $(shell go env GOBIN) @@ -69,6 +69,8 @@ LDFLAGS += -X helm.sh/helm/v4/pkg/internal/v3/lint/rules.k8sVersionMajor=$(K8S_M LDFLAGS += -X helm.sh/helm/v4/pkg/internal/v3/lint/rules.k8sVersionMinor=$(K8S_MODULES_MINOR_VER) LDFLAGS += -X helm.sh/helm/v4/pkg/chart/common/util.k8sVersionMajor=$(K8S_MODULES_MAJOR_VER) LDFLAGS += -X helm.sh/helm/v4/pkg/chart/common/util.k8sVersionMinor=$(K8S_MODULES_MINOR_VER) +LDFLAGS += -X helm.sh/helm/v4/internal/version.kubeClientVersionMajor=$(K8S_MODULES_MAJOR_VER) +LDFLAGS += -X helm.sh/helm/v4/internal/version.kubeClientVersionMinor=$(K8S_MODULES_MINOR_VER) .PHONY: all all: build diff --git a/internal/chart/v3/lint/rules/template.go b/internal/chart/v3/lint/rules/template.go index d4c62839f..204966364 100644 --- a/internal/chart/v3/lint/rules/template.go +++ b/internal/chart/v3/lint/rules/template.go @@ -126,7 +126,7 @@ func TemplatesWithSkipSchemaValidation(linter *support.Linter, values map[string linter.RunLinterRule(support.ErrorSev, fpath, validateAllowedExtension(fileName)) // We only apply the following lint rules to yaml files - if filepath.Ext(fileName) != ".yaml" || filepath.Ext(fileName) == ".yml" { + if !isYamlFileExtension(fileName) { continue } @@ -335,6 +335,11 @@ func validateListAnnotations(yamlStruct *k8sYamlStruct, manifest string) error { return nil } +func isYamlFileExtension(fileName string) bool { + ext := strings.ToLower(filepath.Ext(fileName)) + return ext == ".yaml" || ext == ".yml" +} + // k8sYamlStruct stubs a Kubernetes YAML file. type k8sYamlStruct struct { APIVersion string `json:"apiVersion"` diff --git a/internal/chart/v3/lint/rules/template_test.go b/internal/chart/v3/lint/rules/template_test.go index 40bcfa26b..d7665211a 100644 --- a/internal/chart/v3/lint/rules/template_test.go +++ b/internal/chart/v3/lint/rules/template_test.go @@ -439,3 +439,23 @@ items: t.Fatalf("List objects keep annotations should pass. got: %s", err) } } + +func TestIsYamlFileExtension(t *testing.T) { + tests := []struct { + filename string + expected bool + }{ + {"test.yaml", true}, + {"test.yml", true}, + {"test.txt", false}, + {"test", false}, + } + + for _, test := range tests { + result := isYamlFileExtension(test.filename) + if result != test.expected { + t.Errorf("isYamlFileExtension(%s) = %v; want %v", test.filename, result, test.expected) + } + } + +} diff --git a/internal/chart/v3/util/create.go b/internal/chart/v3/util/create.go index 9f742e646..c5e728721 100644 --- a/internal/chart/v3/util/create.go +++ b/internal/chart/v3/util/create.go @@ -218,9 +218,10 @@ httpRoute: # value: v2 resources: {} - # We usually recommend not to specify default resources and to leave this as a conscious - # choice for the user. This also increases chances charts run on environments with little - # resources, such as Minikube. If you do want to specify resources, uncomment the following + # For publicly distributed charts, we recommend leaving 'resources' commented out. + # This makes resource allocation a conscious choice for the user and increases the chances + # charts run on a wide range of environments from low-resource clusters like Minikube to those + # with strict resource policies. If you do want to specify resources, uncomment the following # lines, adjust them as necessary, and remove the curly braces after 'resources:'. # limits: # cpu: 100m diff --git a/internal/plugin/installer/extractor.go b/internal/plugin/installer/extractor.go index 407138197..71efebc67 100644 --- a/internal/plugin/installer/extractor.go +++ b/internal/plugin/installer/extractor.go @@ -85,10 +85,10 @@ func NewExtractor(source string) (Extractor, error) { // // - The character `:` is considered illegal because it is a separator on UNIX and a // drive designator on Windows. -// - The path component `..` is considered suspicions, and therefore illegal +// - The path component `..` is considered suspicious, and therefore illegal // - The character \ (backslash) is treated as a path separator and is converted to /. // - Beginning a path with a path separator is illegal -// - Rudimentary symlink protects are offered by SecureJoin. +// - Rudimentary symlink protections are offered by SecureJoin. func cleanJoin(root, dest string) (string, error) { // On Windows, this is a drive separator. On UNIX-like, this is the path list separator. diff --git a/internal/plugin/installer/installer.go b/internal/plugin/installer/installer.go index a6599c443..c7c1a8801 100644 --- a/internal/plugin/installer/installer.go +++ b/internal/plugin/installer/installer.go @@ -139,18 +139,24 @@ func Update(i Installer) error { } // NewForSource determines the correct Installer for the given source. -func NewForSource(source, version string) (Installer, error) { - // Check if source is an OCI registry reference +func NewForSource(source, version string) (installer Installer, err error) { if strings.HasPrefix(source, fmt.Sprintf("%s://", registry.OCIScheme)) { - return NewOCIInstaller(source) - } - // Check if source is a local directory - if isLocalReference(source) { - return NewLocalInstaller(source) + // Source is an OCI registry reference + installer, err = NewOCIInstaller(source) + } else if isLocalReference(source) { + // Source is a local directory + installer, err = NewLocalInstaller(source) } else if isRemoteHTTPArchive(source) { - return NewHTTPInstaller(source) + installer, err = NewHTTPInstaller(source) + } else { + installer, err = NewVCSInstaller(source, version) + } + + if err != nil { + return installer, fmt.Errorf("cannot get information about plugin source %q (if it's a local directory, does it exist?), last error was: %w", source, err) } - return NewVCSInstaller(source, version) + + return } // FindSource determines the correct Installer for the given source. diff --git a/internal/plugin/installer/local_installer.go b/internal/plugin/installer/local_installer.go index e02261d59..1c8314282 100644 --- a/internal/plugin/installer/local_installer.go +++ b/internal/plugin/installer/local_installer.go @@ -29,8 +29,8 @@ import ( "helm.sh/helm/v4/pkg/helmpath" ) -// ErrPluginNotAFolder indicates that the plugin path is not a folder. -var ErrPluginNotAFolder = errors.New("expected plugin to be a folder") +// ErrPluginNotADirectory indicates that the plugin path is not a directory. +var ErrPluginNotADirectory = errors.New("expected plugin to be a directory (containing a file 'plugin.yaml')") // LocalInstaller installs plugins from the filesystem. type LocalInstaller struct { @@ -91,7 +91,7 @@ func (i *LocalInstaller) installFromDirectory() error { return err } if !stat.IsDir() { - return ErrPluginNotAFolder + return ErrPluginNotADirectory } if !isPlugin(i.Source) { diff --git a/internal/plugin/installer/local_installer_test.go b/internal/plugin/installer/local_installer_test.go index 189108fdb..2decb695f 100644 --- a/internal/plugin/installer/local_installer_test.go +++ b/internal/plugin/installer/local_installer_test.go @@ -64,7 +64,7 @@ func TestLocalInstallerNotAFolder(t *testing.T) { if err == nil { t.Fatal("expected error") } - if err != ErrPluginNotAFolder { + if err != ErrPluginNotADirectory { t.Fatalf("expected error to equal: %q", err) } } diff --git a/internal/plugin/plugin_type_registry.go b/internal/plugin/plugin_type_registry.go index da6546c47..5138422bd 100644 --- a/internal/plugin/plugin_type_registry.go +++ b/internal/plugin/plugin_type_registry.go @@ -73,27 +73,27 @@ type pluginTypeMeta struct { var pluginTypes = []pluginTypeMeta{ { pluginType: "test/v1", - inputType: reflect.TypeOf(schema.InputMessageTestV1{}), - outputType: reflect.TypeOf(schema.OutputMessageTestV1{}), - configType: reflect.TypeOf(schema.ConfigTestV1{}), + inputType: reflect.TypeFor[schema.InputMessageTestV1](), + outputType: reflect.TypeFor[schema.OutputMessageTestV1](), + configType: reflect.TypeFor[schema.ConfigTestV1](), }, { pluginType: "cli/v1", - inputType: reflect.TypeOf(schema.InputMessageCLIV1{}), - outputType: reflect.TypeOf(schema.OutputMessageCLIV1{}), - configType: reflect.TypeOf(schema.ConfigCLIV1{}), + inputType: reflect.TypeFor[schema.InputMessageCLIV1](), + outputType: reflect.TypeFor[schema.OutputMessageCLIV1](), + configType: reflect.TypeFor[schema.ConfigCLIV1](), }, { pluginType: "getter/v1", - inputType: reflect.TypeOf(schema.InputMessageGetterV1{}), - outputType: reflect.TypeOf(schema.OutputMessageGetterV1{}), - configType: reflect.TypeOf(schema.ConfigGetterV1{}), + inputType: reflect.TypeFor[schema.InputMessageGetterV1](), + outputType: reflect.TypeFor[schema.OutputMessageGetterV1](), + configType: reflect.TypeFor[schema.ConfigGetterV1](), }, { pluginType: "postrenderer/v1", - inputType: reflect.TypeOf(schema.InputMessagePostRendererV1{}), - outputType: reflect.TypeOf(schema.OutputMessagePostRendererV1{}), - configType: reflect.TypeOf(schema.ConfigPostRendererV1{}), + inputType: reflect.TypeFor[schema.InputMessagePostRendererV1](), + outputType: reflect.TypeFor[schema.OutputMessagePostRendererV1](), + configType: reflect.TypeFor[schema.ConfigPostRendererV1](), }, } diff --git a/internal/plugin/runtime_subprocess_test.go b/internal/plugin/runtime_subprocess_test.go index 243f4ad7c..ed251d28b 100644 --- a/internal/plugin/runtime_subprocess_test.go +++ b/internal/plugin/runtime_subprocess_test.go @@ -71,7 +71,7 @@ func TestSubprocessPluginRuntime(t *testing.T) { output, err := p.Invoke(t.Context(), &Input{ Message: schema.InputMessageCLIV1{ ExtraArgs: []string{"arg1", "arg2"}, - //Env: []string{"FOO=bar"}, + // Env: []string{"FOO=bar"}, }, }) diff --git a/internal/sympath/walk.go b/internal/sympath/walk.go index f67b9f1b9..812bb68ce 100644 --- a/internal/sympath/walk.go +++ b/internal/sympath/walk.go @@ -70,7 +70,7 @@ func symwalk(path string, info os.FileInfo, walkFn filepath.WalkFunc) error { if err != nil { return fmt.Errorf("error evaluating symlink %s: %w", path, err) } - //This log message is to highlight a symlink that is being used within a chart, symlinks can be used for nefarious reasons. + // This log message is to highlight a symlink that is being used within a chart, symlinks can be used for nefarious reasons. slog.Info("found symbolic link in path. Contents of linked file included and used", "path", path, "resolved", resolved) if info, err = os.Lstat(resolved); err != nil { return err diff --git a/internal/version/version.go b/internal/version/version.go index aa64e618f..b7f2436a1 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -18,6 +18,7 @@ package version // import "helm.sh/helm/v4/internal/version" import ( "flag" + "fmt" "runtime" "strings" ) @@ -37,6 +38,11 @@ var ( gitCommit = "" // gitTreeState is the state of the git tree gitTreeState = "" + + // The Kubernetes version can be set by LDFLAGS. In order to do that the value + // must be a string. + kubeClientVersionMajor = "" + kubeClientVersionMinor = "" ) // BuildInfo describes the compile time information. @@ -49,6 +55,8 @@ type BuildInfo struct { GitTreeState string `json:"git_tree_state,omitempty"` // GoVersion is the version of the Go compiler used. GoVersion string `json:"go_version,omitempty"` + // KubeClientVersion is the version of client-go Helm was build with + KubeClientVersion string `json:"kube_client_version"` } // GetVersion returns the semver string of the version @@ -67,10 +75,11 @@ func GetUserAgent() string { // Get returns build info func Get() BuildInfo { v := BuildInfo{ - Version: GetVersion(), - GitCommit: gitCommit, - GitTreeState: gitTreeState, - GoVersion: runtime.Version(), + Version: GetVersion(), + GitCommit: gitCommit, + GitTreeState: gitTreeState, + GoVersion: runtime.Version(), + KubeClientVersion: fmt.Sprintf("v%s.%s", kubeClientVersionMajor, kubeClientVersionMinor), } // HACK(bacongobbler): strip out GoVersion during a test run for consistent test output diff --git a/pkg/chart/v2/lint/rules/template.go b/pkg/chart/v2/lint/rules/template.go index b21050a9e..0c633dc1a 100644 --- a/pkg/chart/v2/lint/rules/template.go +++ b/pkg/chart/v2/lint/rules/template.go @@ -156,7 +156,7 @@ func (t *templateLinter) Lint() { t.linter.RunLinterRule(support.ErrorSev, fileName, validateAllowedExtension(fileName)) // We only apply the following lint rules to yaml files - if filepath.Ext(fileName) != ".yaml" || filepath.Ext(fileName) == ".yml" { + if !isYamlFileExtension(fileName) { continue } @@ -366,6 +366,11 @@ func validateListAnnotations(yamlStruct *k8sYamlStruct, manifest string) error { return nil } +func isYamlFileExtension(fileName string) bool { + ext := strings.ToLower(filepath.Ext(fileName)) + return ext == ".yaml" || ext == ".yml" +} + // k8sYamlStruct stubs a Kubernetes YAML file. type k8sYamlStruct struct { APIVersion string `json:"apiVersion"` diff --git a/pkg/chart/v2/lint/rules/template_test.go b/pkg/chart/v2/lint/rules/template_test.go index 7629d3de5..7f9899070 100644 --- a/pkg/chart/v2/lint/rules/template_test.go +++ b/pkg/chart/v2/lint/rules/template_test.go @@ -462,3 +462,23 @@ items: t.Fatalf("List objects keep annotations should pass. got: %s", err) } } + +func TestIsYamlFileExtension(t *testing.T) { + tests := []struct { + filename string + expected bool + }{ + {"test.yaml", true}, + {"test.yml", true}, + {"test.txt", false}, + {"test", false}, + } + + for _, test := range tests { + result := isYamlFileExtension(test.filename) + if result != test.expected { + t.Errorf("isYamlFileExtension(%s) = %v; want %v", test.filename, result, test.expected) + } + } + +} diff --git a/pkg/cmd/list_test.go b/pkg/cmd/list_test.go index 097e62d11..35153465a 100644 --- a/pkg/cmd/list_test.go +++ b/pkg/cmd/list_test.go @@ -245,3 +245,373 @@ func TestListOutputCompletion(t *testing.T) { func TestListFileCompletion(t *testing.T) { checkFileCompletion(t, "list", false) } + +func TestListOutputFormats(t *testing.T) { + defaultNamespace := "default" + timestamp := time.Unix(1452902400, 0).UTC() + chartInfo := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test-chart", + Version: "1.0.0", + AppVersion: "0.0.1", + }, + } + + releaseFixture := []*release.Release{ + { + Name: "test-release", + Version: 1, + Namespace: defaultNamespace, + Info: &release.Info{ + LastDeployed: timestamp, + Status: common.StatusDeployed, + }, + Chart: chartInfo, + }, + } + + tests := []cmdTestCase{{ + name: "list releases in json format", + cmd: "list --output json", + golden: "output/list-json.txt", + rels: releaseFixture, + }, { + name: "list releases in yaml format", + cmd: "list --output yaml", + golden: "output/list-yaml.txt", + rels: releaseFixture, + }} + runTestCmd(t, tests) +} + +func TestReleaseListWriter(t *testing.T) { + timestamp := time.Unix(1452902400, 0).UTC() + chartInfo := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test-chart", + Version: "1.0.0", + AppVersion: "0.0.1", + }, + } + + releases := []*release.Release{ + { + Name: "test-release", + Version: 1, + Namespace: "default", + Info: &release.Info{ + LastDeployed: timestamp, + Status: common.StatusDeployed, + }, + Chart: chartInfo, + }, + } + + tests := []struct { + name string + releases []*release.Release + timeFormat string + noHeaders bool + noColor bool + }{ + { + name: "empty releases list", + releases: []*release.Release{}, + timeFormat: "", + noHeaders: false, + noColor: false, + }, + { + name: "custom time format", + releases: releases, + timeFormat: "2006-01-02", + noHeaders: false, + noColor: false, + }, + { + name: "no headers", + releases: releases, + timeFormat: "", + noHeaders: true, + noColor: false, + }, + { + name: "no color", + releases: releases, + timeFormat: "", + noHeaders: false, + noColor: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + writer := newReleaseListWriter(tt.releases, tt.timeFormat, tt.noHeaders, tt.noColor) + + if writer == nil { + t.Error("Expected writer to be non-nil") + } else { + if len(writer.releases) != len(tt.releases) { + t.Errorf("Expected %d releases, got %d", len(tt.releases), len(writer.releases)) + } + } + }) + } +} + +func TestReleaseListWriterMethods(t *testing.T) { + timestamp := time.Unix(1452902400, 0).UTC() + zeroTimestamp := time.Time{} + chartInfo := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test-chart", + Version: "1.0.0", + AppVersion: "0.0.1", + }, + } + + releases := []*release.Release{ + { + Name: "test-release", + Version: 1, + Namespace: "default", + Info: &release.Info{ + LastDeployed: timestamp, + Status: common.StatusDeployed, + }, + Chart: chartInfo, + }, + { + Name: "zero-time-release", + Version: 1, + Namespace: "default", + Info: &release.Info{ + LastDeployed: zeroTimestamp, + Status: common.StatusFailed, + }, + Chart: chartInfo, + }, + } + + tests := []struct { + name string + status common.Status + }{ + {"deployed", common.StatusDeployed}, + {"failed", common.StatusFailed}, + {"pending-install", common.StatusPendingInstall}, + {"pending-upgrade", common.StatusPendingUpgrade}, + {"pending-rollback", common.StatusPendingRollback}, + {"uninstalling", common.StatusUninstalling}, + {"uninstalled", common.StatusUninstalled}, + {"superseded", common.StatusSuperseded}, + {"unknown", common.StatusUnknown}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testReleases := []*release.Release{ + { + Name: "test-release", + Version: 1, + Namespace: "default", + Info: &release.Info{ + LastDeployed: timestamp, + Status: tt.status, + }, + Chart: chartInfo, + }, + } + + writer := newReleaseListWriter(testReleases, "", false, false) + + var buf []byte + out := &bytesWriter{buf: &buf} + + err := writer.WriteJSON(out) + if err != nil { + t.Errorf("WriteJSON failed: %v", err) + } + + err = writer.WriteYAML(out) + if err != nil { + t.Errorf("WriteYAML failed: %v", err) + } + + err = writer.WriteTable(out) + if err != nil { + t.Errorf("WriteTable failed: %v", err) + } + }) + } + + writer := newReleaseListWriter(releases, "", false, false) + + var buf []byte + out := &bytesWriter{buf: &buf} + + err := writer.WriteJSON(out) + if err != nil { + t.Errorf("WriteJSON failed: %v", err) + } + + err = writer.WriteYAML(out) + if err != nil { + t.Errorf("WriteYAML failed: %v", err) + } + + err = writer.WriteTable(out) + if err != nil { + t.Errorf("WriteTable failed: %v", err) + } +} + +func TestFilterReleases(t *testing.T) { + releases := []*release.Release{ + {Name: "release1"}, + {Name: "release2"}, + {Name: "release3"}, + } + + tests := []struct { + name string + releases []*release.Release + ignoredReleaseNames []string + expectedCount int + }{ + { + name: "nil ignored list", + releases: releases, + ignoredReleaseNames: nil, + expectedCount: 3, + }, + { + name: "empty ignored list", + releases: releases, + ignoredReleaseNames: []string{}, + expectedCount: 3, + }, + { + name: "filter one release", + releases: releases, + ignoredReleaseNames: []string{"release1"}, + expectedCount: 2, + }, + { + name: "filter multiple releases", + releases: releases, + ignoredReleaseNames: []string{"release1", "release3"}, + expectedCount: 1, + }, + { + name: "filter non-existent release", + releases: releases, + ignoredReleaseNames: []string{"non-existent"}, + expectedCount: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := filterReleases(tt.releases, tt.ignoredReleaseNames) + if len(result) != tt.expectedCount { + t.Errorf("Expected %d releases, got %d", tt.expectedCount, len(result)) + } + }) + } +} + +type bytesWriter struct { + buf *[]byte +} + +func (b *bytesWriter) Write(p []byte) (n int, err error) { + *b.buf = append(*b.buf, p...) + return len(p), nil +} + +func TestListCustomTimeFormat(t *testing.T) { + defaultNamespace := "default" + timestamp := time.Unix(1452902400, 0).UTC() + chartInfo := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test-chart", + Version: "1.0.0", + AppVersion: "0.0.1", + }, + } + + releaseFixture := []*release.Release{ + { + Name: "test-release", + Version: 1, + Namespace: defaultNamespace, + Info: &release.Info{ + LastDeployed: timestamp, + Status: common.StatusDeployed, + }, + Chart: chartInfo, + }, + } + + tests := []cmdTestCase{{ + name: "list releases with custom time format", + cmd: "list --time-format '2006-01-02 15:04:05'", + golden: "output/list-time-format.txt", + rels: releaseFixture, + }} + runTestCmd(t, tests) +} + +func TestListStatusMapping(t *testing.T) { + defaultNamespace := "default" + timestamp := time.Unix(1452902400, 0).UTC() + chartInfo := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test-chart", + Version: "1.0.0", + AppVersion: "0.0.1", + }, + } + + testCases := []struct { + name string + status common.Status + }{ + {"deployed", common.StatusDeployed}, + {"failed", common.StatusFailed}, + {"pending-install", common.StatusPendingInstall}, + {"pending-upgrade", common.StatusPendingUpgrade}, + {"pending-rollback", common.StatusPendingRollback}, + {"uninstalling", common.StatusUninstalling}, + {"uninstalled", common.StatusUninstalled}, + {"superseded", common.StatusSuperseded}, + {"unknown", common.StatusUnknown}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + releaseFixture := []*release.Release{ + { + Name: "test-release", + Version: 1, + Namespace: defaultNamespace, + Info: &release.Info{ + LastDeployed: timestamp, + Status: tc.status, + }, + Chart: chartInfo, + }, + } + + writer := newReleaseListWriter(releaseFixture, "", false, false) + if len(writer.releases) != 1 { + t.Errorf("Expected 1 release, got %d", len(writer.releases)) + } + + if writer.releases[0].Status != tc.status.String() { + t.Errorf("Expected status %s, got %s", tc.status.String(), writer.releases[0].Status) + } + }) + } +} diff --git a/pkg/cmd/search_repo.go b/pkg/cmd/search_repo.go index 35608e22e..07345a48f 100644 --- a/pkg/cmd/search_repo.go +++ b/pkg/cmd/search_repo.go @@ -287,7 +287,7 @@ func compListChartsOfRepo(repoName string, prefix string) []string { if isNotExist(err) { // If there is no cached charts file, fallback to the full index file. // This is much slower but can happen after the caching feature is first - // installed but before the user does a 'helm repo update' to generate the + // installed but before the user does a 'helm repo update' to generate the // first cached charts file. path = filepath.Join(settings.RepositoryCache, helmpath.CacheIndexFile(repoName)) if indexFile, err := repo.LoadIndexFile(path); err == nil { diff --git a/pkg/cmd/testdata/output/list-json.txt b/pkg/cmd/testdata/output/list-json.txt new file mode 100644 index 000000000..89e4d9dcf --- /dev/null +++ b/pkg/cmd/testdata/output/list-json.txt @@ -0,0 +1 @@ +[{"name":"test-release","namespace":"default","revision":"1","updated":"2016-01-16 00:00:00 +0000 UTC","status":"deployed","chart":"test-chart-1.0.0","app_version":"0.0.1"}] diff --git a/pkg/cmd/testdata/output/list-time-format.txt b/pkg/cmd/testdata/output/list-time-format.txt new file mode 100644 index 000000000..4d493da7c --- /dev/null +++ b/pkg/cmd/testdata/output/list-time-format.txt @@ -0,0 +1,2 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +test-release default 1 2016-01-16 00:00:00 deployed test-chart-1.0.0 0.0.1 diff --git a/pkg/cmd/testdata/output/list-yaml.txt b/pkg/cmd/testdata/output/list-yaml.txt new file mode 100644 index 000000000..9e1d41f30 --- /dev/null +++ b/pkg/cmd/testdata/output/list-yaml.txt @@ -0,0 +1,7 @@ +- app_version: 0.0.1 + chart: test-chart-1.0.0 + name: test-release + namespace: default + revision: "1" + status: deployed + updated: 2016-01-16 00:00:00 +0000 UTC diff --git a/pkg/cmd/testdata/output/version.txt b/pkg/cmd/testdata/output/version.txt index 3b138ae77..2d50053f2 100644 --- a/pkg/cmd/testdata/output/version.txt +++ b/pkg/cmd/testdata/output/version.txt @@ -1 +1 @@ -version.BuildInfo{Version:"v4.0", GitCommit:"", GitTreeState:"", GoVersion:""} +version.BuildInfo{Version:"v4.0", GitCommit:"", GitTreeState:"", GoVersion:"", KubeClientVersion:"v."} diff --git a/pkg/engine/files.go b/pkg/engine/files.go index f0a86988e..7834cac2c 100644 --- a/pkg/engine/files.go +++ b/pkg/engine/files.go @@ -64,7 +64,7 @@ func (f files) Get(name string) string { } // Glob takes a glob pattern and returns another files object only containing -// matched files. +// matched files. // // This is designed to be called from a template. // diff --git a/pkg/getter/httpgetter_test.go b/pkg/getter/httpgetter_test.go index f87d71877..96bfa1ece 100644 --- a/pkg/getter/httpgetter_test.go +++ b/pkg/getter/httpgetter_test.go @@ -520,11 +520,11 @@ func TestHTTPGetterTarDownload(t *testing.T) { b := make([]byte, 512) f.Read(b) - //Get the file size + // Get the file size FileStat, _ := f.Stat() FileSize := strconv.FormatInt(FileStat.Size(), 10) - //Simulating improper header values from bitbucket + // Simulating improper header values from bitbucket w.Header().Set("Content-Type", "application/x-tar") w.Header().Set("Content-Encoding", "gzip") w.Header().Set("Content-Length", FileSize) diff --git a/pkg/getter/plugingetter.go b/pkg/getter/plugingetter.go index 32dbc70c9..d74611637 100644 --- a/pkg/getter/plugingetter.go +++ b/pkg/getter/plugingetter.go @@ -109,7 +109,7 @@ func (g *getterPlugin) Get(href string, options ...Option) (*bytes.Buffer, error Protocol: u.Scheme, }, // TODO should we pass Stdin, Stdout, and Stderr through Input here to getter plugins? - //Stdout: os.Stdout, + // Stdout: os.Stdout, } output, err := g.plg.Invoke(context.Background(), input) if err != nil { diff --git a/pkg/kube/client.go b/pkg/kube/client.go index b290cf721..a8023516a 100644 --- a/pkg/kube/client.go +++ b/pkg/kube/client.go @@ -573,10 +573,17 @@ func (c *Client) update(originals, targets ResourceList, updateApplyFunc UpdateA } if err := deleteResource(info, metav1.DeletePropagationBackground); err != nil { c.Logger().Debug("failed to delete resource", "namespace", info.Namespace, "name", info.Name, "kind", info.Mapping.GroupVersionKind.Kind, slog.Any("error", err)) + if !apierrors.IsNotFound(err) { + updateErrors = append(updateErrors, fmt.Errorf("failed to delete resource %s: %w", info.Name, err)) + } continue } res.Deleted = append(res.Deleted, info) } + + if len(updateErrors) != 0 { + return res, joinErrors(updateErrors, " && ") + } return res, nil } @@ -693,19 +700,19 @@ func (c *Client) Update(originals, targets ResourceList, options ...ClientUpdate errs = append(errs, o(&updateOptions)) } if err := errors.Join(errs...); err != nil { - return nil, fmt.Errorf("invalid client update option(s): %w", err) + return &Result{}, fmt.Errorf("invalid client update option(s): %w", err) } if updateOptions.threeWayMergeForUnstructured && updateOptions.serverSideApply { - return nil, fmt.Errorf("invalid operation: cannot use three-way merge for unstructured and server-side apply together") + return &Result{}, fmt.Errorf("invalid operation: cannot use three-way merge for unstructured and server-side apply together") } if updateOptions.forceConflicts && updateOptions.forceReplace { - return nil, fmt.Errorf("invalid operation: cannot use force conflicts and force replace together") + return &Result{}, fmt.Errorf("invalid operation: cannot use force conflicts and force replace together") } if updateOptions.serverSideApply && updateOptions.forceReplace { - return nil, fmt.Errorf("invalid operation: cannot use server-side apply and force replace together") + return &Result{}, fmt.Errorf("invalid operation: cannot use server-side apply and force replace together") } makeUpdateApplyFunc := func() UpdateApplyFunc { diff --git a/pkg/kube/client_test.go b/pkg/kube/client_test.go index d8c0fba5f..3934171be 100644 --- a/pkg/kube/client_test.go +++ b/pkg/kube/client_test.go @@ -321,6 +321,7 @@ func TestUpdate(t *testing.T) { ThreeWayMergeForUnstructured bool ServerSideApply bool ExpectedActions []string + ExpectedError string } expectedActionsClientSideApply := []string{ @@ -336,6 +337,8 @@ func TestUpdate(t *testing.T) { "/namespaces/default/pods:POST", // retry due to 409 "/namespaces/default/pods/squid:GET", "/namespaces/default/pods/squid:DELETE", + "/namespaces/default/pods/notfound:GET", + "/namespaces/default/pods/notfound:DELETE", } expectedActionsServerSideApply := []string{ @@ -351,11 +354,13 @@ func TestUpdate(t *testing.T) { "/namespaces/default/pods:POST", // retry due to 409 "/namespaces/default/pods/squid:GET", "/namespaces/default/pods/squid:DELETE", + "/namespaces/default/pods/notfound:GET", + "/namespaces/default/pods/notfound:DELETE", } testCases := map[string]testCase{ "client-side apply": { - OriginalPods: newPodList("starfish", "otter", "squid"), + OriginalPods: newPodList("starfish", "otter", "squid", "notfound"), TargetPods: func() v1.PodList { listTarget := newPodList("starfish", "otter", "dolphin") listTarget.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}} @@ -365,9 +370,10 @@ func TestUpdate(t *testing.T) { ThreeWayMergeForUnstructured: false, ServerSideApply: false, ExpectedActions: expectedActionsClientSideApply, + ExpectedError: "", }, "client-side apply (three-way merge for unstructured)": { - OriginalPods: newPodList("starfish", "otter", "squid"), + OriginalPods: newPodList("starfish", "otter", "squid", "notfound"), TargetPods: func() v1.PodList { listTarget := newPodList("starfish", "otter", "dolphin") listTarget.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}} @@ -377,9 +383,10 @@ func TestUpdate(t *testing.T) { ThreeWayMergeForUnstructured: true, ServerSideApply: false, ExpectedActions: expectedActionsClientSideApply, + ExpectedError: "", }, "serverSideApply": { - OriginalPods: newPodList("starfish", "otter", "squid"), + OriginalPods: newPodList("starfish", "otter", "squid", "notfound"), TargetPods: func() v1.PodList { listTarget := newPodList("starfish", "otter", "dolphin") listTarget.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}} @@ -389,6 +396,23 @@ func TestUpdate(t *testing.T) { ThreeWayMergeForUnstructured: false, ServerSideApply: true, ExpectedActions: expectedActionsServerSideApply, + ExpectedError: "", + }, + "serverSideApply with forbidden deletion": { + OriginalPods: newPodList("starfish", "otter", "squid", "notfound", "forbidden"), + TargetPods: func() v1.PodList { + listTarget := newPodList("starfish", "otter", "dolphin") + listTarget.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}} + + return listTarget + }(), + ThreeWayMergeForUnstructured: false, + ServerSideApply: true, + ExpectedActions: append(expectedActionsServerSideApply, + "/namespaces/default/pods/forbidden:GET", + "/namespaces/default/pods/forbidden:DELETE", + ), + ExpectedError: "failed to delete resource forbidden:", }, } @@ -444,6 +468,22 @@ func TestUpdate(t *testing.T) { return newResponse(http.StatusOK, &listTarget.Items[1]) case p == "/namespaces/default/pods/squid" && m == http.MethodGet: return newResponse(http.StatusOK, &listTarget.Items[2]) + case p == "/namespaces/default/pods/notfound" && m == http.MethodGet: + // Resource exists in original but will simulate not found on delete + return newResponse(http.StatusOK, &listOriginal.Items[3]) + case p == "/namespaces/default/pods/notfound" && m == http.MethodDelete: + // Simulate a not found during deletion; should not cause update to fail + return newResponse(http.StatusNotFound, notFoundBody()) + case p == "/namespaces/default/pods/forbidden" && m == http.MethodGet: + return newResponse(http.StatusOK, &listOriginal.Items[4]) + case p == "/namespaces/default/pods/forbidden" && m == http.MethodDelete: + // Simulate RBAC forbidden that should cause update to fail + return newResponse(http.StatusForbidden, &metav1.Status{ + Status: metav1.StatusFailure, + Message: "pods \"forbidden\" is forbidden: User \"test-user\" cannot delete resource \"pods\" in API group \"\" in the namespace \"default\"", + Reason: metav1.StatusReasonForbidden, + Code: http.StatusForbidden, + }) default: } @@ -471,7 +511,13 @@ func TestUpdate(t *testing.T) { ClientUpdateOptionForceReplace(false), ClientUpdateOptionServerSideApply(tc.ServerSideApply, false), ClientUpdateOptionUpgradeClientSideFieldManager(true)) - require.NoError(t, err) + + if tc.ExpectedError != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.ExpectedError) + } else { + require.NoError(t, err) + } assert.Len(t, result.Created, 1, "expected 1 resource created, got %d", len(result.Created)) assert.Len(t, result.Updated, 2, "expected 2 resource updated, got %d", len(result.Updated)) diff --git a/pkg/strvals/parser.go b/pkg/strvals/parser.go index c65e98c84..86e349f37 100644 --- a/pkg/strvals/parser.go +++ b/pkg/strvals/parser.go @@ -237,7 +237,7 @@ func (t *parser) key(data map[string]interface{}, nestedNameLevel int) (reterr e _, err = t.emptyVal() return err } - //End of key. Consume =, Get value. + // End of key. Consume =, Get value. // FIXME: Get value list first vl, e := t.valList() switch e { diff --git a/scripts/get b/scripts/get index 45ae3275b..25fd08e76 100755 --- a/scripts/get +++ b/scripts/get @@ -60,7 +60,7 @@ runAsRoot() { # verifySupported checks that the os/arch combination is supported for # binary builds. verifySupported() { - local supported="darwin-amd64\nlinux-386\nlinux-amd64\nlinux-arm\nlinux-arm64\nlinux-ppc64le\nlinux-s390x\nlinux-riscv64\nwindows-amd64\nwindows-arm64" + local supported="darwin-amd64\nlinux-386\nlinux-amd64\nlinux-arm\nlinux-arm64\nlinux-loong64\nlinux-ppc64le\nlinux-s390x\nwindows-amd64\nwindows-arm64" if ! echo "${supported}" | grep -q "${OS}-${ARCH}"; then echo "No prebuilt binary for ${OS}-${ARCH}." echo "To build from source, go to https://github.com/helm/helm" diff --git a/scripts/get-helm-3 b/scripts/get-helm-3 index 3aa44daee..e4b12c2ad 100755 --- a/scripts/get-helm-3 +++ b/scripts/get-helm-3 @@ -69,7 +69,7 @@ runAsRoot() { # verifySupported checks that the os/arch combination is supported for # binary builds, as well whether or not necessary tools are present. verifySupported() { - local supported="darwin-amd64\ndarwin-arm64\nlinux-386\nlinux-amd64\nlinux-arm\nlinux-arm64\nlinux-ppc64le\nlinux-s390x\nlinux-riscv64\nwindows-amd64\nwindows-arm64" + local supported="darwin-amd64\ndarwin-arm64\nlinux-386\nlinux-amd64\nlinux-arm\nlinux-arm64\nlinux-loong64\nlinux-ppc64le\nlinux-s390x\nlinux-riscv64\nwindows-amd64\nwindows-arm64" if ! echo "${supported}" | grep -q "${OS}-${ARCH}"; then echo "No prebuilt binary for ${OS}-${ARCH}." echo "To build from source, go to https://github.com/helm/helm" diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh index cea9bf4dc..48328cb38 100755 --- a/scripts/release-notes.sh +++ b/scripts/release-notes.sh @@ -87,6 +87,7 @@ Download Helm ${RELEASE}. The common platform binaries are here: - [Linux arm](https://get.helm.sh/helm-${RELEASE}-linux-arm.tar.gz) ([checksum](https://get.helm.sh/helm-${RELEASE}-linux-arm.tar.gz.sha256sum) / $(cat _dist/helm-${RELEASE}-linux-arm.tar.gz.sha256)) - [Linux arm64](https://get.helm.sh/helm-${RELEASE}-linux-arm64.tar.gz) ([checksum](https://get.helm.sh/helm-${RELEASE}-linux-arm64.tar.gz.sha256sum) / $(cat _dist/helm-${RELEASE}-linux-arm64.tar.gz.sha256)) - [Linux i386](https://get.helm.sh/helm-${RELEASE}-linux-386.tar.gz) ([checksum](https://get.helm.sh/helm-${RELEASE}-linux-386.tar.gz.sha256sum) / $(cat _dist/helm-${RELEASE}-linux-386.tar.gz.sha256)) +- [Linux loong64](https://get.helm.sh/helm-${RELEASE}-linux-loong64.tar.gz) ([checksum](https://get.helm.sh/helm-${RELEASE}-linux-loong64.tar.gz.sha256sum) / $(cat _dist/helm-${RELEASE}-linux-loong64.tar.gz.sha256)) - [Linux ppc64le](https://get.helm.sh/helm-${RELEASE}-linux-ppc64le.tar.gz) ([checksum](https://get.helm.sh/helm-${RELEASE}-linux-ppc64le.tar.gz.sha256sum) / $(cat _dist/helm-${RELEASE}-linux-ppc64le.tar.gz.sha256)) - [Linux s390x](https://get.helm.sh/helm-${RELEASE}-linux-s390x.tar.gz) ([checksum](https://get.helm.sh/helm-${RELEASE}-linux-s390x.tar.gz.sha256sum) / $(cat _dist/helm-${RELEASE}-linux-s390x.tar.gz.sha256)) - [Linux riscv64](https://get.helm.sh/helm-${RELEASE}-linux-riscv64.tar.gz) ([checksum](https://get.helm.sh/helm-${RELEASE}-linux-riscv64.tar.gz.sha256sum) / $(cat _dist/helm-${RELEASE}-linux-riscv64.tar.gz.sha256))