Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package helmfile for openSUSE:Factory checked in at 2026-06-17 16:22:23 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/helmfile (Old) and /work/SRC/openSUSE:Factory/.helmfile.new.1981 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "helmfile" Wed Jun 17 16:22:23 2026 rev:95 rq:1359885 version:1.5.5 Changes: -------- --- /work/SRC/openSUSE:Factory/helmfile/helmfile.changes 2026-06-16 18:30:15.051671844 +0200 +++ /work/SRC/openSUSE:Factory/.helmfile.new.1981/helmfile.changes 2026-06-17 16:23:08.903183749 +0200 @@ -1,0 +2,9 @@ +Wed Jun 17 05:37:57 UTC 2026 - Manfred Hollstein <[email protected]> + +- Update to version 1.5.5: + * fix: restore s3:: vhost-style remote source support (#2643) by + @yxxhero in #2644 + * feat: add helm 4 --server-side flag support for diff and bump + helm-diff to v3.15.10 by @yxxhero in #2645 + +------------------------------------------------------------------- Old: ---- helmfile-1.5.4.tar.gz New: ---- helmfile-1.5.5.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ helmfile.spec ++++++ --- /var/tmp/diff_new_pack.fdtsfp/_old 2026-06-17 16:23:11.311284478 +0200 +++ /var/tmp/diff_new_pack.fdtsfp/_new 2026-06-17 16:23:11.327285147 +0200 @@ -17,9 +17,9 @@ # -%define git_commit a94fdf4194ed6befc026acc6824767fecfd7cb5f +%define git_commit bbceb05b314ceef32b631c46b512de08b5482d7d Name: helmfile -Version: 1.5.4 +Version: 1.5.5 Release: 0 Summary: Deploy Kubernetes Helm Charts License: MIT ++++++ _service ++++++ --- /var/tmp/diff_new_pack.fdtsfp/_old 2026-06-17 16:23:11.479291506 +0200 +++ /var/tmp/diff_new_pack.fdtsfp/_new 2026-06-17 16:23:11.483291672 +0200 @@ -5,7 +5,7 @@ <param name="exclude">.git</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> - <param name="revision">v1.5.4</param> + <param name="revision">v1.5.5</param> <param name="changesgenerate">enable</param> <param name="changesauthor">[email protected]</param> </service> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.fdtsfp/_old 2026-06-17 16:23:11.619297362 +0200 +++ /var/tmp/diff_new_pack.fdtsfp/_new 2026-06-17 16:23:11.639298198 +0200 @@ -1,7 +1,7 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/helmfile/helmfile.git</param> - <param name="changesrevision">a94fdf4194ed6befc026acc6824767fecfd7cb5f</param> + <param name="changesrevision">bbceb05b314ceef32b631c46b512de08b5482d7d</param> </service> </servicedata> ++++++ helmfile-1.5.4.tar.gz -> helmfile-1.5.5.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/helmfile-1.5.4/Dockerfile new/helmfile-1.5.5/Dockerfile --- old/helmfile-1.5.4/Dockerfile 2026-06-16 02:24:47.000000000 +0200 +++ new/helmfile-1.5.5/Dockerfile 2026-06-17 03:07:44.000000000 +0200 @@ -95,7 +95,7 @@ [ "$(age-keygen --version)" = "${AGE_VERSION}" ] ARG HELM_SECRETS_VERSION="4.7.4" -RUN helm plugin install https://github.com/databus23/helm-diff --version v3.15.9 --verify=false && \ +RUN helm plugin install https://github.com/databus23/helm-diff --version v3.15.10 --verify=false && \ helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-${HELM_SECRETS_VERSION}.tgz --verify=false && \ helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-getter-${HELM_SECRETS_VERSION}.tgz --verify=false && \ helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-post-renderer-${HELM_SECRETS_VERSION}.tgz --verify=false && \ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/helmfile-1.5.4/Dockerfile.debian-stable-slim new/helmfile-1.5.5/Dockerfile.debian-stable-slim --- old/helmfile-1.5.4/Dockerfile.debian-stable-slim 2026-06-16 02:24:47.000000000 +0200 +++ new/helmfile-1.5.5/Dockerfile.debian-stable-slim 2026-06-17 03:07:44.000000000 +0200 @@ -104,7 +104,7 @@ [ "$(age-keygen --version)" = "${AGE_VERSION}" ] ARG HELM_SECRETS_VERSION="4.7.4" -RUN helm plugin install https://github.com/databus23/helm-diff --version v3.15.9 --verify=false && \ +RUN helm plugin install https://github.com/databus23/helm-diff --version v3.15.10 --verify=false && \ helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-${HELM_SECRETS_VERSION}.tgz --verify=false && \ helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-getter-${HELM_SECRETS_VERSION}.tgz --verify=false && \ helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-post-renderer-${HELM_SECRETS_VERSION}.tgz --verify=false && \ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/helmfile-1.5.4/Dockerfile.ubuntu new/helmfile-1.5.5/Dockerfile.ubuntu --- old/helmfile-1.5.4/Dockerfile.ubuntu 2026-06-16 02:24:47.000000000 +0200 +++ new/helmfile-1.5.5/Dockerfile.ubuntu 2026-06-17 03:07:44.000000000 +0200 @@ -104,7 +104,7 @@ [ "$(age-keygen --version)" = "${AGE_VERSION}" ] ARG HELM_SECRETS_VERSION="4.7.4" -RUN helm plugin install https://github.com/databus23/helm-diff --version v3.15.9 --verify=false && \ +RUN helm plugin install https://github.com/databus23/helm-diff --version v3.15.10 --verify=false && \ helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-${HELM_SECRETS_VERSION}.tgz --verify=false && \ helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-getter-${HELM_SECRETS_VERSION}.tgz --verify=false && \ helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-post-renderer-${HELM_SECRETS_VERSION}.tgz --verify=false && \ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/helmfile-1.5.4/pkg/app/init.go new/helmfile-1.5.5/pkg/app/init.go --- old/helmfile-1.5.4/pkg/app/init.go 2026-06-16 02:24:47.000000000 +0200 +++ new/helmfile-1.5.5/pkg/app/init.go 2026-06-17 03:07:44.000000000 +0200 @@ -19,7 +19,7 @@ const ( HelmRequiredVersion = "v3.18.6" // Minimum required version (supports Helm 3.x and 4.x) - HelmDiffRecommendedVersion = "v3.15.9" + HelmDiffRecommendedVersion = "v3.15.10" HelmRecommendedVersion = "v4.2.1" // Recommended Helm 4 version HelmSecretsRecommendedVersion = "v4.7.4" // v4.7.0+ works with both Helm 3 (single plugin) and Helm 4 (split plugin architecture) HelmGitRecommendedVersion = "v1.3.0" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/helmfile-1.5.4/pkg/remote/remote.go new/helmfile-1.5.5/pkg/remote/remote.go --- old/helmfile-1.5.4/pkg/remote/remote.go 2026-06-16 02:24:47.000000000 +0200 +++ new/helmfile-1.5.5/pkg/remote/remote.go 2026-06-17 03:07:44.000000000 +0200 @@ -6,7 +6,6 @@ "encoding/hex" "errors" "fmt" - "io" "maps" "net/http" neturl "net/url" @@ -330,8 +329,26 @@ switch { case u.Getter == "normal" && u.Scheme == "s3": - err := r.S3Getter.Get(r.Home, path, cacheDirPath) - if err != nil { + if err := r.S3Getter.Get(r.Home, path, cacheDirPath); err != nil { + rmerr := os.RemoveAll(cacheDirPath) + if rmerr != nil { + return "", errors.Join(err, rmerr) + } + return "", err + } + case u.Getter == "s3": + // go-getter forced-getter syntax (e.g. "s3::https://bucket.s3.region.amazonaws.com/key"). + // go-getter v2 no longer ships an S3 getter, so route these to the + // built-in AWS SDK v2 S3Getter which understands vhost/path-style URLs. + // helmfile's Parse splits a "@<file>" selector (if any) into u.File; + // strip it from the source so the S3 object key is derived from the + // URL path only, matching how the go-getter branch feeds u.Dir. + s3Src := stripSubdirSelector(path) + if err := r.S3Getter.Get(r.Home, s3Src, cacheDirPath); err != nil { + rmerr := os.RemoveAll(cacheDirPath) + if rmerr != nil { + return "", errors.Join(err, rmerr) + } return "", err } case u.Getter == "normal" && (u.Scheme == "https" || u.Scheme == "http"): @@ -393,21 +410,21 @@ } func (g *S3Getter) Get(wd, src, dst string) error { - u, err := url.Parse(src) + region, bucket, key, err := ParseS3Url(src) if err != nil { return err } - file := path.Base(u.Path) + file := path.Base(key) targetFilePath := filepath.Join(dst, file) - region, err := g.S3FileExists(src) + // If the region could not be derived from the URL, S3FileExists resolves it + // via GetBucketLocation. + resolvedRegion, err := g.S3FileExists(src, region) if err != nil { return err } - - bucket, key, err := ParseS3Url(src) - if err != nil { - return err + if resolvedRegion != "" { + region = resolvedRegion } err = os.MkdirAll(dst, os.FileMode(0700)) @@ -441,18 +458,34 @@ Key: &key, } resp, err := s3Client.GetObject(context.TODO(), getObjectInput) - defer func(Body io.ReadCloser) { - err := Body.Close() - if err != nil { + if err != nil { + return err + } + defer func() { + if err := resp.Body.Close(); err != nil { g.Logger.Errorf("Error closing connection to remote data source \n%v", err) } - }(resp.Body) + }() - if err != nil { - return err + // go-getter v2 no longer ships an S3 getter, but it still ships archive + // decompressors. To preserve go-getter v1's automatic archive extraction + // (used with the "@<file>" selector to reference a file inside a tarball), + // download archives to a temp file and decompress them into dst. + decompressor := decompressorForFile(file) + downloadPath := targetFilePath + if decompressor != nil { + tmp, terr := os.CreateTemp(dst, ".s3-archive-*") + if terr != nil { + return terr + } + if cerr := tmp.Close(); cerr != nil { + return cerr + } + downloadPath = tmp.Name() + defer func() { _ = os.Remove(downloadPath) }() } - localFile, err := os.Create(targetFilePath) + localFile, err := os.Create(downloadPath) if err != nil { return err } @@ -464,8 +497,35 @@ }(localFile) _, err = localFile.ReadFrom(resp.Body) + if err != nil { + return err + } - return err + if decompressor != nil { + if err := decompressor.Decompress(dst, downloadPath, true, os.FileMode(0)); err != nil { + return fmt.Errorf("decompress %s: %w", file, err) + } + } + + return nil +} + +// decompressorForFile returns the go-getter decompressor matching the file's +// archive extension (e.g. ".tar.gz", ".zip"), or nil if the file is not a +// recognized archive. The detection mirrors go-getter's own suffix matching. +func decompressorForFile(file string) getter.Decompressor { + matchLen := 0 + var match string + for k := range getter.Decompressors { + if strings.HasSuffix(file, "."+k) && len(k) > matchLen { + match = k + matchLen = len(k) + } + } + if match == "" { + return nil + } + return getter.Decompressors[match] } func (g *HttpGetter) Get(wd, src, dst string) error { @@ -516,44 +576,47 @@ return err } -func (g *S3Getter) S3FileExists(path string) (string, error) { +func (g *S3Getter) S3FileExists(path, regionHint string) (string, error) { g.Logger.Debugf("Parsing S3 URL %s", path) - bucket, key, err := ParseS3Url(path) + _, bucket, key, err := ParseS3Url(path) if err != nil { return "", err } - // Region - g.Logger.Debugf("Creating config for determining S3 region %s", path) - // Suppress AWS SDK debug logging by default to prevent sensitive information from being logged - // Can be configured via HELMFILE_AWS_SDK_LOG_LEVEL environment variable - // See issue #2270 - var configOpts []func(*config.LoadOptions) error - if awsSDKLogLevel == "off" { - // ClientLogMode(0) disables all AWS SDK logging (no LogRequest, LogResponse, etc.) - configOpts = append(configOpts, config.WithClientLogMode(0)) - } - cfg, err := config.LoadDefaultConfig(context.TODO(), configOpts...) - if err != nil { - return "", err - } + bucketRegion := regionHint + if bucketRegion == "" { + // Region + g.Logger.Debugf("Creating config for determining S3 region %s", path) + // Suppress AWS SDK debug logging by default to prevent sensitive information from being logged + // Can be configured via HELMFILE_AWS_SDK_LOG_LEVEL environment variable + // See issue #2270 + var configOpts []func(*config.LoadOptions) error + if awsSDKLogLevel == "off" { + // ClientLogMode(0) disables all AWS SDK logging (no LogRequest, LogResponse, etc.) + configOpts = append(configOpts, config.WithClientLogMode(0)) + } + cfg, err := config.LoadDefaultConfig(context.TODO(), configOpts...) + if err != nil { + return "", err + } - g.Logger.Debugf("Getting bucket %s location %s", bucket, path) - s3Client := s3.NewFromConfig(cfg) - bucketRegion := "us-east-1" - getBucketLocationInput := &s3.GetBucketLocationInput{ - Bucket: &bucket, - } - resp, err := s3Client.GetBucketLocation(context.TODO(), getBucketLocationInput) - if err != nil { - return "", fmt.Errorf("failed to retrieve bucket location: %v", err) - } - if resp == nil || string(resp.LocationConstraint) == "" { - g.Logger.Debugf("Bucket has no location Assuming us-east-1") - } else { - bucketRegion = string(resp.LocationConstraint) + g.Logger.Debugf("Getting bucket %s location %s", bucket, path) + s3Client := s3.NewFromConfig(cfg) + bucketRegion = "us-east-1" + getBucketLocationInput := &s3.GetBucketLocationInput{ + Bucket: &bucket, + } + resp, err := s3Client.GetBucketLocation(context.TODO(), getBucketLocationInput) + if err != nil { + return "", fmt.Errorf("failed to retrieve bucket location: %v", err) + } + if resp == nil || string(resp.LocationConstraint) == "" { + g.Logger.Debugf("Bucket has no location Assuming us-east-1") + } else { + bucketRegion = string(resp.LocationConstraint) + } + g.Logger.Debugf("Got bucket location %s", bucketRegion) } - g.Logger.Debugf("Got bucket location %s", bucketRegion) // File existence g.Logger.Debugf("Creating new config with region to see if file exists") @@ -573,7 +636,7 @@ return bucketRegion, err } g.Logger.Debugf("Creating new s3 client to check if object exists") - s3Client = s3.NewFromConfig(regionCfg) + s3Client := s3.NewFromConfig(regionCfg) headObjectInput := &s3.HeadObjectInput{ Bucket: &bucket, Key: &key, @@ -596,20 +659,116 @@ return err } -func ParseS3Url(s3URL string) (string, string, error) { - parsedURL, err := url.Parse(s3URL) - if err != nil { - return "", "", fmt.Errorf("failed to parse S3 URL: %w", err) - } - - if parsedURL.Scheme != "s3" { - return "", "", fmt.Errorf("invalid URL scheme (expected 's3')") +// stripSubdirSelector removes the helmfile "@<file>" selector from a remote +// source URL, preserving any query string. +// +// helmfile's Parse splits the URL path on "@" into the download source (before +// "@") and the file selector (after "@") — see the comment in Parse. The +// selector lives in the path component only, so a "@" inside the query string +// is left intact. When the path contains no "@" (single-file downloads) the URL +// is returned unchanged. +func stripSubdirSelector(src string) string { + pathPart := src + queryPart := "" + if q := strings.Index(src, "?"); q >= 0 { + pathPart = src[:q] + queryPart = src[q:] + } + // Mirror Parse: only treat a single "@" as the dir/file separator. + if parts := strings.Split(pathPart, "@"); len(parts) == 2 { + pathPart = parts[0] + } + return pathPart + queryPart +} + +// ParseS3Url parses an S3 URL and returns the region, bucket, and object key. +// +// Supported URL formats: +// - s3://<bucket>/<key> (region resolved dynamically via GetBucketLocation) +// - s3::https://s3.amazonaws.com/<bucket>/<key> +// - s3::https://s3-<region>.amazonaws.com/<bucket>/<key> +// - s3::https://<bucket>.s3.<region>.amazonaws.com/<key> +// - s3::https://<bucket>.s3-<region>.amazonaws.com/<key> +// - s3::http://... (same amazonaws.com forms over plain HTTP) +// +// The "s3::" forced-getter prefix (the go-getter syntax for selecting the S3 +// getter with an HTTPS URL) is optional and stripped before parsing. +func ParseS3Url(s3URL string) (region, bucket, key string, err error) { + raw := s3URL + // Strip any forced-getter prefix like "s3::" so that vhost-style URLs such + // as "s3::https://bucket.s3.region.amazonaws.com/key" parse correctly. + if idx := strings.Index(raw, "::"); idx >= 0 { + raw = raw[idx+2:] + } + + parsedURL, err := url.Parse(raw) + if err != nil { + return "", "", "", fmt.Errorf("failed to parse S3 URL: %w", err) + } + + switch parsedURL.Scheme { + case "s3": + // Path-style: s3://<bucket>/<key> + bucket = parsedURL.Host + key = strings.TrimPrefix(parsedURL.Path, "/") + // Region is unknown for the bare s3:// form; it is resolved later + // via GetBucketLocation. + return "", bucket, key, nil + case "http", "https": + // Continue to amazonaws.com vhost/path-style parsing below. + default: + return "", "", "", fmt.Errorf("invalid URL scheme (expected 's3', 'http', or 'https'): %s", s3URL) + } + + // Amazon S3 supports both virtual-hosted-style and path-style URLs. + // See https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-bucket-intro.html + if !strings.Contains(parsedURL.Host, "amazonaws.com") { + return "", "", "", fmt.Errorf("URL is not a valid S3 URL (host must be amazonaws.com): %s", s3URL) + } + + hostParts := strings.Split(parsedURL.Host, ".") + switch len(hostParts) { + case 3: + // Path-style: s3.amazonaws.com/<bucket>/<key> or s3-<region>.amazonaws.com/<bucket>/<key> + region = strings.TrimPrefix(strings.TrimPrefix(hostParts[0], "s3-"), "s3") + if region == "" { + region = "us-east-1" + } + pathParts := strings.SplitN(parsedURL.Path, "/", 3) + if len(pathParts) < 3 { + return "", "", "", fmt.Errorf("URL is not a valid S3 URL: %s", s3URL) + } + bucket = pathParts[1] + key = pathParts[2] + case 4: + // Virtual-hosted-style, dash region: <bucket>.s3-<region>.amazonaws.com/<key> + region = strings.TrimPrefix(strings.TrimPrefix(hostParts[1], "s3-"), "s3") + if region == "" { + return "", "", "", fmt.Errorf("URL is not a valid S3 URL: %s", s3URL) + } + pathParts := strings.SplitN(parsedURL.Path, "/", 2) + if len(pathParts) < 2 { + return "", "", "", fmt.Errorf("URL is not a valid S3 URL: %s", s3URL) + } + bucket = hostParts[0] + key = pathParts[1] + case 5: + // Virtual-hosted-style, dot region: <bucket>.s3.<region>.amazonaws.com/<key> + region = hostParts[2] + if region == "" { + return "", "", "", fmt.Errorf("URL is not a valid S3 URL: %s", s3URL) + } + pathParts := strings.SplitN(parsedURL.Path, "/", 2) + if len(pathParts) < 2 { + return "", "", "", fmt.Errorf("URL is not a valid S3 URL: %s", s3URL) + } + bucket = hostParts[0] + key = pathParts[1] + default: + return "", "", "", fmt.Errorf("URL is not a valid S3 URL: %s", s3URL) } - bucket := parsedURL.Host - key := strings.TrimPrefix(parsedURL.Path, "/") - - return bucket, key, nil + return region, bucket, key, nil } func NewRemote(logger *zap.SugaredLogger, homeDir string, fs *filesystem.FileSystem) *Remote { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/helmfile-1.5.4/pkg/remote/remote_test.go new/helmfile-1.5.5/pkg/remote/remote_test.go --- old/helmfile-1.5.4/pkg/remote/remote_test.go 2026-06-16 02:24:47.000000000 +0200 +++ new/helmfile-1.5.5/pkg/remote/remote_test.go 2026-06-17 03:07:44.000000000 +0200 @@ -1,14 +1,19 @@ package remote import ( + "archive/tar" + "bytes" + "compress/gzip" "fmt" "io" + "os" "path/filepath" "strings" "testing" "github.com/google/go-cmp/cmp" + "github.com/helmfile/helmfile/pkg/filesystem" "github.com/helmfile/helmfile/pkg/helmexec" "github.com/helmfile/helmfile/pkg/testhelper" ) @@ -357,6 +362,354 @@ } }) } +} + +func TestRemote_S3VhostUrl(t *testing.T) { + cleanfs := map[string]string{ + CacheDir(): "", + } + cachefs := map[string]string{ + filepath.Join(CacheDir(), "https_test-helmfile_s3_eu-north-1_amazonaws_com_test", "test.tar.gz"): "foo: bar", + } + + testcases := []struct { + name string + files map[string]string + expectCacheHit bool + }{ + {name: "not expectCacheHit", files: cleanfs, expectCacheHit: false}, + {name: "expectCacheHit", files: cachefs, expectCacheHit: true}, + } + + for _, tt := range testcases { + t.Run(tt.name, func(t *testing.T) { + testfs := testhelper.NewTestFs(tt.files) + + hit := true + + get := func(wd, src, dst string) error { + if wd != CacheDir() { + return fmt.Errorf("unexpected wd: %s", wd) + } + expectedSrc := "s3::https://test-helmfile.s3.eu-north-1.amazonaws.com/test/test.tar.gz" + if src != expectedSrc { + return fmt.Errorf("unexpected src: %s", src) + } + expectedDst := filepath.Join(CacheDir(), "https_test-helmfile_s3_eu-north-1_amazonaws_com_test") + if dst != expectedDst { + return fmt.Errorf("unexpected dst: %s", dst) + } + + hit = false + + return nil + } + + getter := &testGetter{ + get: get, + } + remote := &Remote{ + Logger: helmexec.NewLogger(io.Discard, "debug"), + Home: CacheDir(), + Getter: getter, + S3Getter: getter, + HttpGetter: getter, + fs: testfs.ToFileSystem(), + } + + // go-getter forced-getter vhost-style S3 URL (see issue #2643) + url := "s3::https://test-helmfile.s3.eu-north-1.amazonaws.com/test/test.tar.gz" + file, err := remote.Locate(url) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectedFile := filepath.Join(CacheDir(), "https_test-helmfile_s3_eu-north-1_amazonaws_com_test", "test.tar.gz") + if file != expectedFile { + t.Errorf("unexpected file located: %s vs expected: %s", file, expectedFile) + } + + if tt.expectCacheHit && !hit { + t.Errorf("unexpected result: unexpected cache miss") + } + if !tt.expectCacheHit && hit { + t.Errorf("unexpected result: unexpected cache hit") + } + }) + } +} + +// TestRemote_S3VhostUrlWithSelector verifies that the helmfile "@<file>" +// selector is stripped before the URL is handed to the S3 getter, so the object +// key is derived from the URL path only. (Archive decompression for the +// selector is handled separately and is out of scope for this routing fix.) +func TestRemote_S3VhostUrlWithSelector(t *testing.T) { + testfs := testhelper.NewTestFs(map[string]string{CacheDir(): ""}) + + var gotSrc string + get := func(wd, src, dst string) error { + gotSrc = src + return nil + } + + getter := &testGetter{get: get} + remote := &Remote{ + Logger: helmexec.NewLogger(io.Discard, "debug"), + Home: CacheDir(), + Getter: getter, + S3Getter: getter, + HttpGetter: getter, + fs: testfs.ToFileSystem(), + } + + url := "s3::https://test-helmfile.s3.eu-north-1.amazonaws.com/test/[email protected]" + if _, err := remote.Locate(url); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + wantSrc := "s3::https://test-helmfile.s3.eu-north-1.amazonaws.com/test/test.tar.gz" + if gotSrc != wantSrc { + t.Errorf("selector not stripped: got src=%q, want %q", gotSrc, wantSrc) + } +} + +// TestRemote_S3VhostUrlErrorCleansCache verifies that a failed S3 download does +// not leave a partial cache directory behind that would be mistaken for a cache +// hit on the next run. It uses the real (on-disk) filesystem so that the +// os.RemoveAll cleanup and the DirectoryExistsAt cache check share the same +// store. The failing getter first creates dst, mirroring how the real +// S3Getter.Get runs os.MkdirAll(dst) before it can fail. +func TestRemote_S3VhostUrlErrorCleansCache(t *testing.T) { + home := t.TempDir() + + calls := 0 + get := func(wd, src, dst string) error { + calls++ + // Mimic S3Getter.Get which MkdirAll's dst before failing. + _ = os.MkdirAll(dst, 0o700) + return fmt.Errorf("simulated S3 failure") + } + + getter := &testGetter{get: get} + remote := &Remote{ + Logger: helmexec.NewLogger(io.Discard, "debug"), + Home: home, + Getter: getter, + S3Getter: getter, + HttpGetter: getter, + fs: filesystem.DefaultFileSystem(), + } + + url := "s3::https://test-helmfile.s3.eu-north-1.amazonaws.com/test/test.tar.gz" + if _, err := remote.Locate(url); err == nil { + t.Fatal("expected error on first Locate") + } + if _, err := remote.Locate(url); err == nil { + t.Fatal("expected error on second Locate") + } + if calls != 2 { + t.Errorf("expected getter called twice (partial cache must not be reused), got %d", calls) + } +} + +func TestDecompressorForFile(t *testing.T) { + testcases := []struct { + name string + file string + wantNil bool + }{ + {name: "tar.gz", file: "test.tar.gz", wantNil: false}, + {name: "tgz", file: "test.tgz", wantNil: false}, + {name: "zip", file: "test.zip", wantNil: false}, + {name: "tar", file: "test.tar", wantNil: false}, + {name: "gz", file: "values.yaml.gz", wantNil: false}, + {name: "plain yaml", file: "values.yaml", wantNil: true}, + {name: "plain txt", file: "test.gotmpl", wantNil: true}, + } + + for _, tt := range testcases { + t.Run(tt.name, func(t *testing.T) { + got := decompressorForFile(tt.file) + if (got == nil) != tt.wantNil { + t.Errorf("decompressorForFile(%q) = %v, want nil=%v", tt.file, got, tt.wantNil) + } + }) + } +} + +// TestS3GetterArchiveExtraction simulates the post-download step that +// S3Getter.Get performs for archive objects: download (here: pre-create the +// archive) then decompress into the cache dir so a "@<file>" selector resolves. +func TestS3GetterArchiveExtraction(t *testing.T) { + // Build an in-memory tar.gz containing "test.gotmpl". + var archive bytes.Buffer + gw := gzip.NewWriter(&archive) + tw := tar.NewWriter(gw) + contents := []byte("releases:\n - name: test\n") + hdr := &tar.Header{Name: "test.gotmpl", Mode: 0644, Size: int64(len(contents))} + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(contents); err != nil { + t.Fatal(err) + } + tw.Close() + gw.Close() + + dst := t.TempDir() + archivePath := filepath.Join(dst, ".s3-archive-test") + if err := os.WriteFile(archivePath, archive.Bytes(), 0o600); err != nil { + t.Fatal(err) + } + defer os.Remove(archivePath) + + dec := decompressorForFile("test.tar.gz") + if dec == nil { + t.Fatal("expected a decompressor for test.tar.gz") + } + + // Mirror S3Getter.Get: decompress the downloaded archive into dst (dir mode). + if err := dec.Decompress(dst, archivePath, true, os.FileMode(0)); err != nil { + t.Fatalf("decompress: %v", err) + } + + got, err := os.ReadFile(filepath.Join(dst, "test.gotmpl")) + if err != nil { + t.Fatalf("expected extracted file test.gotmpl: %v", err) + } + if string(got) != string(contents) { + t.Errorf("extracted content mismatch: got %q", got) + } +} + +func TestStripSubdirSelector(t *testing.T) { + testcases := []struct { + name string + input string + want string + }{ + {name: "no selector", input: "s3::https://h.s3.us-east-1.amazonaws.com/k/file.yaml", want: "s3::https://h.s3.us-east-1.amazonaws.com/k/file.yaml"}, + {name: "selector stripped", input: "s3::https://h.s3.us-east-1.amazonaws.com/test/[email protected]", want: "s3::https://h.s3.us-east-1.amazonaws.com/test/test.tar.gz"}, + {name: "selector stripped with query", input: "s3::https://h.s3.us-east-1.amazonaws.com/k/file.yaml@sel?x=1", want: "s3::https://h.s3.us-east-1.amazonaws.com/k/file.yaml?x=1"}, + {name: "at in query preserved", input: "s3::https://h.s3.us-east-1.amazonaws.com/k/file?t=a@b", want: "s3::https://h.s3.us-east-1.amazonaws.com/k/file?t=a@b"}, + {name: "multiple at is not a selector", input: "s3::https://h/k/a@b@c", want: "s3::https://h/k/a@b@c"}, + } + + for _, tt := range testcases { + t.Run(tt.name, func(t *testing.T) { + got := stripSubdirSelector(tt.input) + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("stripSubdirSelector mismatch:\n%s", diff) + } + }) + } +} + +func TestParseS3Url(t *testing.T) { + testcases := []struct { + name string + input string + region string + bucket string + key string + err string + }{ + { + name: "s3 path-style", + input: "s3://helm-s3-values-example/subdir/values.yaml", + region: "", + bucket: "helm-s3-values-example", + key: "subdir/values.yaml", + }, + { + name: "s3 path-style no subdir", + input: "s3://helm-s3-values-example/values.yaml", + region: "", + bucket: "helm-s3-values-example", + key: "values.yaml", + }, + { + name: "vhost dot region", + input: "s3::https://test-helmfile.s3.eu-north-1.amazonaws.com/test/test.tar.gz", + region: "eu-north-1", + bucket: "test-helmfile", + key: "test/test.tar.gz", + }, + { + name: "vhost dot region us-east-1", + input: "s3::https://mybucket.s3.us-east-1.amazonaws.com/dir/file.txt", + region: "us-east-1", + bucket: "mybucket", + key: "dir/file.txt", + }, + { + name: "vhost dash region", + input: "s3::https://mybucket.s3-us-west-2.amazonaws.com/dir/file.txt", + region: "us-west-2", + bucket: "mybucket", + key: "dir/file.txt", + }, + { + name: "path-style s3.amazonaws.com", + input: "s3::https://s3.amazonaws.com/mybucket/dir/file.txt", + region: "us-east-1", + bucket: "mybucket", + key: "dir/file.txt", + }, + { + name: "path-style s3-region", + input: "s3::https://s3-eu-west-1.amazonaws.com/mybucket/dir/file.txt", + region: "eu-west-1", + bucket: "mybucket", + key: "dir/file.txt", + }, + { + name: "plain http vhost dot region", + input: "https://mybucket.s3.us-east-1.amazonaws.com/dir/file.txt", + region: "us-east-1", + bucket: "mybucket", + key: "dir/file.txt", + }, + { + name: "invalid scheme", + input: "ftp://example.com/file.txt", + err: "invalid URL scheme (expected 's3', 'http', or 'https'): ftp://example.com/file.txt", + }, + { + name: "non-amazonaws host", + input: "https://example.com/bucket/file.txt", + err: "URL is not a valid S3 URL (host must be amazonaws.com): https://example.com/bucket/file.txt", + }, + } + + for _, tt := range testcases { + t.Run(tt.name, func(t *testing.T) { + region, bucket, key, err := ParseS3Url(tt.input) + + var errMsg string + if err != nil { + errMsg = err.Error() + } + + if diff := cmp.Diff(tt.err, errMsg); diff != "" { + t.Fatalf("Unexpected error:\n%s", diff) + } + + if tt.err != "" { + return + } + + if diff := cmp.Diff(tt.region, region); diff != "" { + t.Errorf("Unexpected region:\n%s", diff) + } + if diff := cmp.Diff(tt.bucket, bucket); diff != "" { + t.Errorf("Unexpected bucket:\n%s", diff) + } + if diff := cmp.Diff(tt.key, key); diff != "" { + t.Errorf("Unexpected key:\n%s", diff) + } + }) + } } func TestIsRemote(t *testing.T) { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/helmfile-1.5.4/pkg/state/helmx.go new/helmfile-1.5.5/pkg/state/helmx.go --- old/helmfile-1.5.4/pkg/state/helmx.go 2026-06-16 02:24:47.000000000 +0200 +++ new/helmfile-1.5.5/pkg/state/helmx.go 2026-06-17 03:07:44.000000000 +0200 @@ -357,6 +357,21 @@ // validServerSideValues are the allowed values for the helm 4 --server-side flag. var validServerSideValues = map[string]struct{}{"true": {}, "false": {}, "auto": {}} +// resolveServerSideValue resolves the server-side value following precedence: +// release-level > CLI flag > helmDefaults. Returns empty string if no value is configured. +func (st *HelmState) resolveServerSideValue(release *ReleaseSpec, serverSide string) string { + switch { + case release.ServerSide != nil && *release.ServerSide != "": + return *release.ServerSide + case serverSide != "": + return serverSide + case st.HelmDefaults.ServerSide != nil && *st.HelmDefaults.ServerSide != "": + return *st.HelmDefaults.ServerSide + default: + return "" + } +} + // appendServerSideFlagsForUpgrade appends the helm 4 --server-side flag when appropriate. // Precedence: release-level > CLI flag > helmDefaults. func (st *HelmState) appendServerSideFlagsForUpgrade(flags []string, helm helmexec.Interface, release *ReleaseSpec, serverSide string) ([]string, error) { @@ -372,15 +387,8 @@ return flags, nil } - var value string - switch { - case release.ServerSide != nil && *release.ServerSide != "": - value = *release.ServerSide - case serverSide != "": - value = serverSide - case st.HelmDefaults.ServerSide != nil && *st.HelmDefaults.ServerSide != "": - value = *st.HelmDefaults.ServerSide - default: + value := st.resolveServerSideValue(release, serverSide) + if value == "" { return flags, nil } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/helmfile-1.5.4/pkg/state/state.go new/helmfile-1.5.5/pkg/state/state.go --- old/helmfile-1.5.4/pkg/state/state.go 2026-06-16 02:24:47.000000000 +0200 +++ new/helmfile-1.5.5/pkg/state/state.go 2026-06-17 03:07:44.000000000 +0200 @@ -4019,7 +4019,7 @@ } // append server-side flag - flags, err = st.appendServerSideFlagsForUpgrade(flags, helm, release, serverSide) + flags, err = st.appendServerSideFlagsForDiff(flags, helm, release, serverSide, pluginsDir) if err != nil { return nil, nil, err } @@ -4058,6 +4058,27 @@ return flags, nil } +// appendServerSideFlagsForDiff appends the helm 4 --server-side flag for helm-diff. +// It requires helm-diff plugin version v3.15.10 or later. +func (st *HelmState) appendServerSideFlagsForDiff(flags []string, helm helmexec.Interface, release *ReleaseSpec, serverSide string, pluginsDir string) ([]string, error) { + if helm.IsHelm4() { + value := st.resolveServerSideValue(release, serverSide) + if value != "" { + diffVersion, err := helmexec.GetPluginVersion("diff", pluginsDir) + if err != nil { + return flags, err + } + minVersion, _ := semver.NewVersion("v3.15.10") + + if diffVersion.LessThan(minVersion) { + return flags, fmt.Errorf("server-side is not supported by helm-diff plugin version %s, please use at least v3.15.10", diffVersion) + } + } + } + + return st.appendServerSideFlagsForUpgrade(flags, helm, release, serverSide) +} + func (st *HelmState) appendChartVersionFlags(flags []string, release *ReleaseSpec) []string { version := release.Version // Strip OCI digest from version (digest is handled in chart URL, not --version flag) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/helmfile-1.5.4/test/integration/run.sh new/helmfile-1.5.5/test/integration/run.sh --- old/helmfile-1.5.4/test/integration/run.sh 2026-06-16 02:24:47.000000000 +0200 +++ new/helmfile-1.5.5/test/integration/run.sh 2026-06-17 03:07:44.000000000 +0200 @@ -27,7 +27,7 @@ export HELM_HOME="${HELM_DATA_HOME}" export HELM_PLUGINS="${HELM_DATA_HOME}/plugins" export HELM_CONFIG_HOME="${helm_dir}/config" -HELM_DIFF_VERSION="${HELM_DIFF_VERSION:-3.15.9}" +HELM_DIFF_VERSION="${HELM_DIFF_VERSION:-3.15.10}" HELM_GIT_VERSION="${HELM_GIT_VERSION:-1.4.1}" HELM_SECRETS_VERSION="${HELM_SECRETS_VERSION:-4.7.4}" export GNUPGHOME="${PWD}/${dir}/.gnupg" ++++++ vendor.tar.gz ++++++ /work/SRC/openSUSE:Factory/helmfile/vendor.tar.gz /work/SRC/openSUSE:Factory/.helmfile.new.1981/vendor.tar.gz differ: char 121, line 4
