diff --git a/pkg/action/show.go b/pkg/action/show.go index 4195d69a5..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" @@ -61,7 +62,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 +100,25 @@ 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 { + 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 { + 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..520cb0d96 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,129 @@ 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, "---") +} + +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 a92ca75b4..81833de99 100644 --- a/pkg/cmd/show.go +++ b/pkg/cmd/show.go @@ -21,11 +21,13 @@ import ( "io" "log" "log/slog" + "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 +220,14 @@ func runShow(args []string, client *action.Show) (string, error) { client.Version = ">0.0.0-0" } + if registry.IsOCI(args[0]) { + // 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) if err != nil { return "", err diff --git a/pkg/registry/client.go b/pkg/registry/client.go index 4d7da78dc..87ba2e50c 100644 --- a/pkg/registry/client.go +++ b/pkg/registry/client.go @@ -910,6 +910,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,