From 544e3ba3d431fed68b100e37bf3fc149109d3357 Mon Sep 17 00:00:00 2001 From: Arnav Nagzirkar <113314200+arnavnagzirkar@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:24:06 -0700 Subject: [PATCH 1/2] fix: Display oci annotations or created date if annotation exists in helm sho Fixes helm/helm#30787 Signed-off-by: Arnav Nagzirkar <113314200+arnavnagzirkar@users.noreply.github.com> --- pkg/action/show.go | 15 ++++++++- pkg/action/show_test.go | 74 +++++++++++++++++++++++++++++++++++++++++ pkg/cmd/show.go | 11 ++++++ pkg/registry/client.go | 30 +++++++++++++++++ 4 files changed, 129 insertions(+), 1 deletion(-) diff --git a/pkg/action/show.go b/pkg/action/show.go index 4195d69a5..c1a6d1992 100644 --- a/pkg/action/show.go +++ b/pkg/action/show.go @@ -61,7 +61,10 @@ type Show struct { Devel bool OutputFormat ShowOutputFormat JSONPathTemplate string - chart *chart.Chart // for testing + // OCIRef is the original OCI reference (without oci:// scheme) used to fetch + // manifest annotations when showing an OCI chart. Leave empty for non-OCI charts. + OCIRef string + chart *chart.Chart // for testing } // NewShow creates a new Show object with the given configuration. @@ -96,6 +99,16 @@ func (s *Show) Run(chartpath string) (string, error) { var out strings.Builder if s.OutputFormat == ShowChart || s.OutputFormat == ShowAll { fmt.Fprintf(&out, "%s\n", cf) + if s.OCIRef != "" && s.registryClient != nil { + annotations, err := s.registryClient.GetManifestAnnotations(s.OCIRef) + if err == nil && len(annotations) > 0 { + annotationsYAML, marshalErr := yaml.Marshal(annotations) + if marshalErr == nil { + fmt.Fprintln(&out, "---") + fmt.Fprintf(&out, "%s\n", annotationsYAML) + } + } + } } if (s.OutputFormat == ShowValues || s.OutputFormat == ShowAll) && s.chart.Values != nil { diff --git a/pkg/action/show_test.go b/pkg/action/show_test.go index 854dee07a..9c9ef67a4 100644 --- a/pkg/action/show_test.go +++ b/pkg/action/show_test.go @@ -17,10 +17,15 @@ limitations under the License. package action import ( + "io" + "net/http" + "net/http/httptest" + "strings" "testing" "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "helm.sh/helm/v4/pkg/chart/common" chart "helm.sh/helm/v4/pkg/chart/v2" @@ -180,3 +185,72 @@ func TestShowSetRegistryClient(t *testing.T) { client.SetRegistryClient(registryClient) assert.Equal(t, registryClient, client.registryClient) } + +func TestShowOCIAnnotations(t *testing.T) { + const manifestJSON = `{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.cncf.helm.config.v1+json", + "digest": "sha256:abc123", + "size": 100 + }, + "layers": [], + "annotations": { + "org.opencontainers.image.created": "2025-04-11T20:12:25Z" + } + }` + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v2/" { + w.WriteHeader(http.StatusOK) + return + } + if strings.Contains(r.URL.Path, "/manifests/") { + w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(manifestJSON)) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + host := strings.TrimPrefix(srv.URL, "http://") + + registryClient, err := registry.NewClient( + registry.ClientOptWriter(io.Discard), + registry.ClientOptPlainHTTP(), + ) + require.NoError(t, err) + + config := actionConfigFixture(t) + client := NewShow(ShowChart, config) + client.SetRegistryClient(registryClient) + client.OCIRef = host + "/test/chart:1.0.0" + client.chart = &chart.Chart{ + Metadata: &chart.Metadata{Name: "test-chart", Version: "1.0.0"}, + } + + output, err := client.Run("") + require.NoError(t, err) + + assert.Contains(t, output, "---") + assert.Contains(t, output, "org.opencontainers.image.created") + assert.Contains(t, output, "2025-04-11T20:12:25Z") +} + +func TestShowOCIAnnotationsNoRegistry(t *testing.T) { + config := actionConfigFixture(t) + client := NewShow(ShowChart, config) + // OCIRef set but no registry client - should not fail, just omit annotations + client.OCIRef = "host/test/chart:1.0.0" + client.chart = &chart.Chart{ + Metadata: &chart.Metadata{Name: "test-chart", Version: "1.0.0"}, + } + + output, err := client.Run("") + require.NoError(t, err) + // No annotations section, no "---" separator + assert.NotContains(t, output, "---") +} diff --git a/pkg/cmd/show.go b/pkg/cmd/show.go index d7249c3fe..73aba9f04 100644 --- a/pkg/cmd/show.go +++ b/pkg/cmd/show.go @@ -21,11 +21,14 @@ import ( "io" "log" "log/slog" + "path" + "strings" "github.com/spf13/cobra" "helm.sh/helm/v4/pkg/action" "helm.sh/helm/v4/pkg/cmd/require" + "helm.sh/helm/v4/pkg/registry" ) const showDesc = ` @@ -218,6 +221,14 @@ func runShow(args []string, client *action.Show) (string, error) { client.Version = ">0.0.0-0" } + if registry.IsOCI(args[0]) { + ref := strings.TrimPrefix(args[0], registry.OCIScheme+"://") + if client.Version != "" && !strings.Contains(path.Base(ref), ":") { + ref = fmt.Sprintf("%s:%s", ref, client.Version) + } + client.OCIRef = ref + } + cp, err := client.LocateChart(args[0], settings) if err != nil { return "", err diff --git a/pkg/registry/client.go b/pkg/registry/client.go index f2bfd13b4..a28cbd167 100644 --- a/pkg/registry/client.go +++ b/pkg/registry/client.go @@ -909,6 +909,36 @@ func (c *Client) ValidateReference(ref, version string, u *url.URL) (string, *ur return "", u, err } +// GetManifestAnnotations fetches the OCI manifest annotations for the given reference. +// It returns nil, nil when no annotations are present. The caller should treat errors as +// non-fatal and proceed without annotations. +func (c *Client) GetManifestAnnotations(ref string) (map[string]string, error) { + parsedRef, err := newReference(ref) + if err != nil { + return nil, err + } + + remoteRepository, err := remote.NewRepository(parsedRef.String()) + if err != nil { + return nil, err + } + remoteRepository.PlainHTTP = c.plainHTTP + remoteRepository.Client = c.authorizer + + ctx := context.Background() + _, rc, err := remoteRepository.FetchReference(ctx, parsedRef.String()) + if err != nil { + return nil, err + } + defer rc.Close() + + var manifest ocispec.Manifest + if err := json.NewDecoder(rc).Decode(&manifest); err != nil { + return nil, err + } + return manifest.Annotations, nil +} + // tagManifest prepares and tags a manifest in memory storage func (c *Client) tagManifest(ctx context.Context, memoryStore *memory.Store, configDescriptor ocispec.Descriptor, layers []ocispec.Descriptor, From 3cbd4a7903c30c98aa97bead0603510305da6d52 Mon Sep 17 00:00:00 2001 From: Arnav Nagzirkar <113314200+arnavnagzirkar@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:48:38 -0700 Subject: [PATCH 2/2] fix(show): resolve OCI tag from chart version for annotations Build the manifest reference from the concrete version of the downloaded chart instead of recombining with client.Version, which can be a range such as ">0.0.0-0" when --devel is used and is not a valid tag. Show.Run now appends the resolved chart version when the reference has no explicit tag or digest, so annotations also work without an explicit --version. Adds a regression test. Signed-off-by: Arnav Nagzirkar <113314200+arnavnagzirkar@users.noreply.github.com> --- pkg/action/show.go | 12 ++++++++- pkg/action/show_test.go | 57 +++++++++++++++++++++++++++++++++++++++++ pkg/cmd/show.go | 11 ++++---- 3 files changed, 73 insertions(+), 7 deletions(-) diff --git a/pkg/action/show.go b/pkg/action/show.go index c1a6d1992..a24113bda 100644 --- a/pkg/action/show.go +++ b/pkg/action/show.go @@ -19,6 +19,7 @@ package action import ( "bytes" "fmt" + "path" "strings" "k8s.io/cli-runtime/pkg/printers" @@ -100,7 +101,16 @@ func (s *Show) Run(chartpath string) (string, error) { if s.OutputFormat == ShowChart || s.OutputFormat == ShowAll { fmt.Fprintf(&out, "%s\n", cf) if s.OCIRef != "" && s.registryClient != nil { - annotations, err := s.registryClient.GetManifestAnnotations(s.OCIRef) + ref := s.OCIRef + // When the reference has no explicit tag or digest, fall back to the + // resolved chart version. Helm stores an OCI chart under a tag that + // matches its version, and s.chart.Metadata.Version is the concrete + // version actually downloaded (the requested version may be a range). + if s.chart.Metadata != nil && s.chart.Metadata.Version != "" && + !strings.Contains(path.Base(ref), ":") && !strings.Contains(ref, "@") { + ref = fmt.Sprintf("%s:%s", ref, s.chart.Metadata.Version) + } + annotations, err := s.registryClient.GetManifestAnnotations(ref) if err == nil && len(annotations) > 0 { annotationsYAML, marshalErr := yaml.Marshal(annotations) if marshalErr == nil { diff --git a/pkg/action/show_test.go b/pkg/action/show_test.go index 9c9ef67a4..520cb0d96 100644 --- a/pkg/action/show_test.go +++ b/pkg/action/show_test.go @@ -254,3 +254,60 @@ func TestShowOCIAnnotationsNoRegistry(t *testing.T) { // No annotations section, no "---" separator assert.NotContains(t, output, "---") } + +func TestShowOCIAnnotationsResolvesVersion(t *testing.T) { + const manifestJSON = `{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.cncf.helm.config.v1+json", + "digest": "sha256:abc123", + "size": 100 + }, + "layers": [], + "annotations": { + "org.opencontainers.image.created": "2025-04-11T20:12:25Z" + } + }` + + var requestedManifest string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v2/" { + w.WriteHeader(http.StatusOK) + return + } + if strings.Contains(r.URL.Path, "/manifests/") { + requestedManifest = r.URL.Path + w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(manifestJSON)) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + host := strings.TrimPrefix(srv.URL, "http://") + + registryClient, err := registry.NewClient( + registry.ClientOptWriter(io.Discard), + registry.ClientOptPlainHTTP(), + ) + require.NoError(t, err) + + config := actionConfigFixture(t) + client := NewShow(ShowChart, config) + client.SetRegistryClient(registryClient) + // OCIRef has no explicit tag; the concrete tag is taken from the chart version. + client.OCIRef = host + "/test/chart" + client.chart = &chart.Chart{ + Metadata: &chart.Metadata{Name: "test-chart", Version: "1.0.0"}, + } + + output, err := client.Run("") + require.NoError(t, err) + + assert.Contains(t, output, "org.opencontainers.image.created") + assert.Contains(t, output, "2025-04-11T20:12:25Z") + assert.Contains(t, requestedManifest, "/manifests/1.0.0") +} diff --git a/pkg/cmd/show.go b/pkg/cmd/show.go index 73aba9f04..fb36ec3a5 100644 --- a/pkg/cmd/show.go +++ b/pkg/cmd/show.go @@ -21,7 +21,6 @@ import ( "io" "log" "log/slog" - "path" "strings" "github.com/spf13/cobra" @@ -222,11 +221,11 @@ func runShow(args []string, client *action.Show) (string, error) { } if registry.IsOCI(args[0]) { - ref := strings.TrimPrefix(args[0], registry.OCIScheme+"://") - if client.Version != "" && !strings.Contains(path.Base(ref), ":") { - ref = fmt.Sprintf("%s:%s", ref, client.Version) - } - client.OCIRef = ref + // Store the bare OCI reference (without the oci:// scheme). The concrete + // tag is resolved from the downloaded chart version in Show.Run, because + // client.Version may be a range (for example ">0.0.0-0" with --devel) + // rather than an exact tag. + client.OCIRef = strings.TrimPrefix(args[0], registry.OCIScheme+"://") } cp, err := client.LocateChart(args[0], settings)