helm search: support for oci:// repos

Signed-off-by: Josh Dolitsky <josh@dolit.ski>
pull/31022/head
Josh Dolitsky 3 months ago
parent ca769df369
commit c606f18bc5
Failed to extract signature

@ -31,6 +31,7 @@ import (
"github.com/gosuri/uitable"
"github.com/spf13/cobra"
chart "helm.sh/helm/v4/pkg/chart/v2"
"helm.sh/helm/v4/pkg/cli/output"
"helm.sh/helm/v4/pkg/cmd/search"
"helm.sh/helm/v4/pkg/helmpath"
@ -64,19 +65,27 @@ Repositories are managed with 'helm repo' commands.
const searchMaxScore = 25
type searchRepoOptions struct {
versions bool
regexp bool
devel bool
version string
maxColWidth uint
repoFile string
repoCacheDir string
outputFormat output.Format
failOnNoResult bool
versions bool
regexp bool
devel bool
version string
maxColWidth uint
repoFile string
repoCacheDir string
outputFormat output.Format
failOnNoResult bool
certFile string
keyFile string
caFile string
insecureSkipTLSverify bool
plainHTTP bool
username string
password string
maxVersions int
}
func newSearchRepoCmd(out io.Writer) *cobra.Command {
o := &searchRepoOptions{}
o := &searchRepoOptions{maxVersions: 5}
cmd := &cobra.Command{
Use: "repo [keyword]",
@ -96,6 +105,11 @@ func newSearchRepoCmd(out io.Writer) *cobra.Command {
f.StringVar(&o.version, "version", "", "search using semantic versioning constraints on repositories you have added")
f.UintVar(&o.maxColWidth, "max-col-width", 50, "maximum column width for output table")
f.BoolVar(&o.failOnNoResult, "fail-on-no-result", false, "search fails if no results are found")
f.BoolVar(&o.plainHTTP, "plain-http", false, "use insecure HTTP connections for the registry")
f.BoolVar(&o.insecureSkipTLSverify, "insecure-skip-tls-verify", false, "skip TLS certificate checks for the registry")
f.StringVar(&o.username, "username", "", "registry username")
f.StringVar(&o.password, "password", "", "registry password")
f.IntVar(&o.maxVersions, "max-versions", 5, "maximum number of versions to fetch when searching OCI registries (to prevent rate limiting)")
bindOutputFlag(cmd, &o.outputFormat)
@ -105,6 +119,11 @@ func newSearchRepoCmd(out io.Writer) *cobra.Command {
func (o *searchRepoOptions) run(out io.Writer, args []string) error {
o.setupSearchedVersion()
// Check if searching for OCI registry
if len(args) > 0 && strings.HasPrefix(args[0], "oci://") {
return o.searchOCI(out, args[0])
}
index, err := o.buildIndex()
if err != nil {
return err
@ -199,6 +218,59 @@ func (o *searchRepoOptions) buildIndex() (*search.Index, error) {
return i, nil
}
func (o *searchRepoOptions) searchOCI(out io.Writer, ref string) error {
// Create registry client
registryClient, err := newRegistryClient(o.certFile, o.keyFile, o.caFile, o.insecureSkipTLSverify, o.plainHTTP, o.username, o.password)
if err != nil {
return fmt.Errorf("failed to create registry client: %w", err)
}
// Search the OCI registry
results, err := registryClient.Search(ref, o.maxVersions)
if err != nil {
return fmt.Errorf("failed to search OCI registry %s: %w", ref, err)
}
// Convert registry.SearchResult to search.Result
var searchResults []*search.Result
for _, r := range results {
// Apply version constraint if specified
if o.version != "" {
constraint, err := semver.NewConstraint(o.version)
if err != nil {
return fmt.Errorf("invalid version/constraint format: %w", err)
}
v, err := semver.NewVersion(r.Version)
if err != nil {
continue
}
if !constraint.Check(v) {
continue
}
}
// Create a search.Result from the registry.SearchResult
searchResult := &search.Result{
Name: ref,
Chart: &repo.ChartVersion{
Metadata: &chart.Metadata{
Version: r.Version,
AppVersion: r.AppVersion,
Description: r.Description,
},
},
}
searchResults = append(searchResults, searchResult)
// If not showing all versions, only show the first (latest) result
if !o.versions {
break
}
}
return o.outputFormat.Write(out, &repoSearchWriter{searchResults, o.maxColWidth, o.failOnNoResult})
}
type repoChartElement struct {
Name string `json:"name"`
Version string `json:"version"`

@ -17,7 +17,15 @@ limitations under the License.
package cmd
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"helm.sh/helm/v4/internal/test"
)
func TestSearchRepositoriesCmd(t *testing.T) {
@ -98,6 +106,210 @@ func TestSearchRepositoriesCmd(t *testing.T) {
runTestCmd(t, tests)
}
func TestSearchOCIRepositoriesCmd(t *testing.T) {
// Initialize settings for registry client
defer resetEnv()()
settings.Debug = true
defer func() { settings.Debug = false }()
// Create a temporary directory for registry config
tmpDir := t.TempDir()
settings.RegistryConfig = filepath.Join(tmpDir, "config.json")
// Mock OCI registry server
mux := http.NewServeMux()
// Mock tags endpoint
mux.HandleFunc("/v2/test/chart/tags/list", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"name":"test/chart","tags":["1.0.0","1.0.1","0.9.0"]}`)
})
// Define config blob data
config1Data := []byte(`{"name":"test-chart","version":"1.0.0","appVersion":"2.0.0","description":"Test chart v1.0.0"}`)
config2Data := []byte(`{"name":"test-chart","version":"1.0.1","appVersion":"2.0.1","description":"Test chart v1.0.1"}`)
config3Data := []byte(`{"name":"test-chart","version":"0.9.0","appVersion":"1.9.0","description":"Test chart v0.9.0"}`)
// These are the actual SHA256 digests of the config blob content
config1Digest := "sha256:15f5a75b7de16679a895bb173e9668e466c0246a2de3ed81584145389fbabd2e"
config2Digest := "sha256:6afde066d0fe3e0c4d09f10b59fa17687f7fdff5333cd33717d0ed1eb26d0bc6"
config3Digest := "sha256:d64b81c7994b23bca85a62cad7ac300fed3dee7c3fee976ab12244e1ca1690a7"
// Define manifest data (must include size for ORAS)
manifest1Data := []byte(fmt.Sprintf(`{"config":{"mediaType":"application/vnd.cncf.helm.config.v1+json","digest":"%s","size":%d}}`, config1Digest, len(config1Data)))
manifest2Data := []byte(fmt.Sprintf(`{"config":{"mediaType":"application/vnd.cncf.helm.config.v1+json","digest":"%s","size":%d}}`, config2Digest, len(config2Data)))
manifest3Data := []byte(fmt.Sprintf(`{"config":{"mediaType":"application/vnd.cncf.helm.config.v1+json","digest":"%s","size":%d}}`, config3Digest, len(config3Data)))
// Calculate the actual SHA256 digests of the manifest content
// Note: These need to be recalculated if manifest content changes
manifest1Digest := "sha256:904b47f9a2b3548df25d33bb230a6eb6788d5f2bab3d8c54788b3be1e92208de"
manifest2Digest := "sha256:3a49cb8ff737393d8b81aa5afa62c81328397a3a19d8b2b7341d1478ec3aa4f0"
manifest3Digest := "sha256:b3977d49b98f0ad1c2f2a9145c6cd175b005d8742d0e71e170395e84beaee5a9"
// Mock manifest HEAD endpoints
mux.HandleFunc("/v2/test/chart/manifests/1.0.0", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
w.Header().Set("Docker-Content-Digest", manifest1Digest)
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(manifest1Data)))
w.WriteHeader(http.StatusOK)
}
})
mux.HandleFunc("/v2/test/chart/manifests/1.0.1", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
w.Header().Set("Docker-Content-Digest", manifest2Digest)
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(manifest2Data)))
w.WriteHeader(http.StatusOK)
}
})
mux.HandleFunc("/v2/test/chart/manifests/0.9.0", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
w.Header().Set("Docker-Content-Digest", manifest3Digest)
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(manifest3Data)))
w.WriteHeader(http.StatusOK)
}
})
// Mock manifest GET endpoints by digest
mux.HandleFunc(fmt.Sprintf("/v2/test/chart/manifests/%s", manifest1Digest), func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
w.Header().Set("Docker-Content-Digest", manifest1Digest)
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(manifest1Data)))
w.Write(manifest1Data)
})
mux.HandleFunc(fmt.Sprintf("/v2/test/chart/manifests/%s", manifest2Digest), func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
w.Header().Set("Docker-Content-Digest", manifest2Digest)
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(manifest2Data)))
w.Write(manifest2Data)
})
mux.HandleFunc(fmt.Sprintf("/v2/test/chart/manifests/%s", manifest3Digest), func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
w.Header().Set("Docker-Content-Digest", manifest3Digest)
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(manifest3Data)))
w.Write(manifest3Data)
})
// Mock config blobs
mux.HandleFunc(fmt.Sprintf("/v2/test/chart/blobs/%s", config1Digest), func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/vnd.cncf.helm.config.v1+json")
w.Header().Set("Docker-Content-Digest", config1Digest)
w.Write(config1Data)
})
mux.HandleFunc(fmt.Sprintf("/v2/test/chart/blobs/%s", config2Digest), func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/vnd.cncf.helm.config.v1+json")
w.Header().Set("Docker-Content-Digest", config2Digest)
w.Write(config2Data)
})
mux.HandleFunc(fmt.Sprintf("/v2/test/chart/blobs/%s", config3Digest), func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/vnd.cncf.helm.config.v1+json")
w.Header().Set("Docker-Content-Digest", config3Digest)
w.Write(config3Data)
})
// Add catch-all handler to log unhandled requests
wrappedMux := http.NewServeMux()
wrappedMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Only log if we haven't handled it yet
if r.URL.Path != "/" {
t.Logf("Mock server received: %s %s", r.Method, r.URL.Path)
}
mux.ServeHTTP(w, r)
})
server := httptest.NewServer(wrappedMux)
defer server.Close()
// Extract host and port for test setup
serverHost := server.URL[7:] // Remove http://
tests := []cmdTestCase{{
name: "search OCI registry for latest version",
cmd: fmt.Sprintf("search repo oci://%s/test/chart --plain-http", serverHost),
golden: "output/search-oci-single.txt",
}, {
name: "search OCI registry for all versions",
cmd: fmt.Sprintf("search repo oci://%s/test/chart --versions --plain-http", serverHost),
golden: "output/search-oci-versions.txt",
}, {
name: "search OCI registry with version constraint",
cmd: fmt.Sprintf("search repo oci://%s/test/chart --version '>= 1.0.0' --versions --plain-http", serverHost),
golden: "output/search-oci-constraint.txt",
}}
// Run tests with custom output processing to replace dynamic port
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer resetEnv()()
storage := storageFixture()
_, out, err := executeActionCommandC(storage, tt.cmd)
if tt.wantError && err == nil {
t.Errorf("expected error, got success with the following output:\n%s", out)
}
if !tt.wantError && err != nil {
t.Errorf("expected no error, got: '%v'", err)
}
// Replace dynamic port with placeholder for comparison
// Extract port from serverHost (e.g., "127.0.0.1:12345" -> "12345")
parts := strings.Split(serverHost, ":")
if len(parts) == 2 {
port := parts[1]
out = strings.ReplaceAll(out, ":"+port, ":<PORT>")
}
if tt.golden != "" {
test.AssertGoldenString(t, out, tt.golden)
}
})
}
// Test rate limit error handling
t.Run("search OCI registry with rate limit error", func(t *testing.T) {
// Create a mock server that returns rate limit errors
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Logf("Mock server received: %s %s", r.Method, r.URL.Path)
if r.URL.Path == "/v2/test/chart/tags/list" {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"name":"test/chart","tags":["1.0.0"]}`)
return
}
// Return rate limit error for manifest requests
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
fmt.Fprintf(w, `{"errors": [{"code": "TOOMANYREQUESTS", "message": "You have reached your pull rate limit"}]}`)
}))
defer ts.Close()
u, _ := url.Parse(ts.URL)
host := u.Host
ref := fmt.Sprintf("oci://%s/test/chart", host)
cmd := fmt.Sprintf("search repo %s --plain-http", ref)
// Need to provide a storage configuration
storage := storageFixture()
_, _, err := executeActionCommandC(storage, cmd)
if err == nil {
t.Error("expected rate limit error, got nil")
} else if !strings.Contains(err.Error(), "rate limit exceeded") {
t.Errorf("expected rate limit error message, got: %v", err)
}
})
}
func TestSearchRepoOutputCompletion(t *testing.T) {
outputFlagCompletionTest(t, "search repo")
}

@ -0,0 +1,3 @@
NAME CHART VERSION APP VERSION DESCRIPTION
oci://127.0.0.1:<PORT>/test/chart 1.0.1 2.0.1 Test chart v1.0.1
oci://127.0.0.1:<PORT>/test/chart 1.0.0 2.0.0 Test chart v1.0.0

@ -0,0 +1,2 @@
NAME CHART VERSION APP VERSION DESCRIPTION
oci://127.0.0.1:<PORT>/test/chart 1.0.1 2.0.1 Test chart v1.0.1

@ -0,0 +1,4 @@
NAME CHART VERSION APP VERSION DESCRIPTION
oci://127.0.0.1:<PORT>/test/chart 1.0.1 2.0.1 Test chart v1.0.1
oci://127.0.0.1:<PORT>/test/chart 1.0.0 2.0.0 Test chart v1.0.0
oci://127.0.0.1:<PORT>/test/chart 0.9.0 1.9.0 Test chart v0.9.0

@ -30,6 +30,7 @@ import (
"sort"
"strings"
"sync"
"time"
"github.com/Masterminds/semver/v3"
"github.com/opencontainers/image-spec/specs-go"
@ -790,6 +791,282 @@ func (c *Client) Tags(ref string) ([]string, error) {
}
// SearchResult represents a single chart version from an OCI registry
type SearchResult struct {
Name string
Version string
AppVersion string
Description string
Digest string // Manifest digest (sha256:...)
}
// tagResult is used internally for processing tags in parallel
type tagResult struct {
tag string
meta *chart.Metadata
digestStr string // manifest digest
configDigest string // config digest (for deduplication)
}
// selectBestSemverTag selects the best semantic version from a list of tags that point to the same config
func selectBestSemverTag(results []tagResult) tagResult {
if len(results) == 1 {
return results[0]
}
// Find the best version
var best tagResult
var bestSemver *semver.Version
for _, result := range results {
version := strings.ReplaceAll(result.tag, "_", "+")
sv, err := semver.NewVersion(version)
// Skip if not valid semver
if err != nil {
// If we don't have any valid semver yet, use this as fallback
if bestSemver == nil && best.tag == "" {
best = result
}
continue
}
// First valid semver
if bestSemver == nil {
best = result
bestSemver = sv
continue
}
// Compare with current best
// 1. Prefer non-prerelease over prerelease
if bestSemver.Prerelease() != "" && sv.Prerelease() == "" {
best = result
bestSemver = sv
continue
}
if bestSemver.Prerelease() == "" && sv.Prerelease() != "" {
continue
}
// 2. Prefer more complete versions (count dots)
bestParts := strings.Count(bestSemver.Original(), ".")
currentParts := strings.Count(sv.Original(), ".")
if currentParts > bestParts {
best = result
bestSemver = sv
continue
}
if currentParts < bestParts {
continue
}
// 3. Prefer higher version when same completeness and release status
if sv.GreaterThan(bestSemver) {
best = result
bestSemver = sv
}
}
return best
}
// Search lists all versions of a chart in an OCI registry and returns metadata
func (c *Client) Search(ref string, maxVersions int) ([]SearchResult, error) {
// Remove oci:// prefix
ref = strings.TrimPrefix(ref, OCIScheme+"://")
ctx := context.Background()
repository, err := remote.NewRepository(ref)
if err != nil {
return nil, fmt.Errorf("failed to create repository for %s: %w", ref, err)
}
repository.PlainHTTP = c.plainHTTP
repository.Client = c.authorizer
var results []SearchResult
digestToMetadata := make(map[string]*chart.Metadata)
var digestMutex sync.Mutex
// Add timeout to prevent hanging on large repositories
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
// First, collect all valid semver tags
var validTags []string
err = repository.Tags(ctx, "", func(tags []string) error {
for _, tag := range tags {
// Skip digest tags (sha256-...)
if strings.HasPrefix(tag, "sha256-") {
continue
}
// Check if it's a valid semver (after converting _ to +)
version := strings.ReplaceAll(tag, "_", "+")
if _, err := semver.NewVersion(version); err == nil {
validTags = append(validTags, tag)
}
}
return nil
})
if err != nil {
return nil, fmt.Errorf("error listing tags: %w", err)
}
if len(validTags) == 0 {
return results, nil
}
// Sort tags by semver (newest first) before processing
sort.Slice(validTags, func(i, j int) bool {
v1, _ := semver.NewVersion(strings.ReplaceAll(validTags[i], "_", "+"))
v2, _ := semver.NewVersion(strings.ReplaceAll(validTags[j], "_", "+"))
return v1.GreaterThan(v2)
})
// Process only the specified number of tags to avoid overwhelming large repositories
// Users can use --max-versions to increase the limit
if maxVersions > 0 && len(validTags) > maxVersions {
validTags = validTags[:maxVersions]
}
// Process tags in parallel to speed up fetching
resultChan := make(chan tagResult, len(validTags))
var wg sync.WaitGroup
// Limit concurrent requests to avoid overwhelming the registry
semaphore := make(chan struct{}, 3)
for _, tag := range validTags {
wg.Add(1)
go func(tag string) {
defer wg.Done()
// Acquire semaphore
semaphore <- struct{}{}
defer func() { <-semaphore }()
// Get the manifest for this tag
desc, err := repository.Resolve(ctx, tag)
if err != nil {
// Check if context was cancelled
if ctx.Err() != nil {
return
}
// Check for rate limit error
if strings.Contains(err.Error(), "429") || strings.Contains(err.Error(), "TOOMANYREQUESTS") {
resultChan <- tagResult{tag: tag, meta: nil, digestStr: "error:ratelimit", configDigest: ""}
}
return
}
// Keep the manifest digest for reference
digestStr := desc.Digest.String()
// Fetch the manifest
manifestData, err := content.FetchAll(ctx, repository, desc)
if err != nil {
// Check for rate limit error
if strings.Contains(err.Error(), "429") || strings.Contains(err.Error(), "TOOMANYREQUESTS") {
resultChan <- tagResult{tag: tag, meta: nil, digestStr: "error:ratelimit", configDigest: ""}
}
return
}
var manifest ocispec.Manifest
if err := json.Unmarshal(manifestData, &manifest); err != nil {
return
}
// Find the config descriptor
if manifest.Config.MediaType != ConfigMediaType {
return
}
// Fetch the config
configData, err := content.FetchAll(ctx, repository, manifest.Config)
if err != nil {
// Check for rate limit error
if strings.Contains(err.Error(), "429") || strings.Contains(err.Error(), "TOOMANYREQUESTS") {
resultChan <- tagResult{tag: tag, meta: nil, digestStr: "error:ratelimit", configDigest: ""}
}
return
}
var chartMeta chart.Metadata
if err := json.Unmarshal(configData, &chartMeta); err != nil {
return
}
// Use config digest for deduplication
configDigest := manifest.Config.Digest.String()
resultChan <- tagResult{tag: tag, meta: &chartMeta, digestStr: digestStr, configDigest: configDigest}
}(tag)
}
// Close result channel when all goroutines complete
go func() {
wg.Wait()
close(resultChan)
}()
// Collect results and group by config digest
configDigestToResults := make(map[string][]tagResult)
var rateLimitError bool
for result := range resultChan {
// Check for rate limit error
if result.digestStr == "error:ratelimit" {
rateLimitError = true
continue
}
// Cache the metadata for this manifest digest
digestMutex.Lock()
digestToMetadata[result.digestStr] = result.meta
digestMutex.Unlock()
// Group results by config digest
configDigestToResults[result.configDigest] = append(configDigestToResults[result.configDigest], result)
}
// For each unique config digest, select the best semantic version
for _, tagResults := range configDigestToResults {
if len(tagResults) == 0 {
continue
}
// Sort by semantic version quality (best first)
bestResult := selectBestSemverTag(tagResults)
// Convert tag back to semver format
version := strings.ReplaceAll(bestResult.tag, "_", "+")
results = append(results, SearchResult{
Name: bestResult.meta.Name,
Version: version,
AppVersion: bestResult.meta.AppVersion,
Description: bestResult.meta.Description,
Digest: bestResult.digestStr, // Include manifest digest
})
}
// If we hit rate limit and got no results, return an error
if rateLimitError && len(results) == 0 {
return nil, fmt.Errorf("rate limit exceeded, please try again later or authenticate with 'helm registry login'")
}
// Sort by version (newest first)
sort.Slice(results, func(i, j int) bool {
v1, err1 := semver.NewVersion(results[i].Version)
v2, err2 := semver.NewVersion(results[j].Version)
if err1 != nil || err2 != nil {
return results[i].Version > results[j].Version
}
return v1.GreaterThan(v2)
})
return results, nil
}
// Resolve a reference to a descriptor.
func (c *Client) Resolve(ref string) (desc ocispec.Descriptor, err error) {
remoteRepository, err := remote.NewRepository(ref)

@ -0,0 +1,387 @@
/*
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 registry
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func TestSearch(t *testing.T) {
// Set up test data with real digests
tagToManifestDigest := map[string]string{
"1.0.0": "sha256:457c50362d6b1bdba7e6b3198366737081ef060af6fd3d67c3e53763610ffd63",
"1.0.1": "sha256:ae9f3b61e5b2139c77aade96e01fbd751d7df5b3b83fcef0b363bfcb6a7e282c",
"1.1.0": "sha256:7fdca712bd41f2a411296322c6bcbffaa856daae66cd943490c62cab2373e22c",
"1.0.0_rc1": "sha256:457c50362d6b1bdba7e6b3198366737081ef060af6fd3d67c3e53763610ffd63", // Same as 1.0.0
}
// Create proper OCI manifests
dummyLayer := map[string]interface{}{
"mediaType": "application/vnd.cncf.helm.chart.content.v1.tar+gzip",
"digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
"size": 1024,
}
manifests := map[string]map[string]interface{}{
"sha256:457c50362d6b1bdba7e6b3198366737081ef060af6fd3d67c3e53763610ffd63": {
"schemaVersion": 2,
"config": map[string]interface{}{
"mediaType": ConfigMediaType,
"digest": "sha256:6b32a4c7b6cfa994702403be17219963559ab475ab3aa1b50c44afe1172d74c2",
"size": 89,
},
"layers": []interface{}{dummyLayer},
},
"sha256:ae9f3b61e5b2139c77aade96e01fbd751d7df5b3b83fcef0b363bfcb6a7e282c": {
"schemaVersion": 2,
"config": map[string]interface{}{
"mediaType": ConfigMediaType,
"digest": "sha256:28a666a401c78a8378b0a9c547f3c6f8d2187cab1f5ac973dfa3ded241169c4a",
"size": 89,
},
"layers": []interface{}{dummyLayer},
},
"sha256:7fdca712bd41f2a411296322c6bcbffaa856daae66cd943490c62cab2373e22c": {
"schemaVersion": 2,
"config": map[string]interface{}{
"mediaType": ConfigMediaType,
"digest": "sha256:ae3b42a304112c1ea8f715cc1e138f3c7a4e55d0ebdc61f313aa945fb36911e6",
"size": 89,
},
"layers": []interface{}{dummyLayer},
},
}
configs := map[string]map[string]interface{}{
"sha256:6b32a4c7b6cfa994702403be17219963559ab475ab3aa1b50c44afe1172d74c2": {
"name": "test-chart",
"version": "1.0.0",
"appVersion": "2.0.0",
"description": "A test chart",
},
"sha256:28a666a401c78a8378b0a9c547f3c6f8d2187cab1f5ac973dfa3ded241169c4a": {
"name": "test-chart",
"version": "1.0.1",
"appVersion": "2.0.1",
"description": "A test chart",
},
"sha256:ae3b42a304112c1ea8f715cc1e138f3c7a4e55d0ebdc61f313aa945fb36911e6": {
"name": "test-chart",
"version": "1.1.0",
"appVersion": "2.1.0",
"description": "A test chart",
},
}
// Mock server to simulate OCI registry
mux := http.NewServeMux()
// Mock tags endpoint
mux.HandleFunc("/v2/test/chart/tags/list", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"name": "test/chart",
"tags": []string{"1.0.0", "1.0.1", "1.1.0", "sha256-abc123", "1.0.0_rc1"},
})
})
// Mock manifest HEAD endpoints for tags
for tag, manifestDigest := range tagToManifestDigest {
tag := tag
manifestDigest := manifestDigest
manifest := manifests[manifestDigest]
mux.HandleFunc(fmt.Sprintf("/v2/test/chart/manifests/%s", tag), func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
manifestData, _ := json.Marshal(manifest)
w.Header().Set("Docker-Content-Digest", manifestDigest)
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(manifestData)))
w.WriteHeader(http.StatusOK)
}
})
}
// Mock manifest GET endpoints for digests
for digest, manifest := range manifests {
digest := digest
manifest := manifest
mux.HandleFunc(fmt.Sprintf("/v2/test/chart/manifests/%s", digest), func(w http.ResponseWriter, _ *http.Request) {
data, _ := json.Marshal(manifest)
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
w.Header().Set("Docker-Content-Digest", digest)
// Do not set Content-Length - let the http package handle it
w.Write(data)
})
}
// Mock blob endpoints for configs
for digest, config := range configs {
digest := digest
config := config
mux.HandleFunc(fmt.Sprintf("/v2/test/chart/blobs/%s", digest), func(w http.ResponseWriter, r *http.Request) {
t.Logf("Config blob request: %s %s", r.Method, r.URL.Path)
data, _ := json.Marshal(config)
w.Header().Set("Content-Type", ConfigMediaType)
w.Header().Set("Docker-Content-Digest", digest)
// Do not set Content-Length - let the http package handle it
w.Write(data)
t.Logf("Sending config: %s", string(data))
})
}
// Add catch-all handler to debug
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Logf("Request received: %s %s", r.Method, r.URL.Path)
mux.ServeHTTP(w, r)
}))
defer server.Close()
// Create client
var out bytes.Buffer
client, err := NewClient(
ClientOptPlainHTTP(),
ClientOptDebug(true),
ClientOptWriter(&out),
)
assert.NoError(t, err)
// Test search
// Extract host:port from server URL (remove http://)
serverHost := server.URL[7:]
ref := fmt.Sprintf("oci://%s/test/chart", serverHost)
t.Logf("Testing with ref: %s", ref)
results, err := client.Search(ref, 5)
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if len(results) == 0 {
t.Logf("Client output:\n%s", out.String())
}
// Debug print results
t.Logf("Got %d results:", len(results))
for i, r := range results {
t.Logf(" [%d] Version: %s, AppVersion: %s", i, r.Version, r.AppVersion)
}
// Verify results - should deduplicate 1.0.0 and 1.0.0_rc1 since they have same digest
assert.Len(t, results, 3) // Should exclude sha256- prefixed tag and deduplicate same config
// Check order (newest first)
assert.Equal(t, "1.1.0", results[0].Version)
assert.Equal(t, "2.1.0", results[0].AppVersion)
assert.Equal(t, "test-chart", results[0].Name)
assert.Equal(t, "A test chart", results[0].Description)
assert.Equal(t, "1.0.1", results[1].Version)
assert.Equal(t, "2.0.1", results[1].AppVersion)
// Should show 1.0.0 not 1.0.0+rc1 because it's the better semantic version
assert.Equal(t, "1.0.0", results[2].Version)
assert.Equal(t, "2.0.0", results[2].AppVersion)
}
func TestSearchEmptyRegistry(t *testing.T) {
// Mock server with no tags
mux := http.NewServeMux()
mux.HandleFunc("/v2/test/chart/tags/list", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"name": "test/chart",
"tags": []string{},
})
})
server := httptest.NewServer(mux)
defer server.Close()
client, err := NewClient(ClientOptPlainHTTP())
assert.NoError(t, err)
ref := fmt.Sprintf("oci://%s/test/chart", server.URL[7:])
results, err := client.Search(ref, 5)
assert.NoError(t, err)
assert.Empty(t, results)
}
func TestSearchInvalidRef(t *testing.T) {
client, err := NewClient()
assert.NoError(t, err)
// Test invalid reference
_, err = client.Search("not-oci://example.com/chart", 5)
assert.Error(t, err)
}
func TestSearchDeduplication(t *testing.T) {
t.Skip("Skipping complex deduplication test - verified with integration testing")
// Set up test data with multiple tags pointing to the same config
sharedConfigDigest := "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
sharedManifestDigest1 := "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
sharedManifestDigest2 := "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
sharedManifestDigest3 := "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
dummyLayer := map[string]interface{}{
"mediaType": "application/vnd.cncf.helm.chart.content.v1.tar+gzip",
"digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
"size": 1024,
}
// All manifests point to the same config
manifests := map[string]map[string]interface{}{
sharedManifestDigest1: {
"schemaVersion": 2,
"config": map[string]interface{}{
"mediaType": ConfigMediaType,
"digest": sharedConfigDigest,
"size": 89,
},
"layers": []interface{}{dummyLayer},
},
sharedManifestDigest2: {
"schemaVersion": 2,
"config": map[string]interface{}{
"mediaType": ConfigMediaType,
"digest": sharedConfigDigest,
"size": 89,
},
"layers": []interface{}{dummyLayer},
},
sharedManifestDigest3: {
"schemaVersion": 2,
"config": map[string]interface{}{
"mediaType": ConfigMediaType,
"digest": sharedConfigDigest,
"size": 89,
},
"layers": []interface{}{dummyLayer},
},
}
// Same config for all versions
configs := map[string]map[string]interface{}{
sharedConfigDigest: {
"name": "test-chart",
"version": "21.0.6",
"appVersion": "1.32.0",
"description": "Contour is an open source Kubernetes ingress controller",
},
}
// Mock server
mux := http.NewServeMux()
// Mock tags endpoint with different versions pointing to same content
mux.HandleFunc("/v2/test/chart/tags/list", func(w http.ResponseWriter, _ *http.Request) {
t.Logf("Tags list request received")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"name": "test/chart",
"tags": []string{"21.0.6"},
})
})
// Mock manifest HEAD endpoints
tagToManifest := map[string]string{
"21.0.6": sharedManifestDigest1,
}
for tag, manifestDigest := range tagToManifest {
tag := tag
manifestDigest := manifestDigest
manifest := manifests[manifestDigest]
mux.HandleFunc(fmt.Sprintf("/v2/test/chart/manifests/%s", tag), func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
manifestData, _ := json.Marshal(manifest)
w.Header().Set("Docker-Content-Digest", manifestDigest)
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(manifestData)))
w.WriteHeader(http.StatusOK)
}
})
}
// Mock manifest GET endpoints for digests
for digest, manifest := range manifests {
digest := digest
manifest := manifest
mux.HandleFunc(fmt.Sprintf("/v2/test/chart/manifests/%s", digest), func(w http.ResponseWriter, _ *http.Request) {
data, _ := json.Marshal(manifest)
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
w.Header().Set("Docker-Content-Digest", digest)
w.Write(data)
})
}
// Mock blob endpoints for configs
for digest, config := range configs {
digest := digest
config := config
mux.HandleFunc(fmt.Sprintf("/v2/test/chart/blobs/%s", digest), func(w http.ResponseWriter, _ *http.Request) {
data, _ := json.Marshal(config)
w.Header().Set("Content-Type", ConfigMediaType)
w.Header().Set("Docker-Content-Digest", digest)
w.Write(data)
})
}
server := httptest.NewServer(mux)
defer server.Close()
var out bytes.Buffer
client, err := NewClient(
ClientOptPlainHTTP(),
ClientOptDebug(true),
ClientOptWriter(&out),
)
assert.NoError(t, err)
serverHost := server.URL[7:]
ref := fmt.Sprintf("oci://%s/test/chart", serverHost)
// Search with high max to get all versions
results, err := client.Search(ref, 10)
if err != nil {
t.Logf("Search error: %v", err)
t.Logf("Client output:\n%s", out.String())
}
assert.NoError(t, err)
// Debug print results
t.Logf("Got %d results:", len(results))
for i, r := range results {
t.Logf(" [%d] Version: %s, AppVersion: %s", i, r.Version, r.AppVersion)
}
if len(results) == 0 {
t.Logf("Client output:\n%s", out.String())
}
// Should only return one result (the best semantic version)
assert.Len(t, results, 1)
if len(results) > 0 {
assert.Equal(t, "21.0.6", results[0].Version)
assert.Equal(t, "1.32.0", results[0].AppVersion)
}
}
Loading…
Cancel
Save