From 1e97f1839d35fc81c21107a67e69fa5e65bcd89e Mon Sep 17 00:00:00 2001 From: clcc2019 <46099295+clcc2019@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:59:31 +0800 Subject: [PATCH] Include bundle metadata in describe output Signed-off-by: Song Duan Signed-off-by: clcc2019 <46099295+clcc2019@users.noreply.github.com> --- pkg/imgpkg/bundle/bundle_metadata.go | 97 ++++++++++++++++++++++++++++ pkg/imgpkg/bundle/contents.go | 7 +- pkg/imgpkg/v1/describe.go | 23 +++++++ pkg/imgpkg/v1/describe_test.go | 41 ++++++++++++ 4 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 pkg/imgpkg/bundle/bundle_metadata.go diff --git a/pkg/imgpkg/bundle/bundle_metadata.go b/pkg/imgpkg/bundle/bundle_metadata.go new file mode 100644 index 000000000..d1c3894ba --- /dev/null +++ b/pkg/imgpkg/bundle/bundle_metadata.go @@ -0,0 +1,97 @@ +// Copyright 2026 The Carvel Authors. +// SPDX-License-Identifier: Apache-2.0 + +package bundle + +import ( + "archive/tar" + "fmt" + "io" + "path/filepath" + + regv1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/types" + "sigs.k8s.io/yaml" +) + +// BundleAuthor contains author information from a bundle's metadata file. +type BundleAuthor struct { + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` +} + +// BundleWebsite contains a website from a bundle's metadata file. +type BundleWebsite struct { + URL string `json:"url,omitempty"` +} + +// BundleMetadata contains the user-provided information in .imgpkg/bundle.yml. +type BundleMetadata struct { + Metadata map[string]string `json:"metadata,omitempty"` + Authors []BundleAuthor `json:"authors,omitempty"` + Websites []BundleWebsite `json:"websites,omitempty"` +} + +// Metadata reads the metadata associated with the bundle. +func (o *Bundle) Metadata() (BundleMetadata, error) { + img, err := o.checkedImage() + if err != nil { + return BundleMetadata{}, err + } + + return readBundleMetadata(img) +} + +func readBundleMetadata(img regv1.Image) (BundleMetadata, error) { + metadata := BundleMetadata{} + layers, err := img.Layers() + if err != nil { + return metadata, err + } + + if len(layers) != 1 { + return metadata, fmt.Errorf("Expected bundle to only have a single layer, got %d", len(layers)) + } + + layer := layers[0] + mediaType, err := layer.MediaType() + if err != nil { + return metadata, err + } + + if mediaType != types.DockerLayer { + return metadata, fmt.Errorf("Expected layer to have docker layer media type, was %s", mediaType) + } + + uncompressedReader, err := layer.Uncompressed() + if err != nil { + return metadata, fmt.Errorf("Could not read bundle image layer contents: %v", err) + } + defer uncompressedReader.Close() + + tarReader := tar.NewReader(uncompressedReader) + for { + header, err := tarReader.Next() + if err != nil { + if err == io.EOF { + return metadata, nil + } + return metadata, fmt.Errorf("reading tar: %v", err) + } + + if filepath.Dir(header.Name) == ImgpkgDir && filepath.Base(header.Name) == BundleMetadataFile { + break + } + } + + contents, err := io.ReadAll(tarReader) + if err != nil { + return metadata, fmt.Errorf("Reading bundle.yml from layer: %s", err) + } + + if err := yaml.Unmarshal(contents, &metadata); err != nil { + return metadata, fmt.Errorf("Unmarshalling bundle metadata: %s", err) + } + + return metadata, nil +} diff --git a/pkg/imgpkg/bundle/contents.go b/pkg/imgpkg/bundle/contents.go index d7cdbc03f..5a8a66087 100644 --- a/pkg/imgpkg/bundle/contents.go +++ b/pkg/imgpkg/bundle/contents.go @@ -18,9 +18,10 @@ import ( ) const ( - ImgpkgDir = ".imgpkg" - BundlesDir = "bundles" - ImagesLockFile = "images.yml" + ImgpkgDir = ".imgpkg" + BundlesDir = "bundles" + BundleMetadataFile = "bundle.yml" + ImagesLockFile = "images.yml" ) type Contents struct { diff --git a/pkg/imgpkg/v1/describe.go b/pkg/imgpkg/v1/describe.go index 8d0834f83..4a671b62f 100644 --- a/pkg/imgpkg/v1/describe.go +++ b/pkg/imgpkg/v1/describe.go @@ -175,6 +175,11 @@ func (r *refWithDescription) describeBundleRec(visitedImgs map[string]refWithDes if newBundle == nil { return desc.bundle, fmt.Errorf("Internal inconsistency: bundle with ref '%s' could not be found in list of bundles", currentBundle.PrimaryLocation()) } + metadata, err := newBundle.Metadata() + if err != nil { + return desc.bundle, fmt.Errorf("Reading metadata for bundle '%s': %s", currentBundle.PrimaryLocation(), err) + } + desc.bundle.Metadata = newMetadata(metadata) imagesRefs := newBundle.ImagesRefsWithErrors() sort.Slice(imagesRefs, func(i, j int) bool { @@ -230,6 +235,24 @@ func (r *refWithDescription) describeBundleRec(visitedImgs map[string]refWithDes return desc.bundle, nil } +func newMetadata(metadata bundle.BundleMetadata) Metadata { + authors := make([]Author, len(metadata.Authors)) + for i, author := range metadata.Authors { + authors[i] = Author{Name: author.Name, Email: author.Email} + } + + websites := make([]Website, len(metadata.Websites)) + for i, website := range metadata.Websites { + websites[i] = Website{URL: website.URL} + } + + return Metadata{ + Metadata: metadata.Metadata, + Authors: authors, + Websites: websites, + } +} + func getImageLayersInfo(image string) ([]Layers, int64, error) { layers := []Layers{} parsedImgRef, err := regname.ParseReference(image, regname.WeakValidation) diff --git a/pkg/imgpkg/v1/describe_test.go b/pkg/imgpkg/v1/describe_test.go index 4a9090f8d..88fc91948 100644 --- a/pkg/imgpkg/v1/describe_test.go +++ b/pkg/imgpkg/v1/describe_test.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "os" + "path/filepath" "strings" "testing" @@ -369,6 +370,46 @@ func TestDescribeBundle(t *testing.T) { }) } +func TestDescribeBundleMetadata(t *testing.T) { + logger := &helpers.Logger{LogLevel: helpers.LogDebug} + fakeRegBuilder := helpers.NewFakeRegistry(t, logger) + bundlePath := t.TempDir() + imgpkgPath := filepath.Join(bundlePath, ctlbundle.ImgpkgDir) + require.NoError(t, os.Mkdir(imgpkgPath, 0700)) + require.NoError(t, os.WriteFile(filepath.Join(imgpkgPath, ctlbundle.BundleMetadataFile), []byte(` +apiVersion: imgpkg.carvel.dev/v1alpha1 +kind: Bundle +metadata: + name: example + oslManifestURL: https://example.com/manifest +authors: +- name: Example Author + email: author@example.com +websites: +- url: https://example.com +`), 0600)) + require.NoError(t, lockconfig.NewEmptyImagesLock().WriteToPath(filepath.Join(imgpkgPath, ctlbundle.ImagesLockFile))) + + topBundle := fakeRegBuilder.WithBundleFromPath("repo/bundle-with-metadata", bundlePath) + fakeRegBuilder.Build() + + description, err := v1.Describe(topBundle.RefDigest, v1.DescribeOpts{ + Logger: logger, + Concurrency: 1, + }, registry.Opts{ + EnvironFunc: os.Environ, + RetryCount: 3, + }) + require.NoError(t, err) + + assert.Equal(t, map[string]string{ + "name": "example", + "oslManifestURL": "https://example.com/manifest", + }, description.Metadata.Metadata) + assert.Equal(t, []v1.Author{{Name: "Example Author", Email: "author@example.com"}}, description.Metadata.Authors) + assert.Equal(t, []v1.Website{{URL: "https://example.com"}}, description.Metadata.Websites) +} + type testImage struct { testBundle }