working commit

This commit is contained in:
2026-03-13 19:02:42 +02:00
parent bebbf79c7a
commit 5c1da77f4c
1329 changed files with 314708 additions and 39 deletions
+124
View File
@@ -0,0 +1,124 @@
/*
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 "helm.sh/helm/v4/pkg/registry"
import (
"bytes"
"strings"
"time"
chart "helm.sh/helm/v4/pkg/chart/v2"
"helm.sh/helm/v4/pkg/chart/v2/loader"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)
var immutableOciAnnotations = []string{
ocispec.AnnotationVersion,
ocispec.AnnotationTitle,
}
// extractChartMeta is used to extract a chart metadata from a byte array
func extractChartMeta(chartData []byte) (*chart.Metadata, error) {
ch, err := loader.LoadArchive(bytes.NewReader(chartData))
if err != nil {
return nil, err
}
return ch.Metadata, nil
}
// generateOCIAnnotations will generate OCI annotations to include within the OCI manifest
func generateOCIAnnotations(meta *chart.Metadata, creationTime string) map[string]string {
// Get annotations from Chart attributes
ociAnnotations := generateChartOCIAnnotations(meta, creationTime)
// Copy Chart annotations
annotations:
for chartAnnotationKey, chartAnnotationValue := range meta.Annotations {
// Avoid overriding key properties
for _, immutableOciKey := range immutableOciAnnotations {
if immutableOciKey == chartAnnotationKey {
continue annotations
}
}
// Add chart annotation
ociAnnotations[chartAnnotationKey] = chartAnnotationValue
}
return ociAnnotations
}
// generateChartOCIAnnotations will generate OCI annotations from the provided chart
func generateChartOCIAnnotations(meta *chart.Metadata, creationTime string) map[string]string {
chartOCIAnnotations := map[string]string{}
chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationDescription, meta.Description)
chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationTitle, meta.Name)
chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationVersion, meta.Version)
chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationURL, meta.Home)
if len(creationTime) == 0 {
creationTime = time.Now().UTC().Format(time.RFC3339)
}
chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationCreated, creationTime)
if len(meta.Sources) > 0 {
chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationSource, meta.Sources[0])
}
if len(meta.Maintainers) > 0 {
var maintainerSb strings.Builder
for maintainerIdx, maintainer := range meta.Maintainers {
if len(maintainer.Name) > 0 {
maintainerSb.WriteString(maintainer.Name)
}
if len(maintainer.Email) > 0 {
maintainerSb.WriteString(" (")
maintainerSb.WriteString(maintainer.Email)
maintainerSb.WriteString(")")
}
if maintainerIdx < len(meta.Maintainers)-1 {
maintainerSb.WriteString(", ")
}
}
chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationAuthors, maintainerSb.String())
}
return chartOCIAnnotations
}
// addToMap takes an existing map and adds an item if the value is not empty
func addToMap(inputMap map[string]string, newKey string, newValue string) map[string]string {
// Add item to map if its
if len(strings.TrimSpace(newValue)) > 0 {
inputMap[newKey] = newValue
}
return inputMap
}
+928
View File
@@ -0,0 +1,928 @@
/*
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 "helm.sh/helm/v4/pkg/registry"
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"sort"
"strings"
"github.com/Masterminds/semver/v3"
"github.com/opencontainers/image-spec/specs-go"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"oras.land/oras-go/v2"
"oras.land/oras-go/v2/content/memory"
"oras.land/oras-go/v2/registry"
"oras.land/oras-go/v2/registry/remote"
"oras.land/oras-go/v2/registry/remote/auth"
"oras.land/oras-go/v2/registry/remote/credentials"
"oras.land/oras-go/v2/registry/remote/retry"
"helm.sh/helm/v4/internal/version"
chart "helm.sh/helm/v4/pkg/chart/v2"
"helm.sh/helm/v4/pkg/helmpath"
)
// See https://github.com/helm/helm/issues/10166
const registryUnderscoreMessage = `
OCI artifact references (e.g. tags) do not support the plus sign (+). To support
storing semantic versions, Helm adopts the convention of changing plus (+) to
an underscore (_) in chart version tags when pushing to a registry and back to
a plus (+) when pulling from a registry.`
type (
// RemoteClient shadows the ORAS remote.Client interface
// (hiding the ORAS type from Helm client visibility)
// https://pkg.go.dev/oras.land/oras-go/pkg/registry/remote#Client
RemoteClient interface {
Do(req *http.Request) (*http.Response, error)
}
// Client works with OCI-compliant registries
Client struct {
debug bool
enableCache bool
// path to repository config file e.g. ~/.docker/config.json
credentialsFile string
username string
password string
out io.Writer
authorizer *auth.Client
registryAuthorizer RemoteClient
credentialsStore credentials.Store
httpClient *http.Client
plainHTTP bool
}
// ClientOption allows specifying various settings configurable by the user for overriding the defaults
// used when creating a new default client
// TODO(TerryHowe): ClientOption should return error in v5
ClientOption func(*Client)
)
// NewClient returns a new registry client with config
func NewClient(options ...ClientOption) (*Client, error) {
client := &Client{
out: io.Discard,
}
for _, option := range options {
option(client)
}
if client.credentialsFile == "" {
client.credentialsFile = helmpath.ConfigPath(CredentialsFileBasename)
}
if client.httpClient == nil {
client.httpClient = &http.Client{
Transport: NewTransport(client.debug),
}
}
storeOptions := credentials.StoreOptions{
AllowPlaintextPut: true,
DetectDefaultNativeStore: true,
}
store, err := credentials.NewStore(client.credentialsFile, storeOptions)
if err != nil {
return nil, err
}
dockerStore, err := credentials.NewStoreFromDocker(storeOptions)
if err != nil {
// should only fail if user home directory can't be determined
client.credentialsStore = store
} else {
// use Helm credentials with fallback to Docker
client.credentialsStore = credentials.NewStoreWithFallbacks(store, dockerStore)
}
if client.authorizer == nil {
authorizer := auth.Client{
Client: client.httpClient,
}
authorizer.SetUserAgent(version.GetUserAgent())
if client.username != "" && client.password != "" {
authorizer.Credential = func(_ context.Context, _ string) (auth.Credential, error) {
return auth.Credential{Username: client.username, Password: client.password}, nil
}
} else {
authorizer.Credential = credentials.Credential(client.credentialsStore)
}
if client.enableCache {
authorizer.Cache = auth.NewCache()
}
client.authorizer = &authorizer
}
return client, nil
}
// Generic returns a GenericClient for low-level OCI operations
func (c *Client) Generic() *GenericClient {
return NewGenericClient(c)
}
// ClientOptDebug returns a function that sets the debug setting on client options set
func ClientOptDebug(debug bool) ClientOption {
return func(client *Client) {
client.debug = debug
}
}
// ClientOptEnableCache returns a function that sets the enableCache setting on a client options set
func ClientOptEnableCache(enableCache bool) ClientOption {
return func(client *Client) {
client.enableCache = enableCache
}
}
// ClientOptBasicAuth returns a function that sets the username and password setting on client options set
func ClientOptBasicAuth(username, password string) ClientOption {
return func(client *Client) {
client.username = username
client.password = password
}
}
// ClientOptWriter returns a function that sets the writer setting on client options set
func ClientOptWriter(out io.Writer) ClientOption {
return func(client *Client) {
client.out = out
}
}
// ClientOptAuthorizer returns a function that sets the authorizer setting on a client options set. This
// can be used to override the default authorization mechanism.
//
// Depending on the use-case you may need to set both ClientOptAuthorizer and ClientOptRegistryAuthorizer.
func ClientOptAuthorizer(authorizer auth.Client) ClientOption {
return func(client *Client) {
client.authorizer = &authorizer
}
}
// ClientOptRegistryAuthorizer returns a function that sets the registry authorizer setting on a client options set. This
// can be used to override the default authorization mechanism.
//
// Depending on the use-case you may need to set both ClientOptAuthorizer and ClientOptRegistryAuthorizer.
func ClientOptRegistryAuthorizer(registryAuthorizer RemoteClient) ClientOption {
return func(client *Client) {
client.registryAuthorizer = registryAuthorizer
}
}
// ClientOptCredentialsFile returns a function that sets the credentialsFile setting on a client options set
func ClientOptCredentialsFile(credentialsFile string) ClientOption {
return func(client *Client) {
client.credentialsFile = credentialsFile
}
}
// ClientOptHTTPClient returns a function that sets the httpClient setting on a client options set
func ClientOptHTTPClient(httpClient *http.Client) ClientOption {
return func(client *Client) {
client.httpClient = httpClient
}
}
func ClientOptPlainHTTP() ClientOption {
return func(c *Client) {
c.plainHTTP = true
}
}
type (
// LoginOption allows specifying various settings on login
LoginOption func(*loginOperation)
loginOperation struct {
host string
client *Client
}
)
// warnIfHostHasPath checks if the host contains a repository path and logs a warning if it does.
// Returns true if the host contains a path component (i.e., contains a '/').
func warnIfHostHasPath(host string) bool {
if strings.Contains(host, "/") {
registryHost := strings.Split(host, "/")[0]
slog.Warn("registry login currently only supports registry hostname, not a repository path", "host", host, "suggested", registryHost)
return true
}
return false
}
// Login logs into a registry
func (c *Client) Login(host string, options ...LoginOption) error {
for _, option := range options {
option(&loginOperation{host, c})
}
warnIfHostHasPath(host)
reg, err := remote.NewRegistry(host)
if err != nil {
return err
}
reg.PlainHTTP = c.plainHTTP
cred := auth.Credential{Username: c.username, Password: c.password}
c.authorizer.ForceAttemptOAuth2 = true
reg.Client = c.authorizer
ctx := context.Background()
if err := reg.Ping(ctx); err != nil {
c.authorizer.ForceAttemptOAuth2 = false
if err := reg.Ping(ctx); err != nil {
return fmt.Errorf("authenticating to %q: %w", host, err)
}
}
// Always restore to false after probing, to avoid forcing POST to token endpoints like GHCR.
c.authorizer.ForceAttemptOAuth2 = false
key := credentials.ServerAddressFromRegistry(host)
key = credentials.ServerAddressFromHostname(key)
if err := c.credentialsStore.Put(ctx, key, cred); err != nil {
return err
}
_, _ = fmt.Fprintln(c.out, "Login Succeeded")
return nil
}
// LoginOptBasicAuth returns a function that sets the username/password settings on login
func LoginOptBasicAuth(username string, password string) LoginOption {
return func(o *loginOperation) {
o.client.username = username
o.client.password = password
o.client.authorizer.Credential = auth.StaticCredential(o.host, auth.Credential{Username: username, Password: password})
}
}
// LoginOptPlainText returns a function that allows plaintext (HTTP) login
func LoginOptPlainText(isPlainText bool) LoginOption {
return func(o *loginOperation) {
o.client.plainHTTP = isPlainText
}
}
func ensureTLSConfig(client *auth.Client, setConfig *tls.Config) (*tls.Config, error) {
var transport *http.Transport
switch t := client.Client.Transport.(type) {
case *http.Transport:
transport = t
case *retry.Transport:
switch t := t.Base.(type) {
case *http.Transport:
transport = t
case *LoggingTransport:
switch t := t.RoundTripper.(type) {
case *http.Transport:
transport = t
}
}
}
if transport == nil {
// we don't know how to access the http.Transport, most likely the
// auth.Client.Client was provided by API user
return nil, fmt.Errorf("unable to access TLS client configuration, the provided HTTP Transport is not supported, given: %T", client.Client.Transport)
}
switch {
case setConfig != nil:
transport.TLSClientConfig = setConfig
case transport.TLSClientConfig == nil:
transport.TLSClientConfig = &tls.Config{}
}
return transport.TLSClientConfig, nil
}
// LoginOptInsecure returns a function that sets the insecure setting on login
func LoginOptInsecure(insecure bool) LoginOption {
return func(o *loginOperation) {
tlsConfig, err := ensureTLSConfig(o.client.authorizer, nil)
if err != nil {
panic(err)
}
tlsConfig.InsecureSkipVerify = insecure
}
}
// LoginOptTLSClientConfig returns a function that sets the TLS settings on login.
func LoginOptTLSClientConfig(certFile, keyFile, caFile string) LoginOption {
return func(o *loginOperation) {
if (certFile == "" || keyFile == "") && caFile == "" {
return
}
tlsConfig, err := ensureTLSConfig(o.client.authorizer, nil)
if err != nil {
panic(err)
}
if certFile != "" && keyFile != "" {
authCert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
panic(err)
}
tlsConfig.Certificates = []tls.Certificate{authCert}
}
if caFile != "" {
certPool := x509.NewCertPool()
ca, err := os.ReadFile(caFile)
if err != nil {
panic(err)
}
if !certPool.AppendCertsFromPEM(ca) {
panic(fmt.Errorf("unable to parse CA file: %q", caFile))
}
tlsConfig.RootCAs = certPool
}
}
}
// LoginOptTLSClientConfigFromConfig returns a function that sets the TLS settings on login
// receiving the configuration in memory rather than from files.
func LoginOptTLSClientConfigFromConfig(conf *tls.Config) LoginOption {
return func(o *loginOperation) {
_, err := ensureTLSConfig(o.client.authorizer, conf)
if err != nil {
panic(err)
}
}
}
type (
// LogoutOption allows specifying various settings on logout
LogoutOption func(*logoutOperation)
logoutOperation struct{}
)
// Logout logs out of a registry
func (c *Client) Logout(host string, opts ...LogoutOption) error {
operation := &logoutOperation{}
for _, opt := range opts {
opt(operation)
}
if err := credentials.Logout(context.Background(), c.credentialsStore, host); err != nil {
return err
}
_, _ = fmt.Fprintf(c.out, "Removing login credentials for %s\n", host)
return nil
}
type (
// PullOption allows specifying various settings on pull
PullOption func(*pullOperation)
// PullResult is the result returned upon successful pull.
PullResult struct {
Manifest *DescriptorPullSummary `json:"manifest"`
Config *DescriptorPullSummary `json:"config"`
Chart *DescriptorPullSummaryWithMeta `json:"chart"`
Prov *DescriptorPullSummary `json:"prov"`
Ref string `json:"ref"`
}
DescriptorPullSummary struct {
Data []byte `json:"-"`
Digest string `json:"digest"`
Size int64 `json:"size"`
}
DescriptorPullSummaryWithMeta struct {
DescriptorPullSummary
Meta *chart.Metadata `json:"meta"`
}
pullOperation struct {
withChart bool
withProv bool
ignoreMissingProv bool
}
)
// processChartPull handles chart-specific processing of a generic pull result
func (c *Client) processChartPull(genericResult *GenericPullResult, operation *pullOperation) (*PullResult, error) {
var err error
// Chart-specific validation
minNumDescriptors := 1 // 1 for the config
if operation.withChart {
minNumDescriptors++
}
if operation.withProv && !operation.ignoreMissingProv {
minNumDescriptors++
}
numDescriptors := len(genericResult.Descriptors)
if numDescriptors < minNumDescriptors {
return nil, fmt.Errorf("manifest does not contain minimum number of descriptors (%d), descriptors found: %d",
minNumDescriptors, numDescriptors)
}
// Find chart-specific descriptors
var configDescriptor *ocispec.Descriptor
var chartDescriptor *ocispec.Descriptor
var provDescriptor *ocispec.Descriptor
for _, descriptor := range genericResult.Descriptors {
d := descriptor
switch d.MediaType {
case ConfigMediaType:
configDescriptor = &d
case ChartLayerMediaType:
chartDescriptor = &d
case ProvLayerMediaType:
provDescriptor = &d
case LegacyChartLayerMediaType:
chartDescriptor = &d
_, _ = fmt.Fprintf(c.out, "Warning: chart media type %s is deprecated\n", LegacyChartLayerMediaType)
}
}
// Chart-specific validation
if configDescriptor == nil {
return nil, fmt.Errorf("could not load config with mediatype %s", ConfigMediaType)
}
if operation.withChart && chartDescriptor == nil {
return nil, fmt.Errorf("manifest does not contain a layer with mediatype %s",
ChartLayerMediaType)
}
var provMissing bool
if operation.withProv && provDescriptor == nil {
if operation.ignoreMissingProv {
provMissing = true
} else {
return nil, fmt.Errorf("manifest does not contain a layer with mediatype %s",
ProvLayerMediaType)
}
}
// Build chart-specific result
result := &PullResult{
Manifest: &DescriptorPullSummary{
Digest: genericResult.Manifest.Digest.String(),
Size: genericResult.Manifest.Size,
},
Config: &DescriptorPullSummary{
Digest: configDescriptor.Digest.String(),
Size: configDescriptor.Size,
},
Chart: &DescriptorPullSummaryWithMeta{},
Prov: &DescriptorPullSummary{},
Ref: genericResult.Ref,
}
// Fetch data using generic client
genericClient := c.Generic()
result.Manifest.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, genericResult.Manifest)
if err != nil {
return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", genericResult.Manifest.Digest, err)
}
result.Config.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *configDescriptor)
if err != nil {
return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", configDescriptor.Digest, err)
}
if err := json.Unmarshal(result.Config.Data, &result.Chart.Meta); err != nil {
return nil, err
}
if operation.withChart {
result.Chart.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *chartDescriptor)
if err != nil {
return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", chartDescriptor.Digest, err)
}
result.Chart.Digest = chartDescriptor.Digest.String()
result.Chart.Size = chartDescriptor.Size
}
if operation.withProv && !provMissing {
result.Prov.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *provDescriptor)
if err != nil {
return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", provDescriptor.Digest, err)
}
result.Prov.Digest = provDescriptor.Digest.String()
result.Prov.Size = provDescriptor.Size
}
_, _ = fmt.Fprintf(c.out, "Pulled: %s\n", result.Ref)
_, _ = fmt.Fprintf(c.out, "Digest: %s\n", result.Manifest.Digest)
if strings.Contains(result.Ref, "_") {
_, _ = fmt.Fprintf(c.out, "%s contains an underscore.\n", result.Ref)
_, _ = fmt.Fprint(c.out, registryUnderscoreMessage+"\n")
}
return result, nil
}
// Pull downloads a chart from a registry
func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) {
operation := &pullOperation{
withChart: true, // By default, always download the chart layer
}
for _, option := range options {
option(operation)
}
if !operation.withChart && !operation.withProv {
return nil, errors.New(
"must specify at least one layer to pull (chart/prov)")
}
// Build allowed media types for chart pull
allowedMediaTypes := []string{
ocispec.MediaTypeImageIndex,
ocispec.MediaTypeImageManifest,
ConfigMediaType,
}
if operation.withChart {
allowedMediaTypes = append(allowedMediaTypes, ChartLayerMediaType, LegacyChartLayerMediaType)
}
if operation.withProv {
allowedMediaTypes = append(allowedMediaTypes, ProvLayerMediaType)
}
// Use generic client for the pull operation
genericClient := c.Generic()
genericResult, err := genericClient.PullGeneric(ref, GenericPullOptions{
AllowedMediaTypes: allowedMediaTypes,
})
if err != nil {
return nil, err
}
// Process the result with chart-specific logic
return c.processChartPull(genericResult, operation)
}
// PullOptWithChart returns a function that sets the withChart setting on pull
func PullOptWithChart(withChart bool) PullOption {
return func(operation *pullOperation) {
operation.withChart = withChart
}
}
// PullOptWithProv returns a function that sets the withProv setting on pull
func PullOptWithProv(withProv bool) PullOption {
return func(operation *pullOperation) {
operation.withProv = withProv
}
}
// PullOptIgnoreMissingProv returns a function that sets the ignoreMissingProv setting on pull
func PullOptIgnoreMissingProv(ignoreMissingProv bool) PullOption {
return func(operation *pullOperation) {
operation.ignoreMissingProv = ignoreMissingProv
}
}
type (
// PushOption allows specifying various settings on push
PushOption func(*pushOperation)
// PushResult is the result returned upon successful push.
PushResult struct {
Manifest *descriptorPushSummary `json:"manifest"`
Config *descriptorPushSummary `json:"config"`
Chart *descriptorPushSummaryWithMeta `json:"chart"`
Prov *descriptorPushSummary `json:"prov"`
Ref string `json:"ref"`
}
descriptorPushSummary struct {
Digest string `json:"digest"`
Size int64 `json:"size"`
}
descriptorPushSummaryWithMeta struct {
descriptorPushSummary
Meta *chart.Metadata `json:"meta"`
}
pushOperation struct {
provData []byte
strictMode bool
creationTime string
}
)
// Push uploads a chart to a registry.
func (c *Client) Push(data []byte, ref string, options ...PushOption) (*PushResult, error) {
parsedRef, err := newReference(ref)
if err != nil {
return nil, err
}
operation := &pushOperation{
strictMode: true, // By default, enable strict mode
}
for _, option := range options {
option(operation)
}
meta, err := extractChartMeta(data)
if err != nil {
return nil, err
}
if operation.strictMode {
if !strings.HasSuffix(ref, fmt.Sprintf("/%s:%s", meta.Name, meta.Version)) {
return nil, errors.New(
"strict mode enabled, ref basename and tag must match the chart name and version")
}
}
ctx := context.Background()
memoryStore := memory.New()
chartDescriptor, err := oras.PushBytes(ctx, memoryStore, ChartLayerMediaType, data)
if err != nil {
return nil, err
}
configData, err := json.Marshal(meta)
if err != nil {
return nil, err
}
configDescriptor, err := oras.PushBytes(ctx, memoryStore, ConfigMediaType, configData)
if err != nil {
return nil, err
}
layers := []ocispec.Descriptor{chartDescriptor}
var provDescriptor ocispec.Descriptor
if operation.provData != nil {
provDescriptor, err = oras.PushBytes(ctx, memoryStore, ProvLayerMediaType, operation.provData)
if err != nil {
return nil, err
}
layers = append(layers, provDescriptor)
}
// sort layers for determinism, similar to how ORAS v1 does it
sort.Slice(layers, func(i, j int) bool {
return layers[i].Digest < layers[j].Digest
})
ociAnnotations := generateOCIAnnotations(meta, operation.creationTime)
manifestDescriptor, err := c.tagManifest(ctx, memoryStore, configDescriptor,
layers, ociAnnotations, parsedRef)
if err != nil {
return nil, err
}
repository, err := remote.NewRepository(parsedRef.String())
if err != nil {
return nil, err
}
repository.PlainHTTP = c.plainHTTP
repository.Client = c.authorizer
manifestDescriptor, err = oras.ExtendedCopy(ctx, memoryStore, parsedRef.String(), repository, parsedRef.String(), oras.DefaultExtendedCopyOptions)
if err != nil {
return nil, err
}
chartSummary := &descriptorPushSummaryWithMeta{
Meta: meta,
}
chartSummary.Digest = chartDescriptor.Digest.String()
chartSummary.Size = chartDescriptor.Size
result := &PushResult{
Manifest: &descriptorPushSummary{
Digest: manifestDescriptor.Digest.String(),
Size: manifestDescriptor.Size,
},
Config: &descriptorPushSummary{
Digest: configDescriptor.Digest.String(),
Size: configDescriptor.Size,
},
Chart: chartSummary,
Prov: &descriptorPushSummary{}, // prevent nil references
Ref: parsedRef.String(),
}
if operation.provData != nil {
result.Prov = &descriptorPushSummary{
Digest: provDescriptor.Digest.String(),
Size: provDescriptor.Size,
}
}
_, _ = fmt.Fprintf(c.out, "Pushed: %s\n", result.Ref)
_, _ = fmt.Fprintf(c.out, "Digest: %s\n", result.Manifest.Digest)
if strings.Contains(parsedRef.orasReference.Reference, "_") {
_, _ = fmt.Fprintf(c.out, "%s contains an underscore.\n", result.Ref)
_, _ = fmt.Fprint(c.out, registryUnderscoreMessage+"\n")
}
return result, err
}
// PushOptProvData returns a function that sets the prov bytes setting on push
func PushOptProvData(provData []byte) PushOption {
return func(operation *pushOperation) {
operation.provData = provData
}
}
// PushOptStrictMode returns a function that sets the strictMode setting on push
func PushOptStrictMode(strictMode bool) PushOption {
return func(operation *pushOperation) {
operation.strictMode = strictMode
}
}
// PushOptCreationTime returns a function that sets the creation time
func PushOptCreationTime(creationTime string) PushOption {
return func(operation *pushOperation) {
operation.creationTime = creationTime
}
}
// Tags provides a sorted list all semver compliant tags for a given repository
func (c *Client) Tags(ref string) ([]string, error) {
parsedReference, err := registry.ParseReference(ref)
if err != nil {
return nil, err
}
ctx := context.Background()
repository, err := remote.NewRepository(parsedReference.String())
if err != nil {
return nil, err
}
repository.PlainHTTP = c.plainHTTP
repository.Client = c.authorizer
var tagVersions []*semver.Version
err = repository.Tags(ctx, "", func(tags []string) error {
for _, tag := range tags {
// Change underscore (_) back to plus (+) for Helm
// See https://github.com/helm/helm/issues/10166
tagVersion, err := semver.StrictNewVersion(strings.ReplaceAll(tag, "_", "+"))
if err == nil {
tagVersions = append(tagVersions, tagVersion)
}
}
return nil
})
if err != nil {
return nil, err
}
// Sort the collection
sort.Sort(sort.Reverse(semver.Collection(tagVersions)))
tags := make([]string, len(tagVersions))
for iTv, tv := range tagVersions {
tags[iTv] = tv.String()
}
return tags, nil
}
// Resolve a reference to a descriptor.
func (c *Client) Resolve(ref string) (desc ocispec.Descriptor, err error) {
remoteRepository, err := remote.NewRepository(ref)
if err != nil {
return desc, err
}
remoteRepository.PlainHTTP = c.plainHTTP
remoteRepository.Client = c.authorizer
parsedReference, err := newReference(ref)
if err != nil {
return desc, err
}
ctx := context.Background()
parsedString := parsedReference.String()
return remoteRepository.Resolve(ctx, parsedString)
}
// ValidateReference for path and version
func (c *Client) ValidateReference(ref, version string, u *url.URL) (string, *url.URL, error) {
var tag string
registryReference, err := newReference(u.Host + u.Path)
if err != nil {
return "", nil, err
}
if version == "" {
// Use OCI URI tag as default
version = registryReference.Tag
} else {
if registryReference.Tag != "" && registryReference.Tag != version {
return "", nil, fmt.Errorf("chart reference and version mismatch: %s is not %s", version, registryReference.Tag)
}
}
if registryReference.Digest != "" {
if version == "" {
// Install by digest only
return "", u, nil
}
u.Path = fmt.Sprintf("%s@%s", registryReference.Repository, registryReference.Digest)
// Validate the tag if it was specified
path := registryReference.Registry + "/" + registryReference.Repository + ":" + version
desc, err := c.Resolve(path)
if err != nil {
// The resource does not have to be tagged when digest is specified
return "", u, nil
}
if desc.Digest.String() != registryReference.Digest {
return "", nil, fmt.Errorf("chart reference digest mismatch: %s is not %s", desc.Digest.String(), registryReference.Digest)
}
return registryReference.Digest, u, nil
}
// Evaluate whether an explicit version has been provided. Otherwise, determine version to use
_, errSemVer := semver.NewVersion(version)
if errSemVer == nil {
tag = version
} else {
// Retrieve list of repository tags
tags, err := c.Tags(strings.TrimPrefix(ref, fmt.Sprintf("%s://", OCIScheme)))
if err != nil {
return "", nil, err
}
if len(tags) == 0 {
return "", nil, fmt.Errorf("unable to locate any tags in provided repository: %s", ref)
}
// Determine if version provided
// If empty, try to get the highest available tag
// If exact version, try to find it
// If semver constraint string, try to find a match
tag, err = GetTagMatchingVersionOrConstraint(tags, version)
if err != nil {
return "", nil, err
}
}
u.Path = fmt.Sprintf("%s:%s", registryReference.Repository, tag)
// desc, err := c.Resolve(u.Path)
return "", u, err
}
// 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,
ociAnnotations map[string]string, parsedRef reference) (ocispec.Descriptor, error) {
manifest := ocispec.Manifest{
Versioned: specs.Versioned{SchemaVersion: 2},
Config: configDescriptor,
Layers: layers,
Annotations: ociAnnotations,
}
manifestData, err := json.Marshal(manifest)
if err != nil {
return ocispec.Descriptor{}, err
}
return oras.TagBytes(ctx, memoryStore, ocispec.MediaTypeImageManifest,
manifestData, parsedRef.String())
}
+37
View File
@@ -0,0 +1,37 @@
/*
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 "helm.sh/helm/v4/pkg/registry"
const (
// OCIScheme is the URL scheme for OCI-based requests
OCIScheme = "oci"
// CredentialsFileBasename is the filename for auth credentials file
CredentialsFileBasename = "registry/config.json"
// ConfigMediaType is the reserved media type for the Helm chart manifest config
ConfigMediaType = "application/vnd.cncf.helm.config.v1+json"
// ChartLayerMediaType is the reserved media type for Helm chart package content
ChartLayerMediaType = "application/vnd.cncf.helm.chart.content.v1.tar+gzip"
// ProvLayerMediaType is the reserved media type for Helm chart provenance files
ProvLayerMediaType = "application/vnd.cncf.helm.chart.provenance.v1.prov"
// LegacyChartLayerMediaType is the legacy reserved media type for Helm chart package content.
LegacyChartLayerMediaType = "application/tar+gzip"
)
+161
View File
@@ -0,0 +1,161 @@
/*
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 (
"context"
"io"
"net/http"
"slices"
"sort"
"sync"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"oras.land/oras-go/v2"
"oras.land/oras-go/v2/content"
"oras.land/oras-go/v2/content/memory"
"oras.land/oras-go/v2/registry/remote"
"oras.land/oras-go/v2/registry/remote/auth"
"oras.land/oras-go/v2/registry/remote/credentials"
)
// GenericClient provides low-level OCI operations without artifact-specific assumptions
type GenericClient struct {
debug bool
enableCache bool
credentialsFile string
username string
password string
out io.Writer
authorizer *auth.Client
registryAuthorizer RemoteClient
credentialsStore credentials.Store
httpClient *http.Client
plainHTTP bool
}
// GenericPullOptions configures a generic pull operation
type GenericPullOptions struct {
// MediaTypes to include in the pull (empty means all)
AllowedMediaTypes []string
// Skip descriptors with these media types
SkipMediaTypes []string
// Custom PreCopy function for filtering
PreCopy func(context.Context, ocispec.Descriptor) error
}
// GenericPullResult contains the result of a generic pull operation
type GenericPullResult struct {
Manifest ocispec.Descriptor
Descriptors []ocispec.Descriptor
MemoryStore *memory.Store
Ref string
}
// NewGenericClient creates a new generic OCI client from an existing Client
func NewGenericClient(client *Client) *GenericClient {
return &GenericClient{
debug: client.debug,
enableCache: client.enableCache,
credentialsFile: client.credentialsFile,
username: client.username,
password: client.password,
out: client.out,
authorizer: client.authorizer,
registryAuthorizer: client.registryAuthorizer,
credentialsStore: client.credentialsStore,
httpClient: client.httpClient,
plainHTTP: client.plainHTTP,
}
}
// PullGeneric performs a generic OCI pull without artifact-specific assumptions
func (c *GenericClient) PullGeneric(ref string, options GenericPullOptions) (*GenericPullResult, error) {
parsedRef, err := newReference(ref)
if err != nil {
return nil, err
}
memoryStore := memory.New()
var descriptors []ocispec.Descriptor
// Set up a repository with authentication and configuration
repository, err := remote.NewRepository(parsedRef.String())
if err != nil {
return nil, err
}
repository.PlainHTTP = c.plainHTTP
repository.Client = c.authorizer
ctx := context.Background()
// Prepare allowed media types for filtering
var allowedMediaTypes []string
if len(options.AllowedMediaTypes) > 0 {
allowedMediaTypes = make([]string, len(options.AllowedMediaTypes))
copy(allowedMediaTypes, options.AllowedMediaTypes)
sort.Strings(allowedMediaTypes)
}
var mu sync.Mutex
manifest, err := oras.Copy(ctx, repository, parsedRef.String(), memoryStore, "", oras.CopyOptions{
CopyGraphOptions: oras.CopyGraphOptions{
PreCopy: func(ctx context.Context, desc ocispec.Descriptor) error {
// Apply a custom PreCopy function if provided
if options.PreCopy != nil {
if err := options.PreCopy(ctx, desc); err != nil {
return err
}
}
mediaType := desc.MediaType
// Skip media types if specified
if slices.Contains(options.SkipMediaTypes, mediaType) {
return oras.SkipNode
}
// Filter by allowed media types if specified
if len(allowedMediaTypes) > 0 {
if i := sort.SearchStrings(allowedMediaTypes, mediaType); i >= len(allowedMediaTypes) || allowedMediaTypes[i] != mediaType {
return oras.SkipNode
}
}
mu.Lock()
descriptors = append(descriptors, desc)
mu.Unlock()
return nil
},
},
})
if err != nil {
return nil, err
}
return &GenericPullResult{
Manifest: manifest,
Descriptors: descriptors,
MemoryStore: memoryStore,
Ref: parsedRef.String(),
}, nil
}
// GetDescriptorData retrieves the data for a specific descriptor
func (c *GenericClient) GetDescriptorData(store *memory.Store, desc ocispec.Descriptor) ([]byte, error) {
return content.FetchAll(context.Background(), store, desc)
}
+212
View File
@@ -0,0 +1,212 @@
/*
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 (
"encoding/json"
"fmt"
"strings"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)
// Plugin-specific constants
const (
// PluginArtifactType is the artifact type for Helm plugins
PluginArtifactType = "application/vnd.helm.plugin.v1+json"
)
// PluginPullOptions configures a plugin pull operation
type PluginPullOptions struct {
// PluginName specifies the expected plugin name for layer validation
PluginName string
}
// PluginPullResult contains the result of a plugin pull operation
type PluginPullResult struct {
Manifest ocispec.Descriptor
PluginData []byte
Prov struct {
Data []byte
}
Ref string
PluginName string
}
// PullPlugin downloads a plugin from an OCI registry using artifact type
func (c *Client) PullPlugin(ref string, pluginName string, options ...PluginPullOption) (*PluginPullResult, error) {
operation := &pluginPullOperation{
pluginName: pluginName,
}
for _, option := range options {
option(operation)
}
// Use generic client for the pull operation with artifact type filtering
genericClient := c.Generic()
genericResult, err := genericClient.PullGeneric(ref, GenericPullOptions{
// Allow manifests and all layer types - we'll validate artifact type after download
AllowedMediaTypes: []string{
ocispec.MediaTypeImageManifest,
"application/vnd.oci.image.layer.v1.tar",
"application/vnd.oci.image.layer.v1.tar+gzip",
},
})
if err != nil {
return nil, err
}
// Process the result with plugin-specific logic
return c.processPluginPull(genericResult, operation.pluginName)
}
// processPluginPull handles plugin-specific processing of a generic pull result using artifact type
func (c *Client) processPluginPull(genericResult *GenericPullResult, pluginName string) (*PluginPullResult, error) {
// First validate that this is actually a plugin artifact
manifestData, err := c.Generic().GetDescriptorData(genericResult.MemoryStore, genericResult.Manifest)
if err != nil {
return nil, fmt.Errorf("unable to retrieve manifest: %w", err)
}
// Parse the manifest to check artifact type
var manifest ocispec.Manifest
if err := json.Unmarshal(manifestData, &manifest); err != nil {
return nil, fmt.Errorf("unable to parse manifest: %w", err)
}
// Validate artifact type (for OCI v1.1+ manifests)
if manifest.ArtifactType != "" && manifest.ArtifactType != PluginArtifactType {
return nil, fmt.Errorf("expected artifact type %s, got %s", PluginArtifactType, manifest.ArtifactType)
}
// For backwards compatibility, also check config media type if no artifact type
if manifest.ArtifactType == "" && manifest.Config.MediaType != PluginArtifactType {
return nil, fmt.Errorf("expected config media type %s for legacy compatibility, got %s", PluginArtifactType, manifest.Config.MediaType)
}
// Find the plugin tarball and optional provenance using NAME-VERSION.tgz format
var pluginDescriptor *ocispec.Descriptor
var provenanceDescriptor *ocispec.Descriptor
var foundProvenanceName string
// Look for layers with the expected titles/annotations
for _, layer := range manifest.Layers {
d := layer
// Check for title annotation
if title, exists := d.Annotations[ocispec.AnnotationTitle]; exists {
// Check if this looks like a plugin tarball: {pluginName}-{version}.tgz
if pluginDescriptor == nil && strings.HasPrefix(title, pluginName+"-") && strings.HasSuffix(title, ".tgz") {
pluginDescriptor = &d
}
// Check if this looks like a plugin provenance: {pluginName}-{version}.tgz.prov
if provenanceDescriptor == nil && strings.HasPrefix(title, pluginName+"-") && strings.HasSuffix(title, ".tgz.prov") {
provenanceDescriptor = &d
foundProvenanceName = title
}
}
}
// Plugin tarball is required
if pluginDescriptor == nil {
return nil, fmt.Errorf("required layer matching pattern %s-VERSION.tgz not found in manifest", pluginName)
}
// Build plugin-specific result
result := &PluginPullResult{
Manifest: genericResult.Manifest,
Ref: genericResult.Ref,
PluginName: pluginName,
}
// Fetch plugin data using generic client
genericClient := c.Generic()
result.PluginData, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *pluginDescriptor)
if err != nil {
return nil, fmt.Errorf("unable to retrieve plugin data with digest %s: %w", pluginDescriptor.Digest, err)
}
// Fetch provenance data if available
if provenanceDescriptor != nil {
result.Prov.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *provenanceDescriptor)
if err != nil {
return nil, fmt.Errorf("unable to retrieve provenance data with digest %s: %w", provenanceDescriptor.Digest, err)
}
}
_, _ = fmt.Fprintf(c.out, "Pulled plugin: %s\n", result.Ref)
_, _ = fmt.Fprintf(c.out, "Digest: %s\n", result.Manifest.Digest)
if result.Prov.Data != nil {
_, _ = fmt.Fprintf(c.out, "Provenance: %s\n", foundProvenanceName)
}
if strings.Contains(result.Ref, "_") {
_, _ = fmt.Fprintf(c.out, "%s contains an underscore.\n", result.Ref)
_, _ = fmt.Fprint(c.out, registryUnderscoreMessage+"\n")
}
return result, nil
}
// Plugin pull operation types and options
type (
pluginPullOperation struct {
pluginName string
withProv bool
}
// PluginPullOption allows customizing plugin pull operations
PluginPullOption func(*pluginPullOperation)
)
// PluginPullOptWithPluginName sets the plugin name for validation
func PluginPullOptWithPluginName(name string) PluginPullOption {
return func(operation *pluginPullOperation) {
operation.pluginName = name
}
}
// GetPluginName extracts the plugin name from an OCI reference using proper reference parsing
func GetPluginName(source string) (string, error) {
ref, err := newReference(source)
if err != nil {
return "", fmt.Errorf("invalid OCI reference: %w", err)
}
// Extract plugin name from the repository path
// e.g., "ghcr.io/user/plugin-name:v1.0.0" -> Repository: "user/plugin-name"
repository := ref.Repository
if repository == "" {
return "", fmt.Errorf("invalid OCI reference: missing repository")
}
// Get the last part of the repository path as the plugin name
parts := strings.Split(repository, "/")
pluginName := parts[len(parts)-1]
if pluginName == "" {
return "", fmt.Errorf("invalid OCI reference: cannot determine plugin name from repository %s", repository)
}
return pluginName, nil
}
// PullPluginOptWithProv configures the pull to fetch provenance data
func PullPluginOptWithProv(withProv bool) PluginPullOption {
return func(operation *pluginPullOperation) {
operation.withProv = withProv
}
}
+84
View File
@@ -0,0 +1,84 @@
/*
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 (
"fmt"
"strings"
"oras.land/oras-go/v2/registry"
)
type reference struct {
orasReference registry.Reference
Registry string
Repository string
Tag string
Digest string
}
// newReference will parse and validate the reference, and clean tags when
// applicable tags are only cleaned when plus (+) signs are present and are
// converted to underscores (_) before pushing
// See https://github.com/helm/helm/issues/10166
func newReference(raw string) (result reference, err error) {
// Remove the oci:// prefix if it is there
raw = strings.TrimPrefix(raw, OCIScheme+"://")
// The sole possible reference modification is replacing plus (+) signs
// present in tags with underscores (_). To do this properly, we first
// need to identify a tag, and then pass it on to the reference parser
// NOTE: Passing immediately to the reference parser will fail since (+)
// signs are an invalid tag character, and simply replacing all plus (+)
// occurrences could invalidate other portions of the URI
lastIndex := strings.LastIndex(raw, "@")
if lastIndex >= 0 {
result.Digest = raw[(lastIndex + 1):]
raw = raw[:lastIndex]
}
parts := strings.Split(raw, ":")
if len(parts) > 1 && !strings.Contains(parts[len(parts)-1], "/") {
tag := parts[len(parts)-1]
if tag != "" {
// Replace any plus (+) signs with known underscore (_) conversion
newTag := strings.ReplaceAll(tag, "+", "_")
raw = strings.ReplaceAll(raw, tag, newTag)
}
}
result.orasReference, err = registry.ParseReference(raw)
if err != nil {
return result, err
}
result.Registry = result.orasReference.Registry
result.Repository = result.orasReference.Repository
result.Tag = result.orasReference.Reference
return result, nil
}
func (r *reference) String() string {
if r.Tag == "" {
return r.orasReference.String() + "@" + r.Digest
}
return r.orasReference.String()
}
// IsOCI determines whether a URL is to be treated as an OCI URL
func IsOCI(url string) bool {
return strings.HasPrefix(url, fmt.Sprintf("%s://", OCIScheme))
}
+59
View File
@@ -0,0 +1,59 @@
/*
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 "helm.sh/helm/v4/pkg/registry"
import (
"fmt"
"github.com/Masterminds/semver/v3"
)
func GetTagMatchingVersionOrConstraint(tags []string, versionString string) (string, error) {
var constraint *semver.Constraints
if versionString == "" {
// If the string is empty, set a wildcard constraint
constraint, _ = semver.NewConstraint("*")
} else {
// when customer inputs a specific version, check whether there's an exact match first
for _, v := range tags {
if versionString == v {
return v, nil
}
}
// Otherwise set constraint to the string given
var err error
constraint, err = semver.NewConstraint(versionString)
if err != nil {
return "", err
}
}
// Otherwise try to find the first available version matching the string,
// in case it is a constraint
for _, v := range tags {
test, err := semver.NewVersion(v)
if err != nil {
continue
}
if constraint.Check(test) {
return v, nil
}
}
return "", fmt.Errorf("could not locate a version matching provided version string %s", versionString)
}
+175
View File
@@ -0,0 +1,175 @@
/*
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"
"fmt"
"io"
"log/slog"
"mime"
"net/http"
"strings"
"sync/atomic"
"oras.land/oras-go/v2/registry/remote/retry"
)
var (
// requestCount records the number of logged request-response pairs and will
// be used as the unique id for the next pair.
requestCount atomic.Uint64
// toScrub is a set of headers that should be scrubbed from the log.
toScrub = []string{
"Authorization",
"Set-Cookie",
}
)
// payloadSizeLimit limits the maximum size of the response body to be printed.
const payloadSizeLimit int64 = 16 * 1024 // 16 KiB
// LoggingTransport is an http.RoundTripper that keeps track of the in-flight
// request and add hooks to report HTTP tracing events.
type LoggingTransport struct {
http.RoundTripper
}
// NewTransport creates and returns a new instance of LoggingTransport
func NewTransport(debug bool) *retry.Transport {
type cloner[T any] interface {
Clone() T
}
// try to copy (clone) the http.DefaultTransport so any mutations we
// perform on it (e.g. TLS config) are not reflected globally
// follow https://github.com/golang/go/issues/39299 for a more elegant
// solution in the future
transport := http.DefaultTransport
if t, ok := transport.(cloner[*http.Transport]); ok {
transport = t.Clone()
} else if t, ok := transport.(cloner[http.RoundTripper]); ok {
// this branch will not be used with go 1.20, it was added
// optimistically to try to clone if the http.DefaultTransport
// implementation changes, still the Clone method in that case
// might not return http.RoundTripper...
transport = t.Clone()
}
if debug {
transport = &LoggingTransport{RoundTripper: transport}
}
return retry.NewTransport(transport)
}
// RoundTrip calls base round trip while keeping track of the current request.
func (t *LoggingTransport) RoundTrip(req *http.Request) (resp *http.Response, err error) {
id := requestCount.Add(1) - 1
slog.Debug(req.Method, "id", id, "url", req.URL, "header", logHeader(req.Header))
resp, err = t.RoundTripper.RoundTrip(req)
if err != nil {
slog.Debug("Response"[:len(req.Method)], "id", id, "error", err)
} else if resp != nil {
slog.Debug("Response"[:len(req.Method)], "id", id, "status", resp.Status, "header", logHeader(resp.Header), "body", logResponseBody(resp))
} else {
slog.Debug("Response"[:len(req.Method)], "id", id, "response", "nil")
}
return resp, err
}
// logHeader prints out the provided header keys and values, with auth header scrubbed.
func logHeader(header http.Header) string {
if len(header) > 0 {
var headers []string
for k, v := range header {
for _, h := range toScrub {
if strings.EqualFold(k, h) {
v = []string{"*****"}
}
}
headers = append(headers, fmt.Sprintf(" %q: %q", k, strings.Join(v, ", ")))
}
return strings.Join(headers, "\n")
}
return " Empty header"
}
// logResponseBody prints out the response body if it is printable and within size limit.
func logResponseBody(resp *http.Response) string {
if resp.Body == nil || resp.Body == http.NoBody {
return " No response body to print"
}
// non-applicable body is not printed and remains untouched for subsequent processing
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
return " Response body without a content type is not printed"
}
if !isPrintableContentType(contentType) {
return fmt.Sprintf(" Response body of content type %q is not printed", contentType)
}
buf := bytes.NewBuffer(nil)
body := resp.Body
// restore the body by concatenating the read body with the remaining body
resp.Body = struct {
io.Reader
io.Closer
}{
Reader: io.MultiReader(buf, body),
Closer: body,
}
// read the body up to limit+1 to check if the body exceeds the limit
if _, err := io.CopyN(buf, body, payloadSizeLimit+1); err != nil && err != io.EOF {
return fmt.Sprintf(" Error reading response body: %v", err)
}
readBody := buf.String()
if len(readBody) == 0 {
return " Response body is empty"
}
if containsCredentials(readBody) {
return " Response body redacted due to potential credentials"
}
if len(readBody) > int(payloadSizeLimit) {
return readBody[:payloadSizeLimit] + "\n...(truncated)"
}
return readBody
}
// isPrintableContentType returns true if the contentType is printable.
func isPrintableContentType(contentType string) bool {
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
return false
}
switch mediaType {
case "application/json", // JSON types
"text/plain", "text/html": // text types
return true
}
return strings.HasSuffix(mediaType, "+json")
}
// containsCredentials returns true if the body contains potential credentials.
func containsCredentials(body string) bool {
return strings.Contains(body, `"token"`) || strings.Contains(body, `"access_token"`)
}