From 3ae2f48d029646df7aa6bef0169b78286dd2ff45 Mon Sep 17 00:00:00 2001 From: Benoit Tigeot Date: Thu, 28 Aug 2025 01:01:40 +0200 Subject: [PATCH] pull: log resolved version and digest for repo/non-OCI pulls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When users run `helm pull` without a version, OCI pulls already print “Pulled:” and “Digest:”. HTTP/repo pulls do not. Some providers (e.g. Bitnami) resolve --repo pulls to `oci://` but skip the registry client summary, so nothing is printed. - Now we print a summary for `--repo` and direct HTTP pulls after download. - Keep direct oci:// behavior unchanged (avoid duplicate output). - Mirror OCI format: ``` Pulled: : Digest: sha256: ``` For HTTP/repo, the digest is the archive SHA-256 (.tgz). For OCI, the digest is the manifest digest (unchanged). I tested with command like: ```sh helm pull ingress-nginx --repo=https://kubernetes.github.io/ingress-nginx --version 4.10.1 helm pull oci://registry-1.docker.io/bitnamicharts/nginx --version '20.0.7' helm/bin/helm pull redis --repo=https://charts.bitnami.com/bitnami --version='22.0.4' ``` Signed-off-by: Benoit Tigeot --- pkg/action/pull.go | 32 ++++++++++ pkg/action/pull_test.go | 129 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 pkg/action/pull_test.go diff --git a/pkg/action/pull.go b/pkg/action/pull.go index c1f77e44c..5eaa0bf1a 100644 --- a/pkg/action/pull.go +++ b/pkg/action/pull.go @@ -17,6 +17,7 @@ limitations under the License. package action import ( + "crypto/sha256" "fmt" "os" "path/filepath" @@ -138,6 +139,37 @@ func (p *Pull) Run(chartRef string) (string, error) { return out.String(), err } + // Print a pull summary for repo mode and non-OCI downloads. + // Some repos (e.g., Bitnami) resolve --repo pulls to oci:// refs, but that + // path does not go through the registry client's summary printer, so we emit + // a consistent summary here. We mirror the OCI output format: + // + // Pulled: : + // Digest: sha256: + // + // For HTTP/repo pulls, the digest is the SHA-256 of the downloaded .tgz archive. + // For direct OCI pulls, the registry client already prints "Pulled:" and + // "Digest:" (manifest digest), so we do not print here to avoid duplicates. + if p.RepoURL != "" || !registry.IsOCI(downloadSourceRef) { + base := strings.TrimSuffix(filepath.Base(saved), ".tgz") + chart := base + ver := "" + if i := strings.LastIndex(base, "-"); i > 0 { + chart = base[:i] + ver = base[i+1:] + } + if ver != "" { + fmt.Fprintf(&out, "Pulled: %s:%s\n", chart, ver) + } else { + fmt.Fprintf(&out, "Pulled: %s\n", chart) + } + + if f, err := os.ReadFile(saved); err == nil { + sum := sha256.Sum256(f) + fmt.Fprintf(&out, "Digest: sha256:%x\n", sum) + } + } + if p.Verify { for name := range v.SignedBy.Identities { fmt.Fprintf(&out, "Signed by: %v\n", name) diff --git a/pkg/action/pull_test.go b/pkg/action/pull_test.go new file mode 100644 index 000000000..69af3c2fc --- /dev/null +++ b/pkg/action/pull_test.go @@ -0,0 +1,129 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package action + +import ( + "crypto/sha256" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "helm.sh/helm/v4/pkg/cli" +) + +// helm pull testchart --repo=http://127.0.0.1: --version 1.2.3 +func TestPull_PrintsSummary_ForHTTPRepo(t *testing.T) { + t.Parallel() + + // Minimal chart payload; verification is off so a plain byte buffer is fine. + chartBytes := []byte("dummy-chart-content") + sum := sha256.Sum256(chartBytes) + wantDigest := fmt.Sprintf("sha256:%x", sum) + + // Serve a valid index.yaml and the chart archive. + mux := http.NewServeMux() + mux.HandleFunc("/index.yaml", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/yaml") + // Valid index entry requires both name and version. + fmt.Fprintf(w, `apiVersion: v1 +entries: + testchart: + - name: testchart + version: 1.2.3 + urls: + - testchart-1.2.3.tgz + created: "2020-01-01T00:00:00Z" +`) + }) + mux.HandleFunc("/testchart-1.2.3.tgz", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/gzip") + _, _ = w.Write(chartBytes) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + // Isolate Helm env/cache to temp dirs for deterministic tests. + settings := cli.New() + settings.RepositoryCache = t.TempDir() + settings.RepositoryConfig = filepath.Join(t.TempDir(), "repositories.yaml") + settings.ContentCache = t.TempDir() + + cfg := &Configuration{} // minimal config; no K8s or registry for HTTP path + + p := NewPull(WithConfig(cfg)) + p.Settings = settings + p.DestDir = t.TempDir() + p.RepoURL = srv.URL + p.Version = "1.2.3" + + out, err := p.Run("testchart") + require.NoError(t, err, "Pull.Run() should succeed. Output:\n%s", out) + + assert.Contains(t, out, "Pulled: testchart:1.2.3", "expected Pulled summary in output") + assert.Contains(t, out, "Digest: "+wantDigest, "expected archive digest in output") + + // Ensure the chart file was saved. + _, statErr := os.Stat(filepath.Join(p.DestDir, "testchart-1.2.3.tgz")) + require.NoError(t, statErr, "expected chart archive to be saved") +} + +// helm pull http://127.0.0.1:/directchart-9.9.9.tgz +func TestPull_PrintsSummary_ForDirectHTTPURL(t *testing.T) { + t.Parallel() + + chartBytes := []byte("another-dummy-chart") + sum := sha256.Sum256(chartBytes) + wantDigest := fmt.Sprintf("sha256:%x", sum) + + mux := http.NewServeMux() + mux.HandleFunc("/directchart-9.9.9.tgz", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/gzip") + _, _ = w.Write(chartBytes) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + settings := cli.New() + settings.RepositoryCache = t.TempDir() + settings.RepositoryConfig = filepath.Join(t.TempDir(), "repositories.yaml") + settings.ContentCache = t.TempDir() + + cfg := &Configuration{} + + p := NewPull(WithConfig(cfg)) + p.Settings = settings + p.DestDir = t.TempDir() + + // Direct HTTP URL (absolute URL). Version is ignored for absolute URLs. + chartURL := srv.URL + "/directchart-9.9.9.tgz" + + out, err := p.Run(chartURL) + require.NoError(t, err, "Pull.Run() should succeed. Output:\n%s", out) + + // Output should reflect name-version.tgz from the URL. + assert.Contains(t, out, "Pulled: directchart:9.9.9", "expected Pulled summary in output") + assert.Contains(t, out, "Digest: "+wantDigest, "expected archive digest in output") + + // Ensure the chart file was saved. + _, statErr := os.Stat(filepath.Join(p.DestDir, "directchart-9.9.9.tgz")) + require.NoError(t, statErr, "expected chart archive to be saved") +}