working commit
This commit is contained in:
+124
@@ -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
@@ -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())
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"`)
|
||||
}
|
||||
Reference in New Issue
Block a user