working commit
This commit is contained in:
+302
@@ -0,0 +1,302 @@
|
||||
/*
|
||||
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 cli describes the operating environment for the Helm CLI.
|
||||
|
||||
Helm's environment encapsulates all of the service dependencies Helm has.
|
||||
These dependencies are expressed as interfaces so that alternate implementations
|
||||
(mocks, etc.) can be easily generated.
|
||||
*/
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"k8s.io/cli-runtime/pkg/genericclioptions"
|
||||
"k8s.io/client-go/rest"
|
||||
|
||||
"helm.sh/helm/v4/internal/version"
|
||||
"helm.sh/helm/v4/pkg/helmpath"
|
||||
"helm.sh/helm/v4/pkg/kube"
|
||||
)
|
||||
|
||||
// defaultMaxHistory sets the maximum number of releases to 0: unlimited
|
||||
const defaultMaxHistory = 10
|
||||
|
||||
// defaultBurstLimit sets the default client-side throttling limit
|
||||
const defaultBurstLimit = 100
|
||||
|
||||
// defaultQPS sets the default QPS value to 0 to use library defaults unless specified
|
||||
const defaultQPS = float32(0)
|
||||
|
||||
// EnvSettings describes all of the environment settings.
|
||||
type EnvSettings struct {
|
||||
namespace string
|
||||
config *genericclioptions.ConfigFlags
|
||||
|
||||
// KubeConfig is the path to the kubeconfig file
|
||||
KubeConfig string
|
||||
// KubeContext is the name of the kubeconfig context.
|
||||
KubeContext string
|
||||
// Bearer KubeToken used for authentication
|
||||
KubeToken string
|
||||
// Username to impersonate for the operation
|
||||
KubeAsUser string
|
||||
// Groups to impersonate for the operation, multiple groups parsed from a comma delimited list
|
||||
KubeAsGroups []string
|
||||
// Kubernetes API Server Endpoint for authentication
|
||||
KubeAPIServer string
|
||||
// Custom certificate authority file.
|
||||
KubeCaFile string
|
||||
// KubeInsecureSkipTLSVerify indicates if server's certificate will not be checked for validity.
|
||||
// This makes the HTTPS connections insecure
|
||||
KubeInsecureSkipTLSVerify bool
|
||||
// KubeTLSServerName overrides the name to use for server certificate validation.
|
||||
// If it is not provided, the hostname used to contact the server is used
|
||||
KubeTLSServerName string
|
||||
// Debug indicates whether or not Helm is running in Debug mode.
|
||||
Debug bool
|
||||
// RegistryConfig is the path to the registry config file.
|
||||
RegistryConfig string
|
||||
// RepositoryConfig is the path to the repositories file.
|
||||
RepositoryConfig string
|
||||
// RepositoryCache is the path to the repository cache directory.
|
||||
RepositoryCache string
|
||||
// PluginsDirectory is the path to the plugins directory.
|
||||
PluginsDirectory string
|
||||
// MaxHistory is the max release history maintained.
|
||||
MaxHistory int
|
||||
// BurstLimit is the default client-side throttling limit.
|
||||
BurstLimit int
|
||||
// QPS is queries per second which may be used to avoid throttling.
|
||||
QPS float32
|
||||
// ColorMode controls colorized output (never, auto, always)
|
||||
ColorMode string
|
||||
// ContentCache is the location where cached charts are stored
|
||||
ContentCache string
|
||||
}
|
||||
|
||||
func New() *EnvSettings {
|
||||
env := &EnvSettings{
|
||||
namespace: os.Getenv("HELM_NAMESPACE"),
|
||||
MaxHistory: envIntOr("HELM_MAX_HISTORY", defaultMaxHistory),
|
||||
KubeConfig: os.Getenv("KUBECONFIG"),
|
||||
KubeContext: os.Getenv("HELM_KUBECONTEXT"),
|
||||
KubeToken: os.Getenv("HELM_KUBETOKEN"),
|
||||
KubeAsUser: os.Getenv("HELM_KUBEASUSER"),
|
||||
KubeAsGroups: envCSV("HELM_KUBEASGROUPS"),
|
||||
KubeAPIServer: os.Getenv("HELM_KUBEAPISERVER"),
|
||||
KubeCaFile: os.Getenv("HELM_KUBECAFILE"),
|
||||
KubeTLSServerName: os.Getenv("HELM_KUBETLS_SERVER_NAME"),
|
||||
KubeInsecureSkipTLSVerify: envBoolOr("HELM_KUBEINSECURE_SKIP_TLS_VERIFY", false),
|
||||
PluginsDirectory: envOr("HELM_PLUGINS", helmpath.DataPath("plugins")),
|
||||
RegistryConfig: envOr("HELM_REGISTRY_CONFIG", helmpath.ConfigPath("registry/config.json")),
|
||||
RepositoryConfig: envOr("HELM_REPOSITORY_CONFIG", helmpath.ConfigPath("repositories.yaml")),
|
||||
RepositoryCache: envOr("HELM_REPOSITORY_CACHE", helmpath.CachePath("repository")),
|
||||
ContentCache: envOr("HELM_CONTENT_CACHE", helmpath.CachePath("content")),
|
||||
BurstLimit: envIntOr("HELM_BURST_LIMIT", defaultBurstLimit),
|
||||
QPS: envFloat32Or("HELM_QPS", defaultQPS),
|
||||
ColorMode: envColorMode(),
|
||||
}
|
||||
env.Debug, _ = strconv.ParseBool(os.Getenv("HELM_DEBUG"))
|
||||
|
||||
// bind to kubernetes config flags
|
||||
config := &genericclioptions.ConfigFlags{
|
||||
Namespace: &env.namespace,
|
||||
Context: &env.KubeContext,
|
||||
BearerToken: &env.KubeToken,
|
||||
APIServer: &env.KubeAPIServer,
|
||||
CAFile: &env.KubeCaFile,
|
||||
KubeConfig: &env.KubeConfig,
|
||||
Impersonate: &env.KubeAsUser,
|
||||
Insecure: &env.KubeInsecureSkipTLSVerify,
|
||||
TLSServerName: &env.KubeTLSServerName,
|
||||
ImpersonateGroup: &env.KubeAsGroups,
|
||||
WrapConfigFn: func(config *rest.Config) *rest.Config {
|
||||
config.Burst = env.BurstLimit
|
||||
config.QPS = env.QPS
|
||||
config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
|
||||
return &kube.RetryingRoundTripper{Wrapped: rt}
|
||||
})
|
||||
config.UserAgent = version.GetUserAgent()
|
||||
return config
|
||||
},
|
||||
}
|
||||
if env.BurstLimit != defaultBurstLimit {
|
||||
config = config.WithDiscoveryBurst(env.BurstLimit)
|
||||
}
|
||||
env.config = config
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
// AddFlags binds flags to the given flagset.
|
||||
func (s *EnvSettings) AddFlags(fs *pflag.FlagSet) {
|
||||
fs.StringVarP(&s.namespace, "namespace", "n", s.namespace, "namespace scope for this request")
|
||||
fs.StringVar(&s.KubeConfig, "kubeconfig", "", "path to the kubeconfig file")
|
||||
fs.StringVar(&s.KubeContext, "kube-context", s.KubeContext, "name of the kubeconfig context to use")
|
||||
fs.StringVar(&s.KubeToken, "kube-token", s.KubeToken, "bearer token used for authentication")
|
||||
fs.StringVar(&s.KubeAsUser, "kube-as-user", s.KubeAsUser, "username to impersonate for the operation")
|
||||
fs.StringArrayVar(&s.KubeAsGroups, "kube-as-group", s.KubeAsGroups, "group to impersonate for the operation, this flag can be repeated to specify multiple groups.")
|
||||
fs.StringVar(&s.KubeAPIServer, "kube-apiserver", s.KubeAPIServer, "the address and the port for the Kubernetes API server")
|
||||
fs.StringVar(&s.KubeCaFile, "kube-ca-file", s.KubeCaFile, "the certificate authority file for the Kubernetes API server connection")
|
||||
fs.StringVar(&s.KubeTLSServerName, "kube-tls-server-name", s.KubeTLSServerName, "server name to use for Kubernetes API server certificate validation. If it is not provided, the hostname used to contact the server is used")
|
||||
fs.BoolVar(&s.KubeInsecureSkipTLSVerify, "kube-insecure-skip-tls-verify", s.KubeInsecureSkipTLSVerify, "if true, the Kubernetes API server's certificate will not be checked for validity. This will make your HTTPS connections insecure")
|
||||
fs.BoolVar(&s.Debug, "debug", s.Debug, "enable verbose output")
|
||||
fs.StringVar(&s.RegistryConfig, "registry-config", s.RegistryConfig, "path to the registry config file")
|
||||
fs.StringVar(&s.RepositoryConfig, "repository-config", s.RepositoryConfig, "path to the file containing repository names and URLs")
|
||||
fs.StringVar(&s.RepositoryCache, "repository-cache", s.RepositoryCache, "path to the directory containing cached repository indexes")
|
||||
fs.StringVar(&s.ContentCache, "content-cache", s.ContentCache, "path to the directory containing cached content (e.g. charts)")
|
||||
fs.IntVar(&s.BurstLimit, "burst-limit", s.BurstLimit, "client-side default throttling limit")
|
||||
fs.Float32Var(&s.QPS, "qps", s.QPS, "queries per second used when communicating with the Kubernetes API, not including bursting")
|
||||
fs.StringVar(&s.ColorMode, "color", s.ColorMode, "use colored output (never, auto, always)")
|
||||
fs.StringVar(&s.ColorMode, "colour", s.ColorMode, "use colored output (never, auto, always)")
|
||||
}
|
||||
|
||||
func envOr(name, def string) string {
|
||||
if v, ok := os.LookupEnv(name); ok {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envBoolOr(name string, def bool) bool {
|
||||
if name == "" {
|
||||
return def
|
||||
}
|
||||
envVal := envOr(name, strconv.FormatBool(def))
|
||||
ret, err := strconv.ParseBool(envVal)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func envIntOr(name string, def int) int {
|
||||
if name == "" {
|
||||
return def
|
||||
}
|
||||
envVal := envOr(name, strconv.Itoa(def))
|
||||
ret, err := strconv.Atoi(envVal)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func envFloat32Or(name string, def float32) float32 {
|
||||
if name == "" {
|
||||
return def
|
||||
}
|
||||
envVal := envOr(name, strconv.FormatFloat(float64(def), 'f', 2, 32))
|
||||
ret, err := strconv.ParseFloat(envVal, 32)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return float32(ret)
|
||||
}
|
||||
|
||||
func envCSV(name string) (ls []string) {
|
||||
trimmed := strings.Trim(os.Getenv(name), ", ")
|
||||
if trimmed != "" {
|
||||
ls = strings.Split(trimmed, ",")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func envColorMode() string {
|
||||
// Check NO_COLOR environment variable first (standard)
|
||||
if v, ok := os.LookupEnv("NO_COLOR"); ok && v != "" {
|
||||
return "never"
|
||||
}
|
||||
// Check HELM_COLOR environment variable
|
||||
if v, ok := os.LookupEnv("HELM_COLOR"); ok {
|
||||
v = strings.ToLower(v)
|
||||
switch v {
|
||||
case "never", "auto", "always":
|
||||
return v
|
||||
}
|
||||
}
|
||||
// Default to auto
|
||||
return "auto"
|
||||
}
|
||||
|
||||
func (s *EnvSettings) EnvVars() map[string]string {
|
||||
envvars := map[string]string{
|
||||
"HELM_BIN": os.Args[0],
|
||||
"HELM_CACHE_HOME": helmpath.CachePath(""),
|
||||
"HELM_CONFIG_HOME": helmpath.ConfigPath(""),
|
||||
"HELM_DATA_HOME": helmpath.DataPath(""),
|
||||
"HELM_DEBUG": fmt.Sprint(s.Debug),
|
||||
"HELM_PLUGINS": s.PluginsDirectory,
|
||||
"HELM_REGISTRY_CONFIG": s.RegistryConfig,
|
||||
"HELM_REPOSITORY_CACHE": s.RepositoryCache,
|
||||
"HELM_CONTENT_CACHE": s.ContentCache,
|
||||
"HELM_REPOSITORY_CONFIG": s.RepositoryConfig,
|
||||
"HELM_NAMESPACE": s.Namespace(),
|
||||
"HELM_MAX_HISTORY": strconv.Itoa(s.MaxHistory),
|
||||
"HELM_BURST_LIMIT": strconv.Itoa(s.BurstLimit),
|
||||
"HELM_QPS": strconv.FormatFloat(float64(s.QPS), 'f', 2, 32),
|
||||
|
||||
// broken, these are populated from helm flags and not kubeconfig.
|
||||
"HELM_KUBECONTEXT": s.KubeContext,
|
||||
"HELM_KUBETOKEN": s.KubeToken,
|
||||
"HELM_KUBEASUSER": s.KubeAsUser,
|
||||
"HELM_KUBEASGROUPS": strings.Join(s.KubeAsGroups, ","),
|
||||
"HELM_KUBEAPISERVER": s.KubeAPIServer,
|
||||
"HELM_KUBECAFILE": s.KubeCaFile,
|
||||
"HELM_KUBEINSECURE_SKIP_TLS_VERIFY": strconv.FormatBool(s.KubeInsecureSkipTLSVerify),
|
||||
"HELM_KUBETLS_SERVER_NAME": s.KubeTLSServerName,
|
||||
}
|
||||
if s.KubeConfig != "" {
|
||||
envvars["KUBECONFIG"] = s.KubeConfig
|
||||
}
|
||||
return envvars
|
||||
}
|
||||
|
||||
// Namespace gets the namespace from the configuration
|
||||
func (s *EnvSettings) Namespace() string {
|
||||
if s.config != nil {
|
||||
if ns, _, err := s.config.ToRawKubeConfigLoader().Namespace(); err == nil {
|
||||
return ns
|
||||
}
|
||||
}
|
||||
if s.namespace != "" {
|
||||
return s.namespace
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
// SetNamespace sets the namespace in the configuration
|
||||
func (s *EnvSettings) SetNamespace(namespace string) {
|
||||
s.namespace = namespace
|
||||
}
|
||||
|
||||
// RESTClientGetter gets the kubeconfig from EnvSettings
|
||||
func (s *EnvSettings) RESTClientGetter() genericclioptions.RESTClientGetter {
|
||||
return s.config
|
||||
}
|
||||
|
||||
// ShouldDisableColor returns true if color output should be disabled
|
||||
func (s *EnvSettings) ShouldDisableColor() bool {
|
||||
return s.ColorMode == "never"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
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 getter provides a generalize tool for fetching data by scheme.
|
||||
|
||||
This provides a method by which the plugin system can load arbitrary protocol
|
||||
handlers based upon a URL scheme.
|
||||
*/
|
||||
package getter
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
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 getter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"helm.sh/helm/v4/pkg/cli"
|
||||
"helm.sh/helm/v4/pkg/registry"
|
||||
)
|
||||
|
||||
// getterOptions are generic parameters to be provided to the getter during instantiation.
|
||||
//
|
||||
// Getters may or may not ignore these parameters as they are passed in.
|
||||
// TODO what is the difference between this and schema.GetterOptionsV1?
|
||||
type getterOptions struct {
|
||||
url string
|
||||
certFile string
|
||||
keyFile string
|
||||
caFile string
|
||||
unTar bool
|
||||
insecureSkipVerifyTLS bool
|
||||
plainHTTP bool
|
||||
acceptHeader string
|
||||
username string
|
||||
password string
|
||||
passCredentialsAll bool
|
||||
userAgent string
|
||||
version string
|
||||
registryClient *registry.Client
|
||||
timeout time.Duration
|
||||
transport *http.Transport
|
||||
artifactType string
|
||||
}
|
||||
|
||||
// Option allows specifying various settings configurable by the user for overriding the defaults
|
||||
// used when performing Get operations with the Getter.
|
||||
type Option func(*getterOptions)
|
||||
|
||||
// WithURL informs the getter the server name that will be used when fetching objects. Used in conjunction with
|
||||
// WithTLSClientConfig to set the TLSClientConfig's server name.
|
||||
func WithURL(url string) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.url = url
|
||||
}
|
||||
}
|
||||
|
||||
// WithAcceptHeader sets the request's Accept header as some REST APIs serve multiple content types
|
||||
func WithAcceptHeader(header string) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.acceptHeader = header
|
||||
}
|
||||
}
|
||||
|
||||
// WithBasicAuth sets the request's Authorization header to use the provided credentials
|
||||
func WithBasicAuth(username, password string) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.username = username
|
||||
opts.password = password
|
||||
}
|
||||
}
|
||||
|
||||
func WithPassCredentialsAll(pass bool) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.passCredentialsAll = pass
|
||||
}
|
||||
}
|
||||
|
||||
// WithUserAgent sets the request's User-Agent header to use the provided agent name.
|
||||
func WithUserAgent(userAgent string) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.userAgent = userAgent
|
||||
}
|
||||
}
|
||||
|
||||
// WithInsecureSkipVerifyTLS determines if a TLS Certificate will be checked
|
||||
func WithInsecureSkipVerifyTLS(insecureSkipVerifyTLS bool) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.insecureSkipVerifyTLS = insecureSkipVerifyTLS
|
||||
}
|
||||
}
|
||||
|
||||
// WithTLSClientConfig sets the client auth with the provided credentials.
|
||||
func WithTLSClientConfig(certFile, keyFile, caFile string) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.certFile = certFile
|
||||
opts.keyFile = keyFile
|
||||
opts.caFile = caFile
|
||||
}
|
||||
}
|
||||
|
||||
func WithPlainHTTP(plainHTTP bool) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.plainHTTP = plainHTTP
|
||||
}
|
||||
}
|
||||
|
||||
// WithTimeout sets the timeout for requests
|
||||
func WithTimeout(timeout time.Duration) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.timeout = timeout
|
||||
}
|
||||
}
|
||||
|
||||
func WithTagName(tagname string) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.version = tagname
|
||||
}
|
||||
}
|
||||
|
||||
func WithRegistryClient(client *registry.Client) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.registryClient = client
|
||||
}
|
||||
}
|
||||
|
||||
func WithUntar() Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.unTar = true
|
||||
}
|
||||
}
|
||||
|
||||
// WithTransport sets the http.Transport to allow overwriting the HTTPGetter default.
|
||||
func WithTransport(transport *http.Transport) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.transport = transport
|
||||
}
|
||||
}
|
||||
|
||||
// WithArtifactType sets the type of OCI artifact ("chart" or "plugin")
|
||||
func WithArtifactType(artifactType string) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.artifactType = artifactType
|
||||
}
|
||||
}
|
||||
|
||||
// Getter is an interface to support GET to the specified URL.
|
||||
type Getter interface {
|
||||
// Get file content by url string
|
||||
Get(url string, options ...Option) (*bytes.Buffer, error)
|
||||
}
|
||||
|
||||
// Constructor is the function for every getter which creates a specific instance
|
||||
// according to the configuration
|
||||
type Constructor func(options ...Option) (Getter, error)
|
||||
|
||||
// Provider represents any getter and the schemes that it supports.
|
||||
//
|
||||
// For example, an HTTP provider may provide one getter that handles both
|
||||
// 'http' and 'https' schemes.
|
||||
type Provider struct {
|
||||
Schemes []string
|
||||
New Constructor
|
||||
}
|
||||
|
||||
// Provides returns true if the given scheme is supported by this Provider.
|
||||
func (p Provider) Provides(scheme string) bool {
|
||||
return slices.Contains(p.Schemes, scheme)
|
||||
}
|
||||
|
||||
// Providers is a collection of Provider objects.
|
||||
type Providers []Provider
|
||||
|
||||
// ByScheme returns a Provider that handles the given scheme.
|
||||
//
|
||||
// If no provider handles this scheme, this will return an error.
|
||||
func (p Providers) ByScheme(scheme string) (Getter, error) {
|
||||
for _, pp := range p {
|
||||
if pp.Provides(scheme) {
|
||||
return pp.New()
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("scheme %q not supported", scheme)
|
||||
}
|
||||
|
||||
const (
|
||||
// The cost timeout references curl's default connection timeout.
|
||||
// https://github.com/curl/curl/blob/master/lib/connect.h#L40C21-L40C21
|
||||
// The helm commands are usually executed manually. Considering the acceptable waiting time, we reduced the entire request time to 120s.
|
||||
DefaultHTTPTimeout = 120
|
||||
)
|
||||
|
||||
var defaultOptions = []Option{WithTimeout(time.Second * DefaultHTTPTimeout)}
|
||||
|
||||
func Getters(extraOpts ...Option) Providers {
|
||||
return Providers{
|
||||
Provider{
|
||||
Schemes: []string{"http", "https"},
|
||||
New: func(options ...Option) (Getter, error) {
|
||||
options = append(options, defaultOptions...)
|
||||
options = append(options, extraOpts...)
|
||||
return NewHTTPGetter(options...)
|
||||
},
|
||||
},
|
||||
Provider{
|
||||
Schemes: []string{registry.OCIScheme},
|
||||
New: func(options ...Option) (Getter, error) {
|
||||
options = append(options, defaultOptions...)
|
||||
options = append(options, extraOpts...)
|
||||
return NewOCIGetter(options...)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// All finds all of the registered getters as a list of Provider instances.
|
||||
// Currently, the built-in getters and the discovered plugins with downloader
|
||||
// notations are collected.
|
||||
func All(settings *cli.EnvSettings, opts ...Option) Providers {
|
||||
result := Getters(opts...)
|
||||
pluginDownloaders, _ := collectGetterPlugins(settings)
|
||||
result = append(result, pluginDownloaders...)
|
||||
return result
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
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 getter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
|
||||
"helm.sh/helm/v4/internal/tlsutil"
|
||||
"helm.sh/helm/v4/internal/version"
|
||||
)
|
||||
|
||||
// HTTPGetter is the default HTTP(/S) backend handler
|
||||
type HTTPGetter struct {
|
||||
opts getterOptions
|
||||
transport *http.Transport
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// Get performs a Get from repo.Getter and returns the body.
|
||||
func (g *HTTPGetter) Get(href string, options ...Option) (*bytes.Buffer, error) {
|
||||
for _, opt := range options {
|
||||
opt(&g.opts)
|
||||
}
|
||||
return g.get(href)
|
||||
}
|
||||
|
||||
func (g *HTTPGetter) get(href string) (*bytes.Buffer, error) {
|
||||
// Set a helm specific user agent so that a repo server and metrics can
|
||||
// separate helm calls from other tools interacting with repos.
|
||||
req, err := http.NewRequest(http.MethodGet, href, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if g.opts.acceptHeader != "" {
|
||||
req.Header.Set("Accept", g.opts.acceptHeader)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", version.GetUserAgent())
|
||||
if g.opts.userAgent != "" {
|
||||
req.Header.Set("User-Agent", g.opts.userAgent)
|
||||
}
|
||||
|
||||
// Before setting the basic auth credentials, make sure the URL associated
|
||||
// with the basic auth is the one being fetched.
|
||||
u1, err := url.Parse(g.opts.url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse getter URL: %w", err)
|
||||
}
|
||||
u2, err := url.Parse(href)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse URL getting from: %w", err)
|
||||
}
|
||||
|
||||
// Host on URL (returned from url.Parse) contains the port if present.
|
||||
// This check ensures credentials are not passed between different
|
||||
// services on different ports.
|
||||
if g.opts.passCredentialsAll || (u1.Scheme == u2.Scheme && u1.Host == u2.Host) {
|
||||
if g.opts.username != "" && g.opts.password != "" {
|
||||
req.SetBasicAuth(g.opts.username, g.opts.password)
|
||||
}
|
||||
}
|
||||
|
||||
client, err := g.httpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to fetch %s : %s", href, resp.Status)
|
||||
}
|
||||
|
||||
buf := bytes.NewBuffer(nil)
|
||||
_, err = io.Copy(buf, resp.Body)
|
||||
return buf, err
|
||||
}
|
||||
|
||||
// NewHTTPGetter constructs a valid http/https client as a Getter
|
||||
func NewHTTPGetter(options ...Option) (Getter, error) {
|
||||
var client HTTPGetter
|
||||
|
||||
for _, opt := range options {
|
||||
opt(&client.opts)
|
||||
}
|
||||
|
||||
return &client, nil
|
||||
}
|
||||
|
||||
func (g *HTTPGetter) httpClient() (*http.Client, error) {
|
||||
if g.opts.transport != nil {
|
||||
return &http.Client{
|
||||
Transport: g.opts.transport,
|
||||
Timeout: g.opts.timeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
g.once.Do(func() {
|
||||
g.transport = &http.Transport{
|
||||
DisableCompression: true,
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
// Being nil would cause the tls.Config default to be used
|
||||
// "NewTLSConfig" modifies an empty TLS config, not the default one
|
||||
TLSClientConfig: &tls.Config{},
|
||||
}
|
||||
})
|
||||
|
||||
if (g.opts.certFile != "" && g.opts.keyFile != "") || g.opts.caFile != "" || g.opts.insecureSkipVerifyTLS {
|
||||
tlsConf, err := tlsutil.NewTLSConfig(
|
||||
tlsutil.WithInsecureSkipVerify(g.opts.insecureSkipVerifyTLS),
|
||||
tlsutil.WithCertKeyPairFiles(g.opts.certFile, g.opts.keyFile),
|
||||
tlsutil.WithCAFile(g.opts.caFile),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't create TLS config for client: %w", err)
|
||||
}
|
||||
|
||||
g.transport.TLSClientConfig = tlsConf
|
||||
}
|
||||
|
||||
if g.opts.insecureSkipVerifyTLS {
|
||||
if g.transport.TLSClientConfig == nil {
|
||||
g.transport.TLSClientConfig = &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
} else {
|
||||
g.transport.TLSClientConfig.InsecureSkipVerify = true
|
||||
}
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: g.transport,
|
||||
Timeout: g.opts.timeout,
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
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 getter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"helm.sh/helm/v4/internal/tlsutil"
|
||||
"helm.sh/helm/v4/internal/urlutil"
|
||||
"helm.sh/helm/v4/pkg/registry"
|
||||
)
|
||||
|
||||
// OCIGetter is the default HTTP(/S) backend handler
|
||||
type OCIGetter struct {
|
||||
opts getterOptions
|
||||
transport *http.Transport
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// Get performs a Get from repo.Getter and returns the body.
|
||||
func (g *OCIGetter) Get(href string, options ...Option) (*bytes.Buffer, error) {
|
||||
for _, opt := range options {
|
||||
opt(&g.opts)
|
||||
}
|
||||
return g.get(href)
|
||||
}
|
||||
|
||||
func (g *OCIGetter) get(href string) (*bytes.Buffer, error) {
|
||||
client := g.opts.registryClient
|
||||
// if the user has already provided a configured registry client, use it,
|
||||
// this is particularly true when user has his own way of handling the client credentials.
|
||||
if client == nil {
|
||||
c, err := g.newRegistryClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client = c
|
||||
}
|
||||
|
||||
ref := strings.TrimPrefix(href, fmt.Sprintf("%s://", registry.OCIScheme))
|
||||
|
||||
if version := g.opts.version; version != "" && !strings.Contains(path.Base(ref), ":") {
|
||||
ref = fmt.Sprintf("%s:%s", ref, version)
|
||||
}
|
||||
// Check if this is a plugin request
|
||||
if g.opts.artifactType == "plugin" {
|
||||
return g.getPlugin(client, ref)
|
||||
}
|
||||
|
||||
// Default to chart behavior for backward compatibility
|
||||
var pullOpts []registry.PullOption
|
||||
requestingProv := strings.HasSuffix(ref, ".prov")
|
||||
if requestingProv {
|
||||
ref = strings.TrimSuffix(ref, ".prov")
|
||||
pullOpts = append(pullOpts,
|
||||
registry.PullOptWithChart(false),
|
||||
registry.PullOptWithProv(true))
|
||||
}
|
||||
|
||||
result, err := client.Pull(ref, pullOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if requestingProv {
|
||||
return bytes.NewBuffer(result.Prov.Data), nil
|
||||
}
|
||||
return bytes.NewBuffer(result.Chart.Data), nil
|
||||
}
|
||||
|
||||
// NewOCIGetter constructs a valid http/https client as a Getter
|
||||
func NewOCIGetter(ops ...Option) (Getter, error) {
|
||||
var client OCIGetter
|
||||
|
||||
for _, opt := range ops {
|
||||
opt(&client.opts)
|
||||
}
|
||||
|
||||
return &client, nil
|
||||
}
|
||||
|
||||
func (g *OCIGetter) newRegistryClient() (*registry.Client, error) {
|
||||
if g.opts.transport != nil {
|
||||
client, err := registry.NewClient(
|
||||
registry.ClientOptHTTPClient(&http.Client{
|
||||
Transport: g.opts.transport,
|
||||
Timeout: g.opts.timeout,
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
g.once.Do(func() {
|
||||
g.transport = &http.Transport{
|
||||
// From https://github.com/google/go-containerregistry/blob/31786c6cbb82d6ec4fb8eb79cd9387905130534e/pkg/v1/remote/options.go#L87
|
||||
DisableCompression: true,
|
||||
DialContext: (&net.Dialer{
|
||||
// By default we wrap the transport in retries, so reduce the
|
||||
// default dial timeout to 5s to avoid 5x 30s of connection
|
||||
// timeouts when doing the "ping" on certain http registries.
|
||||
Timeout: 5 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
// Being nil would cause the tls.Config default to be used
|
||||
// "NewTLSConfig" modifies an empty TLS config, not the default one
|
||||
TLSClientConfig: &tls.Config{},
|
||||
}
|
||||
})
|
||||
|
||||
if (g.opts.certFile != "" && g.opts.keyFile != "") || g.opts.caFile != "" || g.opts.insecureSkipVerifyTLS {
|
||||
tlsConf, err := tlsutil.NewTLSConfig(
|
||||
tlsutil.WithInsecureSkipVerify(g.opts.insecureSkipVerifyTLS),
|
||||
tlsutil.WithCertKeyPairFiles(g.opts.certFile, g.opts.keyFile),
|
||||
tlsutil.WithCAFile(g.opts.caFile),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't create TLS config for client: %w", err)
|
||||
}
|
||||
|
||||
sni, err := urlutil.ExtractHostname(g.opts.url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsConf.ServerName = sni
|
||||
|
||||
g.transport.TLSClientConfig = tlsConf
|
||||
}
|
||||
|
||||
opts := []registry.ClientOption{registry.ClientOptHTTPClient(&http.Client{
|
||||
Transport: g.transport,
|
||||
Timeout: g.opts.timeout,
|
||||
})}
|
||||
if g.opts.plainHTTP {
|
||||
opts = append(opts, registry.ClientOptPlainHTTP())
|
||||
}
|
||||
|
||||
client, err := registry.NewClient(opts...)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// getPlugin handles plugin-specific OCI pulls
|
||||
func (g *OCIGetter) getPlugin(client *registry.Client, ref string) (*bytes.Buffer, error) {
|
||||
// Check if this is a provenance file request
|
||||
requestingProv := strings.HasSuffix(ref, ".prov")
|
||||
if requestingProv {
|
||||
ref = strings.TrimSuffix(ref, ".prov")
|
||||
}
|
||||
|
||||
// Extract plugin name from the reference
|
||||
// e.g., "ghcr.io/user/plugin-name:v1.0.0" -> "plugin-name"
|
||||
parts := strings.Split(ref, "/")
|
||||
if len(parts) < 2 {
|
||||
return nil, fmt.Errorf("invalid OCI reference: %s", ref)
|
||||
}
|
||||
lastPart := parts[len(parts)-1]
|
||||
pluginName := lastPart
|
||||
if idx := strings.LastIndex(lastPart, ":"); idx > 0 {
|
||||
pluginName = lastPart[:idx]
|
||||
}
|
||||
if idx := strings.LastIndex(lastPart, "@"); idx > 0 {
|
||||
pluginName = lastPart[:idx]
|
||||
}
|
||||
|
||||
var pullOpts []registry.PluginPullOption
|
||||
if requestingProv {
|
||||
pullOpts = append(pullOpts, registry.PullPluginOptWithProv(true))
|
||||
}
|
||||
|
||||
result, err := client.PullPlugin(ref, pluginName, pullOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if requestingProv {
|
||||
return bytes.NewBuffer(result.Prov.Data), nil
|
||||
}
|
||||
return bytes.NewBuffer(result.PluginData), nil
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
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 getter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"net/url"
|
||||
|
||||
"helm.sh/helm/v4/internal/plugin"
|
||||
|
||||
"helm.sh/helm/v4/internal/plugin/schema"
|
||||
"helm.sh/helm/v4/pkg/cli"
|
||||
)
|
||||
|
||||
// collectGetterPlugins scans for getter plugins.
|
||||
// This will load plugins according to the cli.
|
||||
func collectGetterPlugins(settings *cli.EnvSettings) (Providers, error) {
|
||||
d := plugin.Descriptor{
|
||||
Type: "getter/v1",
|
||||
}
|
||||
plgs, err := plugin.FindPlugins([]string{settings.PluginsDirectory}, d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env := plugin.FormatEnv(settings.EnvVars())
|
||||
pluginConstructorBuilder := func(plg plugin.Plugin) Constructor {
|
||||
return func(option ...Option) (Getter, error) {
|
||||
|
||||
return &getterPlugin{
|
||||
options: append([]Option{}, option...),
|
||||
plg: plg,
|
||||
env: env,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
results := make([]Provider, 0, len(plgs))
|
||||
for _, plg := range plgs {
|
||||
if c, ok := plg.Metadata().Config.(*schema.ConfigGetterV1); ok {
|
||||
results = append(results, Provider{
|
||||
Schemes: c.Protocols,
|
||||
New: pluginConstructorBuilder(plg),
|
||||
})
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func convertOptions(globalOptions, options []Option) schema.GetterOptionsV1 {
|
||||
opts := getterOptions{}
|
||||
for _, opt := range globalOptions {
|
||||
opt(&opts)
|
||||
}
|
||||
for _, opt := range options {
|
||||
opt(&opts)
|
||||
}
|
||||
|
||||
result := schema.GetterOptionsV1{
|
||||
URL: opts.url,
|
||||
CertFile: opts.certFile,
|
||||
KeyFile: opts.keyFile,
|
||||
CAFile: opts.caFile,
|
||||
UNTar: opts.unTar,
|
||||
InsecureSkipVerifyTLS: opts.insecureSkipVerifyTLS,
|
||||
PlainHTTP: opts.plainHTTP,
|
||||
AcceptHeader: opts.acceptHeader,
|
||||
Username: opts.username,
|
||||
Password: opts.password,
|
||||
PassCredentialsAll: opts.passCredentialsAll,
|
||||
UserAgent: opts.userAgent,
|
||||
Version: opts.version,
|
||||
Timeout: opts.timeout,
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type getterPlugin struct {
|
||||
options []Option
|
||||
plg plugin.Plugin
|
||||
env []string
|
||||
}
|
||||
|
||||
func (g *getterPlugin) Get(href string, options ...Option) (*bytes.Buffer, error) {
|
||||
opts := convertOptions(g.options, options)
|
||||
|
||||
// TODO optimization: pass this along to Get() instead of re-parsing here
|
||||
u, err := url.Parse(href)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
input := &plugin.Input{
|
||||
Message: schema.InputMessageGetterV1{
|
||||
Href: href,
|
||||
Options: opts,
|
||||
Protocol: u.Scheme,
|
||||
},
|
||||
Env: g.env,
|
||||
// TODO should we pass Stdin, Stdout, and Stderr through Input here to getter plugins?
|
||||
// Stdout: os.Stdout,
|
||||
}
|
||||
output, err := g.plg.Invoke(context.Background(), input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugin %q failed to invoke: %w", g.plg, err)
|
||||
}
|
||||
|
||||
outputMessage, ok := output.Message.(schema.OutputMessageGetterV1)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid output message type from plugin %q", g.plg.Metadata().Name)
|
||||
}
|
||||
|
||||
return bytes.NewBuffer(outputMessage.Data), nil
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
// 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 helmpath calculates filesystem paths to Helm's configuration, cache and data.
|
||||
package helmpath
|
||||
|
||||
// This helper builds paths to Helm's configuration, cache and data paths.
|
||||
const lp = lazypath("helm")
|
||||
|
||||
// ConfigPath returns the path where Helm stores configuration.
|
||||
func ConfigPath(elem ...string) string { return lp.configPath(elem...) }
|
||||
|
||||
// CachePath returns the path where Helm stores cached objects.
|
||||
func CachePath(elem ...string) string { return lp.cachePath(elem...) }
|
||||
|
||||
// DataPath returns the path where Helm stores data.
|
||||
func DataPath(elem ...string) string { return lp.dataPath(elem...) }
|
||||
|
||||
// CacheIndexFile returns the path to an index for the given named repository.
|
||||
func CacheIndexFile(name string) string {
|
||||
if name != "" {
|
||||
name += "-"
|
||||
}
|
||||
return name + "index.yaml"
|
||||
}
|
||||
|
||||
// CacheChartsFile returns the path to a text file listing all the charts
|
||||
// within the given named repository.
|
||||
func CacheChartsFile(name string) string {
|
||||
if name != "" {
|
||||
name += "-"
|
||||
}
|
||||
return name + "charts.txt"
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
// 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 helmpath
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"helm.sh/helm/v4/pkg/helmpath/xdg"
|
||||
)
|
||||
|
||||
const (
|
||||
// CacheHomeEnvVar is the environment variable used by Helm
|
||||
// for the cache directory. When no value is set a default is used.
|
||||
CacheHomeEnvVar = "HELM_CACHE_HOME"
|
||||
|
||||
// ConfigHomeEnvVar is the environment variable used by Helm
|
||||
// for the config directory. When no value is set a default is used.
|
||||
ConfigHomeEnvVar = "HELM_CONFIG_HOME"
|
||||
|
||||
// DataHomeEnvVar is the environment variable used by Helm
|
||||
// for the data directory. When no value is set a default is used.
|
||||
DataHomeEnvVar = "HELM_DATA_HOME"
|
||||
)
|
||||
|
||||
// lazypath is a lazy-loaded path buffer for the XDG base directory specification.
|
||||
type lazypath string
|
||||
|
||||
func (l lazypath) path(helmEnvVar, xdgEnvVar string, defaultFn func() string, elem ...string) string {
|
||||
|
||||
// There is an order to checking for a path.
|
||||
// 1. See if a Helm specific environment variable has been set.
|
||||
// 2. Check if an XDG environment variable is set
|
||||
// 3. Fall back to a default
|
||||
base := os.Getenv(helmEnvVar)
|
||||
if base != "" {
|
||||
return filepath.Join(base, filepath.Join(elem...))
|
||||
}
|
||||
base = os.Getenv(xdgEnvVar)
|
||||
if base == "" {
|
||||
base = defaultFn()
|
||||
}
|
||||
return filepath.Join(base, string(l), filepath.Join(elem...))
|
||||
}
|
||||
|
||||
// cachePath defines the base directory relative to which user specific non-essential data files
|
||||
// should be stored.
|
||||
func (l lazypath) cachePath(elem ...string) string {
|
||||
return l.path(CacheHomeEnvVar, xdg.CacheHomeEnvVar, cacheHome, filepath.Join(elem...))
|
||||
}
|
||||
|
||||
// configPath defines the base directory relative to which user specific configuration files should
|
||||
// be stored.
|
||||
func (l lazypath) configPath(elem ...string) string {
|
||||
return l.path(ConfigHomeEnvVar, xdg.ConfigHomeEnvVar, configHome, filepath.Join(elem...))
|
||||
}
|
||||
|
||||
// dataPath defines the base directory relative to which user specific data files should be stored.
|
||||
func (l lazypath) dataPath(elem ...string) string {
|
||||
return l.path(DataHomeEnvVar, xdg.DataHomeEnvVar, dataHome, filepath.Join(elem...))
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// 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.
|
||||
|
||||
//go:build darwin
|
||||
|
||||
package helmpath
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"k8s.io/client-go/util/homedir"
|
||||
)
|
||||
|
||||
func dataHome() string {
|
||||
return filepath.Join(homedir.HomeDir(), "Library")
|
||||
}
|
||||
|
||||
func configHome() string {
|
||||
return filepath.Join(homedir.HomeDir(), "Library", "Preferences")
|
||||
}
|
||||
|
||||
func cacheHome() string {
|
||||
return filepath.Join(homedir.HomeDir(), "Library", "Caches")
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// 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.
|
||||
|
||||
//go:build !windows && !darwin
|
||||
|
||||
package helmpath
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"k8s.io/client-go/util/homedir"
|
||||
)
|
||||
|
||||
// dataHome defines the base directory relative to which user specific data files should be stored.
|
||||
//
|
||||
// If $XDG_DATA_HOME is either not set or empty, a default equal to $HOME/.local/share is used.
|
||||
func dataHome() string {
|
||||
return filepath.Join(homedir.HomeDir(), ".local", "share")
|
||||
}
|
||||
|
||||
// configHome defines the base directory relative to which user specific configuration files should
|
||||
// be stored.
|
||||
//
|
||||
// If $XDG_CONFIG_HOME is either not set or empty, a default equal to $HOME/.config is used.
|
||||
func configHome() string {
|
||||
return filepath.Join(homedir.HomeDir(), ".config")
|
||||
}
|
||||
|
||||
// cacheHome defines the base directory relative to which user specific non-essential data files
|
||||
// should be stored.
|
||||
//
|
||||
// If $XDG_CACHE_HOME is either not set or empty, a default equal to $HOME/.cache is used.
|
||||
func cacheHome() string {
|
||||
return filepath.Join(homedir.HomeDir(), ".cache")
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// 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.
|
||||
|
||||
//go:build windows
|
||||
|
||||
package helmpath
|
||||
|
||||
import "os"
|
||||
|
||||
func dataHome() string { return configHome() }
|
||||
|
||||
func configHome() string { return os.Getenv("APPDATA") }
|
||||
|
||||
func cacheHome() string { return os.Getenv("TEMP") }
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
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 xdg holds constants pertaining to XDG Base Directory Specification.
|
||||
//
|
||||
// The XDG Base Directory Specification https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
|
||||
// specifies the environment variables that define user-specific base directories for various categories of files.
|
||||
package xdg
|
||||
|
||||
const (
|
||||
// CacheHomeEnvVar is the environment variable used by the
|
||||
// XDG base directory specification for the cache directory.
|
||||
CacheHomeEnvVar = "XDG_CACHE_HOME"
|
||||
|
||||
// ConfigHomeEnvVar is the environment variable used by the
|
||||
// XDG base directory specification for the config directory.
|
||||
ConfigHomeEnvVar = "XDG_CONFIG_HOME"
|
||||
|
||||
// DataHomeEnvVar is the environment variable used by the
|
||||
// XDG base directory specification for the data directory.
|
||||
DataHomeEnvVar = "XDG_DATA_HOME"
|
||||
)
|
||||
+1297
File diff suppressed because it is too large
Load Diff
+69
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
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 kube // import "helm.sh/helm/v4/pkg/kube"
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
||||
apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/cli-runtime/pkg/resource"
|
||||
"k8s.io/client-go/kubernetes/scheme"
|
||||
)
|
||||
|
||||
var k8sNativeScheme *runtime.Scheme
|
||||
var k8sNativeSchemeOnce sync.Once
|
||||
|
||||
// AsVersioned converts the given info into a runtime.Object with the correct
|
||||
// group and version set
|
||||
func AsVersioned(info *resource.Info) runtime.Object {
|
||||
return convertWithMapper(info.Object, info.Mapping)
|
||||
}
|
||||
|
||||
// convertWithMapper converts the given object with the optional provided
|
||||
// RESTMapping. If no mapping is provided, the default schema versioner is used
|
||||
func convertWithMapper(obj runtime.Object, mapping *meta.RESTMapping) runtime.Object {
|
||||
s := kubernetesNativeScheme()
|
||||
var gv = runtime.GroupVersioner(schema.GroupVersions(s.PrioritizedVersionsAllGroups()))
|
||||
if mapping != nil {
|
||||
gv = mapping.GroupVersionKind.GroupVersion()
|
||||
}
|
||||
if obj, err := runtime.ObjectConvertor(s).ConvertToVersion(obj, gv); err == nil {
|
||||
return obj
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
// kubernetesNativeScheme returns a clean *runtime.Scheme with _only_ Kubernetes
|
||||
// native resources added to it. This is required to break free of custom resources
|
||||
// that may have been added to scheme.Scheme due to Helm being used as a package in
|
||||
// combination with e.g. a versioned kube client. If we would not do this, the client
|
||||
// may attempt to perform e.g. a 3-way-merge strategy patch for custom resources.
|
||||
func kubernetesNativeScheme() *runtime.Scheme {
|
||||
k8sNativeSchemeOnce.Do(func() {
|
||||
k8sNativeScheme = runtime.NewScheme()
|
||||
scheme.AddToScheme(k8sNativeScheme)
|
||||
// API extensions are not in the above scheme set,
|
||||
// and must thus be added separately.
|
||||
apiextensionsv1beta1.AddToScheme(k8sNativeScheme)
|
||||
apiextensionsv1.AddToScheme(k8sNativeScheme)
|
||||
})
|
||||
return k8sNativeScheme
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
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 kube // import "helm.sh/helm/v4/pkg/kube"
|
||||
|
||||
import (
|
||||
"k8s.io/cli-runtime/pkg/resource"
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
"k8s.io/kubectl/pkg/validation"
|
||||
)
|
||||
|
||||
// Factory provides abstractions that allow the Kubectl command to be extended across multiple types
|
||||
// of resources and different API sets.
|
||||
// This interface is a minimal copy of the kubectl Factory interface containing only the functions
|
||||
// needed by Helm. Since Kubernetes Go APIs, including interfaces, can change in any minor release
|
||||
// this interface is not covered by the Helm backwards compatibility guarantee. The reasons for the
|
||||
// minimal copy is that it does not include the full interface. Changes or additions to functions
|
||||
// Helm does not need are not impacted or exposed. This minimizes the impact of Kubernetes changes
|
||||
// being exposed.
|
||||
type Factory interface {
|
||||
// ToRESTConfig returns restconfig
|
||||
ToRESTConfig() (*rest.Config, error)
|
||||
|
||||
// ToRawKubeConfigLoader return kubeconfig loader as-is
|
||||
ToRawKubeConfigLoader() clientcmd.ClientConfig
|
||||
|
||||
// DynamicClient returns a dynamic client ready for use
|
||||
DynamicClient() (dynamic.Interface, error)
|
||||
|
||||
// KubernetesClientSet gives you back an external clientset
|
||||
KubernetesClientSet() (*kubernetes.Clientset, error)
|
||||
|
||||
// NewBuilder returns an object that assists in loading objects from both disk and the server
|
||||
// and which implements the common patterns for CLI interactions with generic resources.
|
||||
NewBuilder() *resource.Builder
|
||||
|
||||
// Returns a schema that can validate objects stored on disk.
|
||||
Validator(validationDirective string) (validation.Schema, error)
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
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 kube
|
||||
|
||||
import (
|
||||
"io"
|
||||
"time"
|
||||
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
// Interface represents a client capable of communicating with the Kubernetes API.
|
||||
//
|
||||
// A KubernetesClient must be concurrency safe.
|
||||
type Interface interface {
|
||||
// Get details of deployed resources.
|
||||
// The first argument is a list of resources to get. The second argument
|
||||
// specifies if related pods should be fetched. For example, the pods being
|
||||
// managed by a deployment.
|
||||
Get(resources ResourceList, related bool) (map[string][]runtime.Object, error)
|
||||
|
||||
// Create creates one or more resources.
|
||||
Create(resources ResourceList, options ...ClientCreateOption) (*Result, error)
|
||||
|
||||
// Delete destroys one or more resources using the specified deletion propagation policy.
|
||||
// The 'policy' parameter determines how child resources are handled during deletion.
|
||||
Delete(resources ResourceList, policy metav1.DeletionPropagation) (*Result, []error)
|
||||
|
||||
// Update updates one or more resources or creates the resource
|
||||
// if it doesn't exist.
|
||||
Update(original, target ResourceList, options ...ClientUpdateOption) (*Result, error)
|
||||
|
||||
// Build creates a resource list from a Reader.
|
||||
//
|
||||
// Reader must contain a YAML stream (one or more YAML documents separated
|
||||
// by "\n---\n")
|
||||
//
|
||||
// Validates against OpenAPI schema if validate is true.
|
||||
Build(reader io.Reader, validate bool) (ResourceList, error)
|
||||
// IsReachable checks whether the client is able to connect to the cluster.
|
||||
IsReachable() error
|
||||
|
||||
// GetWaiter gets the Kube.Waiter.
|
||||
GetWaiter(ws WaitStrategy) (Waiter, error)
|
||||
|
||||
// GetPodList lists all pods that match the specified listOptions
|
||||
GetPodList(namespace string, listOptions metav1.ListOptions) (*v1.PodList, error)
|
||||
|
||||
// OutputContainerLogsForPodList outputs the logs for a pod list
|
||||
OutputContainerLogsForPodList(podList *v1.PodList, namespace string, writerFunc func(namespace, pod, container string) io.Writer) error
|
||||
|
||||
// BuildTable creates a resource list from a Reader. This differs from
|
||||
// Interface.Build() in that a table kind is returned. A table is useful
|
||||
// if you want to use a printer to display the information.
|
||||
//
|
||||
// Reader must contain a YAML stream (one or more YAML documents separated
|
||||
// by "\n---\n")
|
||||
//
|
||||
// Validates against OpenAPI schema if validate is true.
|
||||
// TODO Helm 4: Integrate into Build with an argument
|
||||
BuildTable(reader io.Reader, validate bool) (ResourceList, error)
|
||||
}
|
||||
|
||||
// Waiter defines methods related to waiting for resource states.
|
||||
type Waiter interface {
|
||||
// Wait waits up to the given timeout for the specified resources to be ready.
|
||||
Wait(resources ResourceList, timeout time.Duration) error
|
||||
|
||||
// WaitWithJobs wait up to the given timeout for the specified resources to be ready, including jobs.
|
||||
WaitWithJobs(resources ResourceList, timeout time.Duration) error
|
||||
|
||||
// WaitForDelete wait up to the given timeout for the specified resources to be deleted.
|
||||
WaitForDelete(resources ResourceList, timeout time.Duration) error
|
||||
|
||||
// WatchUntilReady watches the resources given and waits until it is ready.
|
||||
//
|
||||
// This method is mainly for hook implementations. It watches for a resource to
|
||||
// hit a particular milestone. The milestone depends on the Kind.
|
||||
//
|
||||
// For Jobs, "ready" means the Job ran to completion (exited without error).
|
||||
// For Pods, "ready" means the Pod phase is marked "succeeded".
|
||||
// For all other kinds, it means the kind was created or modified without
|
||||
// error.
|
||||
WatchUntilReady(resources ResourceList, timeout time.Duration) error
|
||||
}
|
||||
|
||||
// InterfaceWaitOptions defines an interface that extends Interface with
|
||||
// methods that accept wait options.
|
||||
//
|
||||
// TODO Helm 5: Remove InterfaceWaitOptions and integrate its method(s) into the Interface.
|
||||
type InterfaceWaitOptions interface {
|
||||
// GetWaiter gets the Kube.Waiter with options.
|
||||
GetWaiterWithOptions(ws WaitStrategy, opts ...WaitOption) (Waiter, error)
|
||||
}
|
||||
|
||||
var _ InterfaceWaitOptions = (*Client)(nil)
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
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 kube
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
|
||||
)
|
||||
|
||||
// WaitOption is a function that configures an option for waiting on resources.
|
||||
type WaitOption func(*waitOptions)
|
||||
|
||||
// WithWaitContext sets the context for waiting on resources.
|
||||
// If unset, context.Background() will be used.
|
||||
func WithWaitContext(ctx context.Context) WaitOption {
|
||||
return func(wo *waitOptions) {
|
||||
wo.ctx = ctx
|
||||
}
|
||||
}
|
||||
|
||||
// WithWatchUntilReadyMethodContext sets the context specifically for the WatchUntilReady method.
|
||||
// If unset, the context set by `WithWaitContext` will be used (falling back to `context.Background()`).
|
||||
func WithWatchUntilReadyMethodContext(ctx context.Context) WaitOption {
|
||||
return func(wo *waitOptions) {
|
||||
wo.watchUntilReadyCtx = ctx
|
||||
}
|
||||
}
|
||||
|
||||
// WithWaitMethodContext sets the context specifically for the Wait method.
|
||||
// If unset, the context set by `WithWaitContext` will be used (falling back to `context.Background()`).
|
||||
func WithWaitMethodContext(ctx context.Context) WaitOption {
|
||||
return func(wo *waitOptions) {
|
||||
wo.waitCtx = ctx
|
||||
}
|
||||
}
|
||||
|
||||
// WithWaitWithJobsMethodContext sets the context specifically for the WaitWithJobs method.
|
||||
// If unset, the context set by `WithWaitContext` will be used (falling back to `context.Background()`).
|
||||
func WithWaitWithJobsMethodContext(ctx context.Context) WaitOption {
|
||||
return func(wo *waitOptions) {
|
||||
wo.waitWithJobsCtx = ctx
|
||||
}
|
||||
}
|
||||
|
||||
// WithWaitForDeleteMethodContext sets the context specifically for the WaitForDelete method.
|
||||
// If unset, the context set by `WithWaitContext` will be used (falling back to `context.Background()`).
|
||||
func WithWaitForDeleteMethodContext(ctx context.Context) WaitOption {
|
||||
return func(wo *waitOptions) {
|
||||
wo.waitForDeleteCtx = ctx
|
||||
}
|
||||
}
|
||||
|
||||
// WithKStatusReaders sets the status readers to be used while waiting on resources.
|
||||
func WithKStatusReaders(readers ...engine.StatusReader) WaitOption {
|
||||
return func(wo *waitOptions) {
|
||||
wo.statusReaders = readers
|
||||
}
|
||||
}
|
||||
|
||||
type waitOptions struct {
|
||||
ctx context.Context
|
||||
watchUntilReadyCtx context.Context
|
||||
waitCtx context.Context
|
||||
waitWithJobsCtx context.Context
|
||||
waitForDeleteCtx context.Context
|
||||
statusReaders []engine.StatusReader
|
||||
}
|
||||
+466
@@ -0,0 +1,466 @@
|
||||
/*
|
||||
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 kube // import "helm.sh/helm/v4/pkg/kube"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
||||
apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
"k8s.io/cli-runtime/pkg/resource"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/kubernetes/scheme"
|
||||
|
||||
deploymentutil "helm.sh/helm/v4/internal/third_party/k8s.io/kubernetes/deployment/util"
|
||||
)
|
||||
|
||||
// ReadyCheckerOption is a function that configures a ReadyChecker.
|
||||
type ReadyCheckerOption func(*ReadyChecker)
|
||||
|
||||
// PausedAsReady returns a ReadyCheckerOption that configures a ReadyChecker
|
||||
// to consider paused resources to be ready. For example a Deployment
|
||||
// with spec.paused equal to true would be considered ready.
|
||||
func PausedAsReady(pausedAsReady bool) ReadyCheckerOption {
|
||||
return func(c *ReadyChecker) {
|
||||
c.pausedAsReady = pausedAsReady
|
||||
}
|
||||
}
|
||||
|
||||
// CheckJobs returns a ReadyCheckerOption that configures a ReadyChecker
|
||||
// to consider readiness of Job resources.
|
||||
func CheckJobs(checkJobs bool) ReadyCheckerOption {
|
||||
return func(c *ReadyChecker) {
|
||||
c.checkJobs = checkJobs
|
||||
}
|
||||
}
|
||||
|
||||
// NewReadyChecker creates a new checker. Passed ReadyCheckerOptions can
|
||||
// be used to override defaults.
|
||||
func NewReadyChecker(cl kubernetes.Interface, opts ...ReadyCheckerOption) ReadyChecker {
|
||||
c := ReadyChecker{
|
||||
client: cl,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(&c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ReadyChecker is a type that can check core Kubernetes types for readiness.
|
||||
type ReadyChecker struct {
|
||||
client kubernetes.Interface
|
||||
checkJobs bool
|
||||
pausedAsReady bool
|
||||
}
|
||||
|
||||
// IsReady checks if v is ready. It supports checking readiness for pods,
|
||||
// deployments, persistent volume claims, services, daemon sets, custom
|
||||
// resource definitions, stateful sets, replication controllers, jobs (optional),
|
||||
// and replica sets. All other resource kinds are always considered ready.
|
||||
//
|
||||
// IsReady will fetch the latest state of the object from the server prior to
|
||||
// performing readiness checks, and it will return any error encountered.
|
||||
func (c *ReadyChecker) IsReady(ctx context.Context, v *resource.Info) (bool, error) {
|
||||
switch value := AsVersioned(v).(type) {
|
||||
case *corev1.Pod:
|
||||
pod, err := c.client.CoreV1().Pods(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{})
|
||||
if err != nil || !c.isPodReady(pod) {
|
||||
return false, err
|
||||
}
|
||||
case *batchv1.Job:
|
||||
if c.checkJobs {
|
||||
job, err := c.client.BatchV1().Jobs(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
ready, err := c.jobReady(job)
|
||||
return ready, err
|
||||
}
|
||||
case *appsv1.Deployment:
|
||||
currentDeployment, err := c.client.AppsV1().Deployments(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// If paused deployment will never be ready
|
||||
if currentDeployment.Spec.Paused {
|
||||
return c.pausedAsReady, nil
|
||||
}
|
||||
// Find RS associated with deployment
|
||||
newReplicaSet, err := deploymentutil.GetNewReplicaSet(currentDeployment, c.client.AppsV1())
|
||||
if err != nil || newReplicaSet == nil {
|
||||
return false, err
|
||||
}
|
||||
if !c.deploymentReady(newReplicaSet, currentDeployment) {
|
||||
return false, nil
|
||||
}
|
||||
case *corev1.PersistentVolumeClaim:
|
||||
claim, err := c.client.CoreV1().PersistentVolumeClaims(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !c.volumeReady(claim) {
|
||||
return false, nil
|
||||
}
|
||||
case *corev1.Service:
|
||||
svc, err := c.client.CoreV1().Services(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !c.serviceReady(svc) {
|
||||
return false, nil
|
||||
}
|
||||
case *appsv1.DaemonSet:
|
||||
ds, err := c.client.AppsV1().DaemonSets(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !c.daemonSetReady(ds) {
|
||||
return false, nil
|
||||
}
|
||||
case *apiextv1beta1.CustomResourceDefinition:
|
||||
if err := v.Get(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
crd := &apiextv1beta1.CustomResourceDefinition{}
|
||||
if err := scheme.Scheme.Convert(v.Object, crd, nil); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !c.crdBetaReady(*crd) {
|
||||
return false, nil
|
||||
}
|
||||
case *apiextv1.CustomResourceDefinition:
|
||||
if err := v.Get(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
crd := &apiextv1.CustomResourceDefinition{}
|
||||
if err := scheme.Scheme.Convert(v.Object, crd, nil); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !c.crdReady(*crd) {
|
||||
return false, nil
|
||||
}
|
||||
case *appsv1.StatefulSet:
|
||||
sts, err := c.client.AppsV1().StatefulSets(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !c.statefulSetReady(sts) {
|
||||
return false, nil
|
||||
}
|
||||
case *corev1.ReplicationController:
|
||||
rc, err := c.client.CoreV1().ReplicationControllers(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !c.replicationControllerReady(rc) {
|
||||
return false, nil
|
||||
}
|
||||
ready, err := c.podsReadyForObject(ctx, v.Namespace, value)
|
||||
if !ready || err != nil {
|
||||
return false, err
|
||||
}
|
||||
case *appsv1.ReplicaSet:
|
||||
rs, err := c.client.AppsV1().ReplicaSets(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !c.replicaSetReady(rs) {
|
||||
return false, nil
|
||||
}
|
||||
ready, err := c.podsReadyForObject(ctx, v.Namespace, value)
|
||||
if !ready || err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *ReadyChecker) podsReadyForObject(ctx context.Context, namespace string, obj runtime.Object) (bool, error) {
|
||||
pods, err := c.podsforObject(ctx, namespace, obj)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, pod := range pods {
|
||||
if !c.isPodReady(&pod) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *ReadyChecker) podsforObject(ctx context.Context, namespace string, obj runtime.Object) ([]corev1.Pod, error) {
|
||||
selector, err := SelectorsForObject(obj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list, err := getPods(ctx, c.client, namespace, selector.String())
|
||||
return list, err
|
||||
}
|
||||
|
||||
// isPodReady returns true if a pod is ready; false otherwise.
|
||||
func (c *ReadyChecker) isPodReady(pod *corev1.Pod) bool {
|
||||
for _, c := range pod.Status.Conditions {
|
||||
if c.Type == corev1.PodReady && c.Status == corev1.ConditionTrue {
|
||||
return true
|
||||
}
|
||||
}
|
||||
slog.Debug("Pod is not ready", "namespace", pod.GetNamespace(), "name", pod.GetName())
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *ReadyChecker) jobReady(job *batchv1.Job) (bool, error) {
|
||||
if job.Status.Failed > *job.Spec.BackoffLimit {
|
||||
slog.Debug("Job is failed", "namespace", job.GetNamespace(), "name", job.GetName())
|
||||
// If a job is failed, it can't recover, so throw an error
|
||||
return false, fmt.Errorf("job is failed: %s/%s", job.GetNamespace(), job.GetName())
|
||||
}
|
||||
if job.Spec.Completions != nil && job.Status.Succeeded < *job.Spec.Completions {
|
||||
slog.Debug("Job is not completed", "namespace", job.GetNamespace(), "name", job.GetName())
|
||||
return false, nil
|
||||
}
|
||||
slog.Debug("Job is completed", "namespace", job.GetNamespace(), "name", job.GetName())
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *ReadyChecker) serviceReady(s *corev1.Service) bool {
|
||||
// ExternalName Services are external to cluster so helm shouldn't be checking to see if they're 'ready' (i.e. have an IP Set)
|
||||
if s.Spec.Type == corev1.ServiceTypeExternalName {
|
||||
return true
|
||||
}
|
||||
|
||||
// Ensure that the service cluster IP is not empty
|
||||
if s.Spec.ClusterIP == "" {
|
||||
slog.Debug("Service does not have cluster IP address", "namespace", s.GetNamespace(), "name", s.GetName())
|
||||
return false
|
||||
}
|
||||
|
||||
// This checks if the service has a LoadBalancer and that balancer has an Ingress defined
|
||||
if s.Spec.Type == corev1.ServiceTypeLoadBalancer {
|
||||
// do not wait when at least 1 external IP is set
|
||||
if len(s.Spec.ExternalIPs) > 0 {
|
||||
slog.Debug("Service has external IP addresses", "namespace", s.GetNamespace(), "name", s.GetName(), "externalIPs", s.Spec.ExternalIPs)
|
||||
return true
|
||||
}
|
||||
|
||||
if s.Status.LoadBalancer.Ingress == nil {
|
||||
slog.Debug("Service does not have load balancer ingress IP address", "namespace", s.GetNamespace(), "name", s.GetName())
|
||||
return false
|
||||
}
|
||||
}
|
||||
slog.Debug("Service is ready", "namespace", s.GetNamespace(), "name", s.GetName(), "clusterIP", s.Spec.ClusterIP, "externalIPs", s.Spec.ExternalIPs)
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *ReadyChecker) volumeReady(v *corev1.PersistentVolumeClaim) bool {
|
||||
if v.Status.Phase != corev1.ClaimBound {
|
||||
slog.Debug("PersistentVolumeClaim is not bound", "namespace", v.GetNamespace(), "name", v.GetName())
|
||||
return false
|
||||
}
|
||||
slog.Debug("PersistentVolumeClaim is bound", "namespace", v.GetNamespace(), "name", v.GetName(), "phase", v.Status.Phase)
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *ReadyChecker) deploymentReady(rs *appsv1.ReplicaSet, dep *appsv1.Deployment) bool {
|
||||
// Verify the replicaset readiness
|
||||
if !c.replicaSetReady(rs) {
|
||||
return false
|
||||
}
|
||||
// Verify the generation observed by the deployment controller matches the spec generation
|
||||
if dep.Status.ObservedGeneration != dep.Generation {
|
||||
slog.Debug("Deployment is not ready, observedGeneration does not match spec generation", "namespace", dep.GetNamespace(), "name", dep.GetName(), "actualGeneration", dep.Status.ObservedGeneration, "expectedGeneration", dep.Generation)
|
||||
return false
|
||||
}
|
||||
|
||||
expectedReady := *dep.Spec.Replicas - deploymentutil.MaxUnavailable(*dep)
|
||||
if rs.Status.ReadyReplicas < expectedReady {
|
||||
slog.Debug("Deployment does not have enough pods ready", "namespace", dep.GetNamespace(), "name", dep.GetName(), "readyPods", rs.Status.ReadyReplicas, "totalPods", expectedReady)
|
||||
return false
|
||||
}
|
||||
slog.Debug("Deployment is ready", "namespace", dep.GetNamespace(), "name", dep.GetName(), "readyPods", rs.Status.ReadyReplicas, "totalPods", expectedReady)
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *ReadyChecker) daemonSetReady(ds *appsv1.DaemonSet) bool {
|
||||
// Verify the generation observed by the daemonSet controller matches the spec generation
|
||||
if ds.Status.ObservedGeneration != ds.Generation {
|
||||
slog.Debug("DaemonSet is not ready, observedGeneration does not match spec generation", "namespace", ds.GetNamespace(), "name", ds.GetName(), "observedGeneration", ds.Status.ObservedGeneration, "expectedGeneration", ds.Generation)
|
||||
return false
|
||||
}
|
||||
|
||||
// If the update strategy is not a rolling update, there will be nothing to wait for
|
||||
if ds.Spec.UpdateStrategy.Type != appsv1.RollingUpdateDaemonSetStrategyType {
|
||||
return true
|
||||
}
|
||||
|
||||
// Make sure all the updated pods have been scheduled
|
||||
if ds.Status.UpdatedNumberScheduled != ds.Status.DesiredNumberScheduled {
|
||||
slog.Debug("DaemonSet does not have enough Pods scheduled", "namespace", ds.GetNamespace(), "name", ds.GetName(), "scheduledPods", ds.Status.UpdatedNumberScheduled, "totalPods", ds.Status.DesiredNumberScheduled)
|
||||
return false
|
||||
}
|
||||
maxUnavailable, err := intstr.GetScaledValueFromIntOrPercent(ds.Spec.UpdateStrategy.RollingUpdate.MaxUnavailable, int(ds.Status.DesiredNumberScheduled), true)
|
||||
if err != nil {
|
||||
// If for some reason the value is invalid, set max unavailable to the
|
||||
// number of desired replicas. This is the same behavior as the
|
||||
// `MaxUnavailable` function in deploymentutil
|
||||
maxUnavailable = int(ds.Status.DesiredNumberScheduled)
|
||||
}
|
||||
|
||||
expectedReady := int(ds.Status.DesiredNumberScheduled) - maxUnavailable
|
||||
if int(ds.Status.NumberReady) < expectedReady {
|
||||
slog.Debug("DaemonSet does not have enough Pods ready", "namespace", ds.GetNamespace(), "name", ds.GetName(), "readyPods", ds.Status.NumberReady, "totalPods", expectedReady)
|
||||
return false
|
||||
}
|
||||
slog.Debug("DaemonSet is ready", "namespace", ds.GetNamespace(), "name", ds.GetName(), "readyPods", ds.Status.NumberReady, "totalPods", expectedReady)
|
||||
return true
|
||||
}
|
||||
|
||||
// Because the v1 extensions API is not available on all supported k8s versions
|
||||
// yet and because Go doesn't support generics, we need to have a duplicate
|
||||
// function to support the v1beta1 types
|
||||
func (c *ReadyChecker) crdBetaReady(crd apiextv1beta1.CustomResourceDefinition) bool {
|
||||
for _, cond := range crd.Status.Conditions {
|
||||
switch cond.Type {
|
||||
case apiextv1beta1.Established:
|
||||
if cond.Status == apiextv1beta1.ConditionTrue {
|
||||
return true
|
||||
}
|
||||
case apiextv1beta1.NamesAccepted:
|
||||
if cond.Status == apiextv1beta1.ConditionFalse {
|
||||
// This indicates a naming conflict, but it's probably not the
|
||||
// job of this function to fail because of that. Instead,
|
||||
// we treat it as a success, since the process should be able to
|
||||
// continue.
|
||||
return true
|
||||
}
|
||||
default:
|
||||
// intentionally left empty
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *ReadyChecker) crdReady(crd apiextv1.CustomResourceDefinition) bool {
|
||||
for _, cond := range crd.Status.Conditions {
|
||||
switch cond.Type {
|
||||
case apiextv1.Established:
|
||||
if cond.Status == apiextv1.ConditionTrue {
|
||||
return true
|
||||
}
|
||||
case apiextv1.NamesAccepted:
|
||||
if cond.Status == apiextv1.ConditionFalse {
|
||||
// This indicates a naming conflict, but it's probably not the
|
||||
// job of this function to fail because of that. Instead,
|
||||
// we treat it as a success, since the process should be able to
|
||||
// continue.
|
||||
return true
|
||||
}
|
||||
default:
|
||||
// intentionally left empty
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *ReadyChecker) statefulSetReady(sts *appsv1.StatefulSet) bool {
|
||||
// Verify the generation observed by the statefulSet controller matches the spec generation
|
||||
if sts.Status.ObservedGeneration != sts.Generation {
|
||||
slog.Debug("StatefulSet is not ready, observedGeneration doest not match spec generation", "namespace", sts.GetNamespace(), "name", sts.GetName(), "actualGeneration", sts.Status.ObservedGeneration, "expectedGeneration", sts.Generation)
|
||||
return false
|
||||
}
|
||||
|
||||
// If the update strategy is not a rolling update, there will be nothing to wait for
|
||||
if sts.Spec.UpdateStrategy.Type != appsv1.RollingUpdateStatefulSetStrategyType {
|
||||
slog.Debug("StatefulSet skipped ready check", "namespace", sts.GetNamespace(), "name", sts.GetName(), "updateStrategy", sts.Spec.UpdateStrategy.Type)
|
||||
return true
|
||||
}
|
||||
|
||||
// Dereference all the pointers because StatefulSets like them
|
||||
var partition int
|
||||
// 1 is the default for replicas if not set
|
||||
replicas := 1
|
||||
// For some reason, even if the update strategy is a rolling update, the
|
||||
// actual rollingUpdate field can be nil. If it is, we can safely assume
|
||||
// there is no partition value
|
||||
if sts.Spec.UpdateStrategy.RollingUpdate != nil && sts.Spec.UpdateStrategy.RollingUpdate.Partition != nil {
|
||||
partition = int(*sts.Spec.UpdateStrategy.RollingUpdate.Partition)
|
||||
}
|
||||
if sts.Spec.Replicas != nil {
|
||||
replicas = int(*sts.Spec.Replicas)
|
||||
}
|
||||
|
||||
// Because an update strategy can use partitioning, we need to calculate the
|
||||
// number of updated replicas we should have. For example, if the replicas
|
||||
// is set to 3 and the partition is 2, we'd expect only one pod to be
|
||||
// updated
|
||||
expectedReplicas := replicas - partition
|
||||
|
||||
// Make sure all the updated pods have been scheduled
|
||||
if int(sts.Status.UpdatedReplicas) < expectedReplicas {
|
||||
slog.Debug("StatefulSet does not have enough Pods scheduled", "namespace", sts.GetNamespace(), "name", sts.GetName(), "readyPods", sts.Status.UpdatedReplicas, "totalPods", expectedReplicas)
|
||||
return false
|
||||
}
|
||||
|
||||
if int(sts.Status.ReadyReplicas) != replicas {
|
||||
slog.Debug("StatefulSet does not have enough Pods ready", "namespace", sts.GetNamespace(), "name", sts.GetName(), "readyPods", sts.Status.ReadyReplicas, "totalPods", replicas)
|
||||
return false
|
||||
}
|
||||
// This check only makes sense when all partitions are being upgraded otherwise during a
|
||||
// partitioned rolling upgrade, this condition will never evaluate to true, leading to
|
||||
// error.
|
||||
if partition == 0 && sts.Status.CurrentRevision != sts.Status.UpdateRevision {
|
||||
slog.Debug("StatefulSet is not ready, currentRevision does not match updateRevision", "namespace", sts.GetNamespace(), "name", sts.GetName(), "currentRevision", sts.Status.CurrentRevision, "updateRevision", sts.Status.UpdateRevision)
|
||||
return false
|
||||
}
|
||||
slog.Debug("StatefulSet is ready", "namespace", sts.GetNamespace(), "name", sts.GetName(), "readyPods", sts.Status.ReadyReplicas, "totalPods", replicas)
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *ReadyChecker) replicationControllerReady(rc *corev1.ReplicationController) bool {
|
||||
// Verify the generation observed by the replicationController controller matches the spec generation
|
||||
if rc.Status.ObservedGeneration != rc.Generation {
|
||||
slog.Debug("ReplicationController is not ready, observedGeneration doest not match spec generation", "namespace", rc.GetNamespace(), "name", rc.GetName(), "actualGeneration", rc.Status.ObservedGeneration, "expectedGeneration", rc.Generation)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *ReadyChecker) replicaSetReady(rs *appsv1.ReplicaSet) bool {
|
||||
// Verify the generation observed by the replicaSet controller matches the spec generation
|
||||
if rs.Status.ObservedGeneration != rs.Generation {
|
||||
slog.Debug("ReplicaSet is not ready, observedGeneration doest not match spec generation", "namespace", rs.GetNamespace(), "name", rs.GetName(), "actualGeneration", rs.Status.ObservedGeneration, "expectedGeneration", rs.Generation)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func getPods(ctx context.Context, client kubernetes.Interface, namespace, selector string) ([]corev1.Pod, error) {
|
||||
list, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
|
||||
LabelSelector: selector,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list pods: %w", err)
|
||||
}
|
||||
return list.Items, nil
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
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 kube // import "helm.sh/helm/v4/pkg/kube"
|
||||
|
||||
import "k8s.io/cli-runtime/pkg/resource"
|
||||
|
||||
// ResourceList provides convenience methods for comparing collections of Infos.
|
||||
type ResourceList []*resource.Info
|
||||
|
||||
// Append adds an Info to the Result.
|
||||
func (r *ResourceList) Append(val *resource.Info) {
|
||||
*r = append(*r, val)
|
||||
}
|
||||
|
||||
// Visit implements resource.Visitor. The visitor stops if fn returns an error.
|
||||
func (r ResourceList) Visit(fn resource.VisitorFunc) error {
|
||||
for _, i := range r {
|
||||
if err := fn(i, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Filter returns a new Result with Infos that satisfy the predicate fn.
|
||||
func (r ResourceList) Filter(fn func(*resource.Info) bool) ResourceList {
|
||||
var result ResourceList
|
||||
for _, i := range r {
|
||||
if fn(i) {
|
||||
result.Append(i)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Get returns the Info from the result that matches the name and kind.
|
||||
func (r ResourceList) Get(info *resource.Info) *resource.Info {
|
||||
for _, i := range r {
|
||||
if isMatchingInfo(i, info) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Contains checks to see if an object exists.
|
||||
func (r ResourceList) Contains(info *resource.Info) bool {
|
||||
for _, i := range r {
|
||||
if isMatchingInfo(i, info) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Difference will return a new Result with objects not contained in rs.
|
||||
func (r ResourceList) Difference(rs ResourceList) ResourceList {
|
||||
return r.Filter(func(info *resource.Info) bool {
|
||||
return !rs.Contains(info)
|
||||
})
|
||||
}
|
||||
|
||||
// Intersect will return a new Result with objects contained in both Results.
|
||||
func (r ResourceList) Intersect(rs ResourceList) ResourceList {
|
||||
return r.Filter(rs.Contains)
|
||||
}
|
||||
|
||||
// isMatchingInfo returns true if infos match on Name, Namespace, Group and Kind.
|
||||
//
|
||||
// IMPORTANT: Version is intentionally excluded from the comparison. Resources
|
||||
// served by the same CRD at different API versions (e.g. v2beta1 vs v2beta2)
|
||||
// share the same underlying storage in the Kubernetes API server. Comparing
|
||||
// the full GroupVersionKind causes Difference() to treat a version change as
|
||||
// a resource removal + addition, which makes Helm delete the resource it just
|
||||
// created during upgrades. See https://github.com/helm/helm/issues/31768
|
||||
func isMatchingInfo(a, b *resource.Info) bool {
|
||||
return a.Name == b.Name && a.Namespace == b.Namespace && a.Mapping.GroupVersionKind.GroupKind() == b.Mapping.GroupVersionKind.GroupKind()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
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 kube // import "helm.sh/helm/v4/pkg/kube"
|
||||
|
||||
// ResourcePolicyAnno is the annotation name for a resource policy
|
||||
const ResourcePolicyAnno = "helm.sh/resource-policy"
|
||||
|
||||
// KeepPolicy is the resource policy type for keep
|
||||
//
|
||||
// This resource policy type allows resources to skip being deleted
|
||||
//
|
||||
// during an uninstallRelease action.
|
||||
const KeepPolicy = "keep"
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
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 kube
|
||||
|
||||
// Result contains the information of created, updated, and deleted resources
|
||||
// for various kube API calls along with helper methods for using those
|
||||
// resources
|
||||
type Result struct {
|
||||
Created ResourceList
|
||||
Updated ResourceList
|
||||
Deleted ResourceList
|
||||
}
|
||||
|
||||
// If needed, we can add methods to the Result type for things like diffing
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
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 kube
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RetryingRoundTripper struct {
|
||||
Wrapped http.RoundTripper
|
||||
}
|
||||
|
||||
func (rt *RetryingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return rt.roundTrip(req, 1, nil)
|
||||
}
|
||||
|
||||
func (rt *RetryingRoundTripper) roundTrip(req *http.Request, retry int, prevResp *http.Response) (*http.Response, error) {
|
||||
if retry < 0 {
|
||||
return prevResp, nil
|
||||
}
|
||||
resp, rtErr := rt.Wrapped.RoundTrip(req)
|
||||
if rtErr != nil {
|
||||
return resp, rtErr
|
||||
}
|
||||
if resp.StatusCode < 500 {
|
||||
return resp, rtErr
|
||||
}
|
||||
if resp.Header.Get("content-type") != "application/json" {
|
||||
return resp, rtErr
|
||||
}
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
var ke kubernetesError
|
||||
r := bytes.NewReader(b)
|
||||
err = json.NewDecoder(r).Decode(&ke)
|
||||
r.Seek(0, io.SeekStart)
|
||||
resp.Body = io.NopCloser(r)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
if ke.Code < 500 {
|
||||
return resp, nil
|
||||
}
|
||||
// Matches messages like "etcdserver: leader changed"
|
||||
if strings.HasSuffix(ke.Message, "etcdserver: leader changed") {
|
||||
return rt.roundTrip(req, retry-1, resp)
|
||||
}
|
||||
// Matches messages like "rpc error: code = Unknown desc = raft proposal dropped"
|
||||
if strings.HasSuffix(ke.Message, "raft proposal dropped") {
|
||||
return rt.roundTrip(req, retry-1, resp)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
type kubernetesError struct {
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
}
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
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 kube // import "helm.sh/helm/v4/pkg/kube"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/aggregator"
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/collector"
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/statusreaders"
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/status"
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/watcher"
|
||||
"github.com/fluxcd/cli-utils/pkg/object"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/client-go/dynamic"
|
||||
watchtools "k8s.io/client-go/tools/watch"
|
||||
|
||||
"helm.sh/helm/v4/internal/logging"
|
||||
helmStatusReaders "helm.sh/helm/v4/internal/statusreaders"
|
||||
)
|
||||
|
||||
type statusWaiter struct {
|
||||
client dynamic.Interface
|
||||
restMapper meta.RESTMapper
|
||||
ctx context.Context
|
||||
watchUntilReadyCtx context.Context
|
||||
waitCtx context.Context
|
||||
waitWithJobsCtx context.Context
|
||||
waitForDeleteCtx context.Context
|
||||
readers []engine.StatusReader
|
||||
logging.LogHolder
|
||||
}
|
||||
|
||||
// DefaultStatusWatcherTimeout is the timeout used by the status waiter when a
|
||||
// zero timeout is provided. This prevents callers from accidentally passing a
|
||||
// zero value (which would immediately cancel the context) and getting
|
||||
// "context deadline exceeded" errors. SDK callers can rely on this default
|
||||
// when they don't set a timeout.
|
||||
var DefaultStatusWatcherTimeout = 30 * time.Second
|
||||
|
||||
func alwaysReady(_ *unstructured.Unstructured) (*status.Result, error) {
|
||||
return &status.Result{
|
||||
Status: status.CurrentStatus,
|
||||
Message: "Resource is current",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *statusWaiter) WatchUntilReady(resourceList ResourceList, timeout time.Duration) error {
|
||||
if timeout == 0 {
|
||||
timeout = DefaultStatusWatcherTimeout
|
||||
}
|
||||
ctx, cancel := w.contextWithTimeout(w.watchUntilReadyCtx, timeout)
|
||||
defer cancel()
|
||||
w.Logger().Debug("waiting for resources", "count", len(resourceList), "timeout", timeout)
|
||||
sw := watcher.NewDefaultStatusWatcher(w.client, w.restMapper)
|
||||
jobSR := helmStatusReaders.NewCustomJobStatusReader(w.restMapper)
|
||||
podSR := helmStatusReaders.NewCustomPodStatusReader(w.restMapper)
|
||||
// We don't want to wait on any other resources as watchUntilReady is only for Helm hooks.
|
||||
// If custom readers are defined they can be used as Helm hooks support any resource.
|
||||
// We put them in front since the DelegatingStatusReader uses the first reader that matches.
|
||||
genericSR := statusreaders.NewGenericStatusReader(w.restMapper, alwaysReady)
|
||||
|
||||
sr := &statusreaders.DelegatingStatusReader{
|
||||
StatusReaders: append(w.readers, jobSR, podSR, genericSR),
|
||||
}
|
||||
sw.StatusReader = sr
|
||||
return w.wait(ctx, resourceList, sw)
|
||||
}
|
||||
|
||||
func (w *statusWaiter) Wait(resourceList ResourceList, timeout time.Duration) error {
|
||||
if timeout == 0 {
|
||||
timeout = DefaultStatusWatcherTimeout
|
||||
}
|
||||
ctx, cancel := w.contextWithTimeout(w.waitCtx, timeout)
|
||||
defer cancel()
|
||||
w.Logger().Debug("waiting for resources", "count", len(resourceList), "timeout", timeout)
|
||||
sw := watcher.NewDefaultStatusWatcher(w.client, w.restMapper)
|
||||
sw.StatusReader = statusreaders.NewStatusReader(w.restMapper, w.readers...)
|
||||
return w.wait(ctx, resourceList, sw)
|
||||
}
|
||||
|
||||
func (w *statusWaiter) WaitWithJobs(resourceList ResourceList, timeout time.Duration) error {
|
||||
if timeout == 0 {
|
||||
timeout = DefaultStatusWatcherTimeout
|
||||
}
|
||||
ctx, cancel := w.contextWithTimeout(w.waitWithJobsCtx, timeout)
|
||||
defer cancel()
|
||||
w.Logger().Debug("waiting for resources", "count", len(resourceList), "timeout", timeout)
|
||||
sw := watcher.NewDefaultStatusWatcher(w.client, w.restMapper)
|
||||
newCustomJobStatusReader := helmStatusReaders.NewCustomJobStatusReader(w.restMapper)
|
||||
readers := append([]engine.StatusReader(nil), w.readers...)
|
||||
readers = append(readers, newCustomJobStatusReader)
|
||||
customSR := statusreaders.NewStatusReader(w.restMapper, readers...)
|
||||
sw.StatusReader = customSR
|
||||
return w.wait(ctx, resourceList, sw)
|
||||
}
|
||||
|
||||
func (w *statusWaiter) WaitForDelete(resourceList ResourceList, timeout time.Duration) error {
|
||||
if timeout == 0 {
|
||||
timeout = DefaultStatusWatcherTimeout
|
||||
}
|
||||
ctx, cancel := w.contextWithTimeout(w.waitForDeleteCtx, timeout)
|
||||
defer cancel()
|
||||
w.Logger().Debug("waiting for resources to be deleted", "count", len(resourceList), "timeout", timeout)
|
||||
sw := watcher.NewDefaultStatusWatcher(w.client, w.restMapper)
|
||||
return w.waitForDelete(ctx, resourceList, sw)
|
||||
}
|
||||
|
||||
func (w *statusWaiter) waitForDelete(ctx context.Context, resourceList ResourceList, sw watcher.StatusWatcher) error {
|
||||
cancelCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
resources := []object.ObjMetadata{}
|
||||
for _, resource := range resourceList {
|
||||
obj, err := object.RuntimeToObjMeta(resource.Object)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resources = append(resources, obj)
|
||||
}
|
||||
eventCh := sw.Watch(cancelCtx, resources, watcher.Options{
|
||||
RESTScopeStrategy: watcher.RESTScopeNamespace,
|
||||
})
|
||||
statusCollector := collector.NewResourceStatusCollector(resources)
|
||||
done := statusCollector.ListenWithObserver(eventCh, statusObserver(cancel, status.NotFoundStatus, w.Logger()))
|
||||
<-done
|
||||
|
||||
if statusCollector.Error != nil {
|
||||
return statusCollector.Error
|
||||
}
|
||||
|
||||
errs := []error{}
|
||||
for _, id := range resources {
|
||||
rs := statusCollector.ResourceStatuses[id]
|
||||
if rs.Status == status.NotFoundStatus || rs.Status == status.UnknownStatus {
|
||||
continue
|
||||
}
|
||||
errs = append(errs, fmt.Errorf("resource %s/%s/%s still exists. status: %s, message: %s",
|
||||
rs.Identifier.GroupKind.Kind, rs.Identifier.Namespace, rs.Identifier.Name, rs.Status, rs.Message))
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *statusWaiter) wait(ctx context.Context, resourceList ResourceList, sw watcher.StatusWatcher) error {
|
||||
cancelCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
resources := []object.ObjMetadata{}
|
||||
for _, resource := range resourceList {
|
||||
switch value := AsVersioned(resource).(type) {
|
||||
case *appsv1.Deployment:
|
||||
if value.Spec.Paused {
|
||||
continue
|
||||
}
|
||||
}
|
||||
obj, err := object.RuntimeToObjMeta(resource.Object)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resources = append(resources, obj)
|
||||
}
|
||||
|
||||
eventCh := sw.Watch(cancelCtx, resources, watcher.Options{
|
||||
RESTScopeStrategy: watcher.RESTScopeNamespace,
|
||||
})
|
||||
statusCollector := collector.NewResourceStatusCollector(resources)
|
||||
done := statusCollector.ListenWithObserver(eventCh, statusObserver(cancel, status.CurrentStatus, w.Logger()))
|
||||
<-done
|
||||
|
||||
if statusCollector.Error != nil {
|
||||
return statusCollector.Error
|
||||
}
|
||||
|
||||
errs := []error{}
|
||||
for _, id := range resources {
|
||||
rs := statusCollector.ResourceStatuses[id]
|
||||
if rs.Status == status.CurrentStatus {
|
||||
continue
|
||||
}
|
||||
errs = append(errs, fmt.Errorf("resource %s/%s/%s not ready. status: %s, message: %s",
|
||||
rs.Identifier.GroupKind.Kind, rs.Identifier.Namespace, rs.Identifier.Name, rs.Status, rs.Message))
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *statusWaiter) contextWithTimeout(methodCtx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
if methodCtx == nil {
|
||||
methodCtx = w.ctx
|
||||
}
|
||||
return contextWithTimeout(methodCtx, timeout)
|
||||
}
|
||||
|
||||
func contextWithTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return watchtools.ContextWithOptionalTimeout(ctx, timeout)
|
||||
}
|
||||
|
||||
func statusObserver(cancel context.CancelFunc, desired status.Status, logger *slog.Logger) collector.ObserverFunc {
|
||||
return func(statusCollector *collector.ResourceStatusCollector, _ event.Event) {
|
||||
var rss []*event.ResourceStatus
|
||||
var nonDesiredResources []*event.ResourceStatus
|
||||
for _, rs := range statusCollector.ResourceStatuses {
|
||||
if rs == nil {
|
||||
continue
|
||||
}
|
||||
// If a resource is already deleted before waiting has started, it will show as unknown.
|
||||
// This check ensures we don't wait forever for a resource that is already deleted.
|
||||
if rs.Status == status.UnknownStatus && desired == status.NotFoundStatus {
|
||||
continue
|
||||
}
|
||||
// Failed is a terminal state. This check ensures we don't wait forever for a resource
|
||||
// that has already failed, as intervention is required to resolve the failure.
|
||||
if rs.Status == status.FailedStatus && desired == status.CurrentStatus {
|
||||
continue
|
||||
}
|
||||
rss = append(rss, rs)
|
||||
if rs.Status != desired {
|
||||
nonDesiredResources = append(nonDesiredResources, rs)
|
||||
}
|
||||
}
|
||||
|
||||
if aggregator.AggregateStatus(rss, desired) == desired {
|
||||
logger.Debug("all resources achieved desired status", "desiredStatus", desired, "resourceCount", len(rss))
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
|
||||
if len(nonDesiredResources) > 0 {
|
||||
// Log a single resource so the user knows what they're waiting for without an overwhelming amount of output
|
||||
sort.Slice(nonDesiredResources, func(i, j int) bool {
|
||||
return nonDesiredResources[i].Identifier.Name < nonDesiredResources[j].Identifier.Name
|
||||
})
|
||||
first := nonDesiredResources[0]
|
||||
logger.Debug("waiting for resource", "namespace", first.Identifier.Namespace, "name", first.Identifier.Name, "kind", first.Identifier.GroupKind.Kind, "expectedStatus", desired, "actualStatus", first.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type hookOnlyWaiter struct {
|
||||
sw *statusWaiter
|
||||
}
|
||||
|
||||
func (w *hookOnlyWaiter) WatchUntilReady(resourceList ResourceList, timeout time.Duration) error {
|
||||
return w.sw.WatchUntilReady(resourceList, timeout)
|
||||
}
|
||||
|
||||
func (w *hookOnlyWaiter) Wait(_ ResourceList, _ time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *hookOnlyWaiter) WaitWithJobs(_ ResourceList, _ time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *hookOnlyWaiter) WaitForDelete(_ ResourceList, _ time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
/*
|
||||
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 kube // import "helm.sh/helm/v4/pkg/kube"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
appsv1beta1 "k8s.io/api/apps/v1beta1"
|
||||
appsv1beta2 "k8s.io/api/apps/v1beta2"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
extensionsv1beta1 "k8s.io/api/extensions/v1beta1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/fields"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
"k8s.io/cli-runtime/pkg/resource"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
cachetools "k8s.io/client-go/tools/cache"
|
||||
watchtools "k8s.io/client-go/tools/watch"
|
||||
|
||||
"k8s.io/apimachinery/pkg/util/wait"
|
||||
)
|
||||
|
||||
// legacyWaiter is the legacy implementation of the Waiter interface. This logic was used by default in Helm 3
|
||||
// Helm 4 now uses the StatusWaiter implementation instead
|
||||
type legacyWaiter struct {
|
||||
c ReadyChecker
|
||||
kubeClient *kubernetes.Clientset
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (hw *legacyWaiter) Wait(resources ResourceList, timeout time.Duration) error {
|
||||
hw.c = NewReadyChecker(hw.kubeClient, PausedAsReady(true))
|
||||
return hw.waitForResources(resources, timeout)
|
||||
}
|
||||
|
||||
func (hw *legacyWaiter) WaitWithJobs(resources ResourceList, timeout time.Duration) error {
|
||||
hw.c = NewReadyChecker(hw.kubeClient, PausedAsReady(true), CheckJobs(true))
|
||||
return hw.waitForResources(resources, timeout)
|
||||
}
|
||||
|
||||
// waitForResources polls to get the current status of all pods, PVCs, Services and
|
||||
// Jobs(optional) until all are ready or a timeout is reached
|
||||
func (hw *legacyWaiter) waitForResources(created ResourceList, timeout time.Duration) error {
|
||||
slog.Debug("beginning wait for resources", "count", len(created), "timeout", timeout)
|
||||
|
||||
ctx, cancel := hw.contextWithTimeout(timeout)
|
||||
defer cancel()
|
||||
|
||||
numberOfErrors := make([]int, len(created))
|
||||
for i := range numberOfErrors {
|
||||
numberOfErrors[i] = 0
|
||||
}
|
||||
|
||||
return wait.PollUntilContextCancel(ctx, 2*time.Second, true, func(ctx context.Context) (bool, error) {
|
||||
waitRetries := 30
|
||||
for i, v := range created {
|
||||
ready, err := hw.c.IsReady(ctx, v)
|
||||
|
||||
if waitRetries > 0 && hw.isRetryableError(err, v) {
|
||||
numberOfErrors[i]++
|
||||
if numberOfErrors[i] > waitRetries {
|
||||
slog.Debug("max number of retries reached", "resource", v.Name, "retries", numberOfErrors[i])
|
||||
return false, err
|
||||
}
|
||||
slog.Debug("retrying resource readiness", "resource", v.Name, "currentRetries", numberOfErrors[i]-1, "maxRetries", waitRetries)
|
||||
return false, nil
|
||||
}
|
||||
numberOfErrors[i] = 0
|
||||
if !ready {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (hw *legacyWaiter) isRetryableError(err error, resource *resource.Info) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
slog.Debug(
|
||||
"error received when checking resource status",
|
||||
slog.String("resource", resource.Name),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
if ev, ok := err.(*apierrors.StatusError); ok {
|
||||
statusCode := ev.Status().Code
|
||||
retryable := hw.isRetryableHTTPStatusCode(statusCode)
|
||||
slog.Debug(
|
||||
"status code received",
|
||||
slog.String("resource", resource.Name),
|
||||
slog.Int("statusCode", int(statusCode)),
|
||||
slog.Bool("retryable", retryable),
|
||||
)
|
||||
return retryable
|
||||
}
|
||||
slog.Debug("retryable error assumed", "resource", resource.Name)
|
||||
return true
|
||||
}
|
||||
|
||||
func (hw *legacyWaiter) isRetryableHTTPStatusCode(httpStatusCode int32) bool {
|
||||
return httpStatusCode == 0 || httpStatusCode == http.StatusTooManyRequests || (httpStatusCode >= 500 && httpStatusCode != http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// WaitForDelete polls to check if all the resources are deleted or a timeout is reached
|
||||
func (hw *legacyWaiter) WaitForDelete(deleted ResourceList, timeout time.Duration) error {
|
||||
slog.Debug("beginning wait for resources to be deleted", "count", len(deleted), "timeout", timeout)
|
||||
|
||||
startTime := time.Now()
|
||||
ctx, cancel := hw.contextWithTimeout(timeout)
|
||||
defer cancel()
|
||||
|
||||
err := wait.PollUntilContextCancel(ctx, 2*time.Second, true, func(_ context.Context) (bool, error) {
|
||||
for _, v := range deleted {
|
||||
err := v.Get()
|
||||
if err == nil || !apierrors.IsNotFound(err) {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
elapsed := time.Since(startTime).Round(time.Second)
|
||||
if err != nil {
|
||||
slog.Debug("wait for resources failed", slog.Duration("elapsed", elapsed), slog.Any("error", err))
|
||||
} else {
|
||||
slog.Debug("wait for resources succeeded", slog.Duration("elapsed", elapsed))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// SelectorsForObject returns the pod label selector for a given object
|
||||
//
|
||||
// Modified version of https://github.com/kubernetes/kubernetes/blob/v1.14.1/pkg/kubectl/polymorphichelpers/helpers.go#L84
|
||||
func SelectorsForObject(object runtime.Object) (selector labels.Selector, err error) {
|
||||
switch t := object.(type) {
|
||||
case *extensionsv1beta1.ReplicaSet:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *appsv1.ReplicaSet:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *appsv1beta2.ReplicaSet:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *corev1.ReplicationController:
|
||||
selector = labels.SelectorFromSet(t.Spec.Selector)
|
||||
case *appsv1.StatefulSet:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *appsv1beta1.StatefulSet:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *appsv1beta2.StatefulSet:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *extensionsv1beta1.DaemonSet:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *appsv1.DaemonSet:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *appsv1beta2.DaemonSet:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *extensionsv1beta1.Deployment:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *appsv1.Deployment:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *appsv1beta1.Deployment:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *appsv1beta2.Deployment:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *batchv1.Job:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *corev1.Service:
|
||||
if len(t.Spec.Selector) == 0 {
|
||||
return nil, fmt.Errorf("invalid service '%s': Service is defined without a selector", t.Name)
|
||||
}
|
||||
selector = labels.SelectorFromSet(t.Spec.Selector)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("selector for %T not implemented", object)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return selector, fmt.Errorf("invalid label selector: %w", err)
|
||||
}
|
||||
|
||||
return selector, nil
|
||||
}
|
||||
|
||||
func (hw *legacyWaiter) watchTimeout(t time.Duration) func(*resource.Info) error {
|
||||
return func(info *resource.Info) error {
|
||||
return hw.watchUntilReady(t, info)
|
||||
}
|
||||
}
|
||||
|
||||
// WatchUntilReady watches the resources given and waits until it is ready.
|
||||
//
|
||||
// This method is mainly for hook implementations. It watches for a resource to
|
||||
// hit a particular milestone. The milestone depends on the Kind.
|
||||
//
|
||||
// For most kinds, it checks to see if the resource is marked as Added or Modified
|
||||
// by the Kubernetes event stream. For some kinds, it does more:
|
||||
//
|
||||
// - Jobs: A job is marked "Ready" when it has successfully completed. This is
|
||||
// ascertained by watching the Status fields in a job's output.
|
||||
// - Pods: A pod is marked "Ready" when it has successfully completed. This is
|
||||
// ascertained by watching the status.phase field in a pod's output.
|
||||
//
|
||||
// Handling for other kinds will be added as necessary.
|
||||
func (hw *legacyWaiter) WatchUntilReady(resources ResourceList, timeout time.Duration) error {
|
||||
// For jobs, there's also the option to do poll c.Jobs(namespace).Get():
|
||||
// https://github.com/adamreese/kubernetes/blob/master/test/e2e/job.go#L291-L300
|
||||
return perform(resources, hw.watchTimeout(timeout))
|
||||
}
|
||||
|
||||
func (hw *legacyWaiter) watchUntilReady(timeout time.Duration, info *resource.Info) error {
|
||||
kind := info.Mapping.GroupVersionKind.Kind
|
||||
switch kind {
|
||||
case "Job", "Pod":
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
slog.Debug("watching for resource changes", "kind", kind, "resource", info.Name, "timeout", timeout)
|
||||
|
||||
// Use a selector on the name of the resource. This should be unique for the
|
||||
// given version and kind
|
||||
selector, err := fields.ParseSelector(fmt.Sprintf("metadata.name=%s", info.Name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lw := cachetools.NewListWatchFromClient(info.Client, info.Mapping.Resource.Resource, info.Namespace, selector)
|
||||
|
||||
// What we watch for depends on the Kind.
|
||||
// - For a Job, we watch for completion.
|
||||
// - For all else, we watch until Ready.
|
||||
// In the future, we might want to add some special logic for types
|
||||
// like Ingress, Volume, etc.
|
||||
|
||||
ctx, cancel := hw.contextWithTimeout(timeout)
|
||||
defer cancel()
|
||||
_, err = watchtools.UntilWithSync(ctx, lw, &unstructured.Unstructured{}, nil, func(e watch.Event) (bool, error) {
|
||||
// Make sure the incoming object is versioned as we use unstructured
|
||||
// objects when we build manifests
|
||||
obj := convertWithMapper(e.Object, info.Mapping)
|
||||
switch e.Type {
|
||||
case watch.Added, watch.Modified:
|
||||
// For things like a secret or a config map, this is the best indicator
|
||||
// we get. We care mostly about jobs, where what we want to see is
|
||||
// the status go into a good state. For other types, like ReplicaSet
|
||||
// we don't really do anything to support these as hooks.
|
||||
slog.Debug("add/modify event received", "resource", info.Name, "eventType", e.Type)
|
||||
|
||||
switch kind {
|
||||
case "Job":
|
||||
return hw.waitForJob(obj, info.Name)
|
||||
case "Pod":
|
||||
return hw.waitForPodSuccess(obj, info.Name)
|
||||
}
|
||||
return true, nil
|
||||
case watch.Deleted:
|
||||
slog.Debug("deleted event received", "resource", info.Name)
|
||||
return true, nil
|
||||
case watch.Error:
|
||||
// Handle error and return with an error.
|
||||
slog.Error("error event received", "resource", info.Name)
|
||||
return true, fmt.Errorf("failed to deploy %s", info.Name)
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// waitForJob is a helper that waits for a job to complete.
|
||||
//
|
||||
// This operates on an event returned from a watcher.
|
||||
func (hw *legacyWaiter) waitForJob(obj runtime.Object, name string) (bool, error) {
|
||||
o, ok := obj.(*batchv1.Job)
|
||||
if !ok {
|
||||
return true, fmt.Errorf("expected %s to be a *batch.Job, got %T", name, obj)
|
||||
}
|
||||
|
||||
for _, c := range o.Status.Conditions {
|
||||
if c.Type == batchv1.JobComplete && c.Status == "True" {
|
||||
return true, nil
|
||||
} else if c.Type == batchv1.JobFailed && c.Status == "True" {
|
||||
slog.Error("job failed", "job", name, "reason", c.Reason)
|
||||
return true, fmt.Errorf("job %s failed: %s", name, c.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Debug("job status update", "job", name, "active", o.Status.Active, "failed", o.Status.Failed, "succeeded", o.Status.Succeeded)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// waitForPodSuccess is a helper that waits for a pod to complete.
|
||||
//
|
||||
// This operates on an event returned from a watcher.
|
||||
func (hw *legacyWaiter) waitForPodSuccess(obj runtime.Object, name string) (bool, error) {
|
||||
o, ok := obj.(*corev1.Pod)
|
||||
if !ok {
|
||||
return true, fmt.Errorf("expected %s to be a *v1.Pod, got %T", name, obj)
|
||||
}
|
||||
|
||||
switch o.Status.Phase {
|
||||
case corev1.PodSucceeded:
|
||||
slog.Debug("pod succeeded", "pod", o.Name)
|
||||
return true, nil
|
||||
case corev1.PodFailed:
|
||||
slog.Error("pod failed", "pod", o.Name)
|
||||
return true, fmt.Errorf("pod %s failed", o.Name)
|
||||
case corev1.PodPending:
|
||||
slog.Debug("pod pending", "pod", o.Name)
|
||||
case corev1.PodRunning:
|
||||
slog.Debug("pod running", "pod", o.Name)
|
||||
case corev1.PodUnknown:
|
||||
slog.Debug("pod unknown", "pod", o.Name)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (hw *legacyWaiter) contextWithTimeout(timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
return contextWithTimeout(hw.ctx, timeout)
|
||||
}
|
||||
+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"`)
|
||||
}
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
/*
|
||||
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 repo // import "helm.sh/helm/v4/pkg/repo/v1"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"helm.sh/helm/v4/internal/fileutil"
|
||||
"helm.sh/helm/v4/pkg/getter"
|
||||
"helm.sh/helm/v4/pkg/helmpath"
|
||||
)
|
||||
|
||||
// Entry represents a collection of parameters for chart repository
|
||||
type Entry struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
CertFile string `json:"certFile"`
|
||||
KeyFile string `json:"keyFile"`
|
||||
CAFile string `json:"caFile"`
|
||||
InsecureSkipTLSVerify bool `json:"insecure_skip_tls_verify"`
|
||||
PassCredentialsAll bool `json:"pass_credentials_all"`
|
||||
}
|
||||
|
||||
// ChartRepository represents a chart repository
|
||||
type ChartRepository struct {
|
||||
Config *Entry
|
||||
IndexFile *IndexFile
|
||||
Client getter.Getter
|
||||
CachePath string
|
||||
}
|
||||
|
||||
// NewChartRepository constructs ChartRepository
|
||||
func NewChartRepository(cfg *Entry, getters getter.Providers) (*ChartRepository, error) {
|
||||
u, err := url.Parse(cfg.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid chart URL format: %s", cfg.URL)
|
||||
}
|
||||
|
||||
client, err := getters.ByScheme(u.Scheme)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not find protocol handler for: %s", u.Scheme)
|
||||
}
|
||||
|
||||
return &ChartRepository{
|
||||
Config: cfg,
|
||||
IndexFile: NewIndexFile(),
|
||||
Client: client,
|
||||
CachePath: helmpath.CachePath("repository"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DownloadIndexFile fetches the index from a repository.
|
||||
func (r *ChartRepository) DownloadIndexFile() (string, error) {
|
||||
indexURL, err := ResolveReferenceURL(r.Config.URL, "index.yaml")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := r.Client.Get(indexURL,
|
||||
getter.WithURL(r.Config.URL),
|
||||
getter.WithInsecureSkipVerifyTLS(r.Config.InsecureSkipTLSVerify),
|
||||
getter.WithTLSClientConfig(r.Config.CertFile, r.Config.KeyFile, r.Config.CAFile),
|
||||
getter.WithBasicAuth(r.Config.Username, r.Config.Password),
|
||||
getter.WithPassCredentialsAll(r.Config.PassCredentialsAll),
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
index, err := io.ReadAll(resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
indexFile, err := loadIndex(index, r.Config.URL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Create the chart list file in the cache directory
|
||||
var charts strings.Builder
|
||||
for name := range indexFile.Entries {
|
||||
fmt.Fprintln(&charts, name)
|
||||
}
|
||||
chartsFile := filepath.Join(r.CachePath, helmpath.CacheChartsFile(r.Config.Name))
|
||||
os.MkdirAll(filepath.Dir(chartsFile), 0755)
|
||||
|
||||
fileutil.AtomicWriteFile(chartsFile, bytes.NewReader([]byte(charts.String())), 0644)
|
||||
|
||||
// Create the index file in the cache directory
|
||||
fname := filepath.Join(r.CachePath, helmpath.CacheIndexFile(r.Config.Name))
|
||||
os.MkdirAll(filepath.Dir(fname), 0755)
|
||||
return fname, fileutil.AtomicWriteFile(fname, bytes.NewReader(index), 0644)
|
||||
}
|
||||
|
||||
type findChartInRepoURLOptions struct {
|
||||
Username string
|
||||
Password string
|
||||
PassCredentialsAll bool
|
||||
InsecureSkipTLSVerify bool
|
||||
CertFile string
|
||||
KeyFile string
|
||||
CAFile string
|
||||
ChartVersion string
|
||||
}
|
||||
|
||||
type FindChartInRepoURLOption func(*findChartInRepoURLOptions)
|
||||
|
||||
// WithChartVersion specifies the chart version to find
|
||||
func WithChartVersion(chartVersion string) FindChartInRepoURLOption {
|
||||
return func(options *findChartInRepoURLOptions) {
|
||||
options.ChartVersion = chartVersion
|
||||
}
|
||||
}
|
||||
|
||||
// WithUsernamePassword specifies the username/password credntials for the repository
|
||||
func WithUsernamePassword(username, password string) FindChartInRepoURLOption {
|
||||
return func(options *findChartInRepoURLOptions) {
|
||||
options.Username = username
|
||||
options.Password = password
|
||||
}
|
||||
}
|
||||
|
||||
// WithPassCredentialsAll flags whether credentials should be passed on to other domains
|
||||
func WithPassCredentialsAll(passCredentialsAll bool) FindChartInRepoURLOption {
|
||||
return func(options *findChartInRepoURLOptions) {
|
||||
options.PassCredentialsAll = passCredentialsAll
|
||||
}
|
||||
}
|
||||
|
||||
// WithClientTLS species the cert, key, and CA files for client mTLS
|
||||
func WithClientTLS(certFile, keyFile, caFile string) FindChartInRepoURLOption {
|
||||
return func(options *findChartInRepoURLOptions) {
|
||||
options.CertFile = certFile
|
||||
options.KeyFile = keyFile
|
||||
options.CAFile = caFile
|
||||
}
|
||||
}
|
||||
|
||||
// WithInsecureSkipTLSVerify skips TLS verification for repository communication
|
||||
func WithInsecureSkipTLSVerify(insecureSkipTLSVerify bool) FindChartInRepoURLOption {
|
||||
return func(options *findChartInRepoURLOptions) {
|
||||
options.InsecureSkipTLSVerify = insecureSkipTLSVerify
|
||||
}
|
||||
}
|
||||
|
||||
// FindChartInRepoURL finds chart in chart repository pointed by repoURL
|
||||
// without adding repo to repositories
|
||||
func FindChartInRepoURL(repoURL string, chartName string, getters getter.Providers, options ...FindChartInRepoURLOption) (string, error) {
|
||||
|
||||
opts := findChartInRepoURLOptions{}
|
||||
for _, option := range options {
|
||||
option(&opts)
|
||||
}
|
||||
|
||||
// Download and write the index file to a temporary location
|
||||
buf := make([]byte, 20)
|
||||
rand.Read(buf)
|
||||
name := strings.ReplaceAll(base64.StdEncoding.EncodeToString(buf), "/", "-")
|
||||
|
||||
c := Entry{
|
||||
URL: repoURL,
|
||||
Username: opts.Username,
|
||||
Password: opts.Password,
|
||||
PassCredentialsAll: opts.PassCredentialsAll,
|
||||
CertFile: opts.CertFile,
|
||||
KeyFile: opts.KeyFile,
|
||||
CAFile: opts.CAFile,
|
||||
Name: name,
|
||||
InsecureSkipTLSVerify: opts.InsecureSkipTLSVerify,
|
||||
}
|
||||
r, err := NewChartRepository(&c, getters)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
idx, err := r.DownloadIndexFile()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("looks like %q is not a valid chart repository or cannot be reached: %w", repoURL, err)
|
||||
}
|
||||
defer func() {
|
||||
os.RemoveAll(filepath.Join(r.CachePath, helmpath.CacheChartsFile(r.Config.Name)))
|
||||
os.RemoveAll(filepath.Join(r.CachePath, helmpath.CacheIndexFile(r.Config.Name)))
|
||||
}()
|
||||
|
||||
// Read the index file for the repository to get chart information and return chart URL
|
||||
repoIndex, err := LoadIndexFile(idx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
errMsg := fmt.Sprintf("chart %q", chartName)
|
||||
if opts.ChartVersion != "" {
|
||||
errMsg = fmt.Sprintf("%s version %q", errMsg, opts.ChartVersion)
|
||||
}
|
||||
cv, err := repoIndex.Get(chartName, opts.ChartVersion)
|
||||
if err != nil {
|
||||
return "", ChartNotFoundError{
|
||||
Chart: errMsg,
|
||||
RepoURL: repoURL,
|
||||
}
|
||||
}
|
||||
|
||||
if len(cv.URLs) == 0 {
|
||||
return "", fmt.Errorf("%s has no downloadable URLs", errMsg)
|
||||
}
|
||||
|
||||
chartURL := cv.URLs[0]
|
||||
|
||||
absoluteChartURL, err := ResolveReferenceURL(repoURL, chartURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to make chart URL absolute: %w", err)
|
||||
}
|
||||
|
||||
return absoluteChartURL, nil
|
||||
}
|
||||
|
||||
// ResolveReferenceURL resolves refURL relative to baseURL.
|
||||
// If refURL is absolute, it simply returns refURL.
|
||||
func ResolveReferenceURL(baseURL, refURL string) (string, error) {
|
||||
parsedRefURL, err := url.Parse(refURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse %s as URL: %w", refURL, err)
|
||||
}
|
||||
|
||||
if parsedRefURL.IsAbs() {
|
||||
return refURL, nil
|
||||
}
|
||||
|
||||
parsedBaseURL, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse %s as URL: %w", baseURL, err)
|
||||
}
|
||||
|
||||
// We need a trailing slash for ResolveReference to work, but make sure there isn't already one
|
||||
parsedBaseURL.RawPath = strings.TrimSuffix(parsedBaseURL.RawPath, "/") + "/"
|
||||
parsedBaseURL.Path = strings.TrimSuffix(parsedBaseURL.Path, "/") + "/"
|
||||
|
||||
resolvedURL := parsedBaseURL.ResolveReference(parsedRefURL)
|
||||
resolvedURL.RawQuery = parsedBaseURL.RawQuery
|
||||
return resolvedURL.String(), nil
|
||||
}
|
||||
|
||||
func (e *Entry) String() string {
|
||||
buf, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
slog.Error("failed to marshal entry", slog.Any("error", err))
|
||||
panic(err)
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
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 repo implements the Helm Chart Repository.
|
||||
|
||||
A chart repository is an HTTP server that provides information on charts. A local
|
||||
repository cache is an on-disk representation of a chart repository.
|
||||
|
||||
There are two important file formats for chart repositories.
|
||||
|
||||
The first is the 'index.yaml' format, which is expressed like this:
|
||||
|
||||
apiVersion: v1
|
||||
entries:
|
||||
frobnitz:
|
||||
- created: 2016-09-29T12:14:34.830161306-06:00
|
||||
description: This is a frobnitz.
|
||||
digest: 587bd19a9bd9d2bc4a6d25ab91c8c8e7042c47b4ac246e37bf8e1e74386190f4
|
||||
home: http://example.com
|
||||
keywords:
|
||||
- frobnitz
|
||||
- sprocket
|
||||
- dodad
|
||||
maintainers:
|
||||
- email: helm@example.com
|
||||
name: The Helm Team
|
||||
- email: nobody@example.com
|
||||
name: Someone Else
|
||||
name: frobnitz
|
||||
urls:
|
||||
- http://example-charts.com/testdata/repository/frobnitz-1.2.3.tgz
|
||||
version: 1.2.3
|
||||
sprocket:
|
||||
- created: 2016-09-29T12:14:34.830507606-06:00
|
||||
description: This is a sprocket"
|
||||
digest: 8505ff813c39502cc849a38e1e4a8ac24b8e6e1dcea88f4c34ad9b7439685ae6
|
||||
home: http://example.com
|
||||
keywords:
|
||||
- frobnitz
|
||||
- sprocket
|
||||
- dodad
|
||||
maintainers:
|
||||
- email: helm@example.com
|
||||
name: The Helm Team
|
||||
- email: nobody@example.com
|
||||
name: Someone Else
|
||||
name: sprocket
|
||||
urls:
|
||||
- http://example-charts.com/testdata/repository/sprocket-1.2.0.tgz
|
||||
version: 1.2.0
|
||||
generated: 2016-09-29T12:14:34.829721375-06:00
|
||||
|
||||
An index.yaml file contains the necessary descriptive information about what
|
||||
charts are available in a repository, and how to get them.
|
||||
|
||||
The second file format is the repositories.yaml file format. This file is for
|
||||
facilitating local cached copies of one or more chart repositories.
|
||||
|
||||
The format of a repository.yaml file is:
|
||||
|
||||
apiVersion: v1
|
||||
generated: TIMESTAMP
|
||||
repositories:
|
||||
- name: stable
|
||||
url: http://example.com/charts
|
||||
cache: stable-index.yaml
|
||||
- name: incubator
|
||||
url: http://example.com/incubator
|
||||
cache: incubator-index.yaml
|
||||
|
||||
This file maps three bits of information about a repository:
|
||||
|
||||
- The name the user uses to refer to it
|
||||
- The fully qualified URL to the repository (index.yaml will be appended)
|
||||
- The name of the local cachefile
|
||||
|
||||
The format for both files was changed after Helm v2.0.0-Alpha.4. Helm is not
|
||||
backwards compatible with those earlier versions.
|
||||
*/
|
||||
package repo
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
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 repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type ChartNotFoundError struct {
|
||||
RepoURL string
|
||||
Chart string
|
||||
}
|
||||
|
||||
func (e ChartNotFoundError) Error() string {
|
||||
return fmt.Sprintf("%s not found in %s repository", e.Chart, e.RepoURL)
|
||||
}
|
||||
|
||||
func (e ChartNotFoundError) Is(err error) bool {
|
||||
_, ok := err.(ChartNotFoundError)
|
||||
return ok
|
||||
}
|
||||
+419
@@ -0,0 +1,419 @@
|
||||
/*
|
||||
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 repo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"sigs.k8s.io/yaml"
|
||||
|
||||
"helm.sh/helm/v4/internal/fileutil"
|
||||
"helm.sh/helm/v4/internal/urlutil"
|
||||
chart "helm.sh/helm/v4/pkg/chart/v2"
|
||||
"helm.sh/helm/v4/pkg/chart/v2/loader"
|
||||
"helm.sh/helm/v4/pkg/provenance"
|
||||
)
|
||||
|
||||
// APIVersionV1 is the v1 API version for index and repository files.
|
||||
const APIVersionV1 = "v1"
|
||||
|
||||
var (
|
||||
// ErrNoAPIVersion indicates that an API version was not specified.
|
||||
ErrNoAPIVersion = errors.New("no API version specified")
|
||||
// ErrNoChartVersion indicates that a chart with the given version is not found.
|
||||
ErrNoChartVersion = errors.New("no chart version found")
|
||||
// ErrNoChartName indicates that a chart with the given name is not found.
|
||||
ErrNoChartName = errors.New("no chart name found")
|
||||
// ErrEmptyIndexYaml indicates that the content of index.yaml is empty.
|
||||
ErrEmptyIndexYaml = errors.New("empty index.yaml file")
|
||||
)
|
||||
|
||||
// ChartVersions is a list of versioned chart references.
|
||||
// Implements a sorter on Version.
|
||||
type ChartVersions []*ChartVersion
|
||||
|
||||
// Len returns the length.
|
||||
func (c ChartVersions) Len() int { return len(c) }
|
||||
|
||||
// Swap swaps the position of two items in the versions slice.
|
||||
func (c ChartVersions) Swap(i, j int) { c[i], c[j] = c[j], c[i] }
|
||||
|
||||
// Less returns true if the version of entry a is less than the version of entry b.
|
||||
func (c ChartVersions) Less(a, b int) bool {
|
||||
// Failed parse pushes to the back.
|
||||
i, err := semver.NewVersion(c[a].Version)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
j, err := semver.NewVersion(c[b].Version)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return i.LessThan(j)
|
||||
}
|
||||
|
||||
// IndexFile represents the index file in a chart repository
|
||||
type IndexFile struct {
|
||||
// This is used ONLY for validation against chartmuseum's index files and is discarded after validation.
|
||||
ServerInfo map[string]interface{} `json:"serverInfo,omitempty"`
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Generated time.Time `json:"generated"`
|
||||
Entries map[string]ChartVersions `json:"entries"`
|
||||
PublicKeys []string `json:"publicKeys,omitempty"`
|
||||
|
||||
// Annotations are additional mappings uninterpreted by Helm. They are made available for
|
||||
// other applications to add information to the index file.
|
||||
Annotations map[string]string `json:"annotations,omitempty"`
|
||||
}
|
||||
|
||||
// NewIndexFile initializes an index.
|
||||
func NewIndexFile() *IndexFile {
|
||||
return &IndexFile{
|
||||
APIVersion: APIVersionV1,
|
||||
Generated: time.Now(),
|
||||
Entries: map[string]ChartVersions{},
|
||||
PublicKeys: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
// LoadIndexFile takes a file at the given path and returns an IndexFile object
|
||||
func LoadIndexFile(path string) (*IndexFile, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
i, err := loadIndex(b, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading %s: %w", path, err)
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
// MustAdd adds a file to the index
|
||||
// This can leave the index in an unsorted state
|
||||
func (i IndexFile) MustAdd(md *chart.Metadata, filename, baseURL, digest string) error {
|
||||
if i.Entries == nil {
|
||||
return errors.New("entries not initialized")
|
||||
}
|
||||
|
||||
if md.APIVersion == "" {
|
||||
md.APIVersion = chart.APIVersionV1
|
||||
}
|
||||
if err := md.Validate(); err != nil {
|
||||
return fmt.Errorf("validate failed for %s: %w", filename, err)
|
||||
}
|
||||
|
||||
u := filename
|
||||
if baseURL != "" {
|
||||
_, file := filepath.Split(filename)
|
||||
var err error
|
||||
u, err = urlutil.URLJoin(baseURL, file)
|
||||
if err != nil {
|
||||
u = path.Join(baseURL, file)
|
||||
}
|
||||
}
|
||||
cr := &ChartVersion{
|
||||
URLs: []string{u},
|
||||
Metadata: md,
|
||||
Digest: digest,
|
||||
Created: time.Now(),
|
||||
}
|
||||
ee := i.Entries[md.Name]
|
||||
i.Entries[md.Name] = append(ee, cr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add adds a file to the index and logs an error.
|
||||
//
|
||||
// Deprecated: Use index.MustAdd instead.
|
||||
func (i IndexFile) Add(md *chart.Metadata, filename, baseURL, digest string) {
|
||||
if err := i.MustAdd(md, filename, baseURL, digest); err != nil {
|
||||
slog.Error("skipping loading invalid entry for chart %q %q from %s: %s", md.Name, md.Version, filename, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Has returns true if the index has an entry for a chart with the given name and exact version.
|
||||
func (i IndexFile) Has(name, version string) bool {
|
||||
_, err := i.Get(name, version)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// SortEntries sorts the entries by version in descending order.
|
||||
//
|
||||
// In canonical form, the individual version records should be sorted so that
|
||||
// the most recent release for every version is in the 0th slot in the
|
||||
// Entries.ChartVersions array. That way, tooling can predict the newest
|
||||
// version without needing to parse SemVers.
|
||||
func (i IndexFile) SortEntries() {
|
||||
for _, versions := range i.Entries {
|
||||
sort.Sort(sort.Reverse(versions))
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the ChartVersion for the given name.
|
||||
//
|
||||
// If version is empty, this will return the chart with the latest stable version,
|
||||
// prerelease versions will be skipped.
|
||||
func (i IndexFile) Get(name, version string) (*ChartVersion, error) {
|
||||
vs, ok := i.Entries[name]
|
||||
if !ok {
|
||||
return nil, ErrNoChartName
|
||||
}
|
||||
if len(vs) == 0 {
|
||||
return nil, ErrNoChartVersion
|
||||
}
|
||||
|
||||
var constraint *semver.Constraints
|
||||
if version == "" {
|
||||
constraint, _ = semver.NewConstraint("*")
|
||||
} else {
|
||||
var err error
|
||||
constraint, err = semver.NewConstraint(version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// when customer inputs specific version, check whether there's an exact match first
|
||||
if len(version) != 0 {
|
||||
for _, ver := range vs {
|
||||
if version == ver.Version {
|
||||
return ver, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, ver := range vs {
|
||||
test, err := semver.NewVersion(ver.Version)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if constraint.Check(test) {
|
||||
if len(version) != 0 {
|
||||
slog.Warn("unable to find exact version requested; falling back to closest available version", "chart", name, "requested", version, "selected", ver.Version)
|
||||
}
|
||||
return ver, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no chart version found for %s-%s", name, version)
|
||||
}
|
||||
|
||||
// WriteFile writes an index file to the given destination path.
|
||||
//
|
||||
// The mode on the file is set to 'mode'.
|
||||
func (i IndexFile) WriteFile(dest string, mode os.FileMode) error {
|
||||
b, err := yaml.Marshal(i)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.AtomicWriteFile(dest, bytes.NewReader(b), mode)
|
||||
}
|
||||
|
||||
// WriteJSONFile writes an index file in JSON format to the given destination
|
||||
// path.
|
||||
//
|
||||
// The mode on the file is set to 'mode'.
|
||||
func (i IndexFile) WriteJSONFile(dest string, mode os.FileMode) error {
|
||||
b, err := json.MarshalIndent(i, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.AtomicWriteFile(dest, bytes.NewReader(b), mode)
|
||||
}
|
||||
|
||||
// Merge merges the given index file into this index.
|
||||
//
|
||||
// This merges by name and version.
|
||||
//
|
||||
// If one of the entries in the given index does _not_ already exist, it is added.
|
||||
// In all other cases, the existing record is preserved.
|
||||
//
|
||||
// This can leave the index in an unsorted state
|
||||
func (i *IndexFile) Merge(f *IndexFile) {
|
||||
for _, cvs := range f.Entries {
|
||||
for _, cv := range cvs {
|
||||
if !i.Has(cv.Name, cv.Version) {
|
||||
e := i.Entries[cv.Name]
|
||||
i.Entries[cv.Name] = append(e, cv)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ChartVersion represents a chart entry in the IndexFile
|
||||
type ChartVersion struct {
|
||||
*chart.Metadata
|
||||
URLs []string `json:"urls"`
|
||||
Created time.Time `json:"created,omitempty"`
|
||||
Removed bool `json:"removed,omitempty"`
|
||||
Digest string `json:"digest,omitempty"`
|
||||
|
||||
// ChecksumDeprecated is deprecated in Helm 3, and therefore ignored. Helm 3 replaced
|
||||
// this with Digest. However, with a strict YAML parser enabled, a field must be
|
||||
// present on the struct for backwards compatibility.
|
||||
ChecksumDeprecated string `json:"checksum,omitempty"`
|
||||
|
||||
// EngineDeprecated is deprecated in Helm 3, and therefore ignored. However, with a strict
|
||||
// YAML parser enabled, this field must be present.
|
||||
EngineDeprecated string `json:"engine,omitempty"`
|
||||
|
||||
// TillerVersionDeprecated is deprecated in Helm 3, and therefore ignored. However, with a strict
|
||||
// YAML parser enabled, this field must be present.
|
||||
TillerVersionDeprecated string `json:"tillerVersion,omitempty"`
|
||||
|
||||
// URLDeprecated is deprecated in Helm 3, superseded by URLs. It is ignored. However,
|
||||
// with a strict YAML parser enabled, this must be present on the struct.
|
||||
URLDeprecated string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// IndexDirectory reads a (flat) directory and generates an index.
|
||||
//
|
||||
// It indexes only charts that have been packaged (*.tgz).
|
||||
//
|
||||
// The index returned will be in an unsorted state
|
||||
func IndexDirectory(dir, baseURL string) (*IndexFile, error) {
|
||||
archives, err := filepath.Glob(filepath.Join(dir, "*.tgz"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
moreArchives, err := filepath.Glob(filepath.Join(dir, "**/*.tgz"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
archives = append(archives, moreArchives...)
|
||||
|
||||
index := NewIndexFile()
|
||||
for _, arch := range archives {
|
||||
fname, err := filepath.Rel(dir, arch)
|
||||
if err != nil {
|
||||
return index, err
|
||||
}
|
||||
|
||||
var parentDir string
|
||||
parentDir, fname = filepath.Split(fname)
|
||||
// filepath.Split appends an extra slash to the end of parentDir. We want to strip that out.
|
||||
parentDir = strings.TrimSuffix(parentDir, string(os.PathSeparator))
|
||||
parentURL, err := urlutil.URLJoin(baseURL, parentDir)
|
||||
if err != nil {
|
||||
parentURL = path.Join(baseURL, parentDir)
|
||||
}
|
||||
|
||||
c, err := loader.Load(arch)
|
||||
if err != nil {
|
||||
// Assume this is not a chart.
|
||||
continue
|
||||
}
|
||||
hash, err := provenance.DigestFile(arch)
|
||||
if err != nil {
|
||||
return index, err
|
||||
}
|
||||
if err := index.MustAdd(c.Metadata, fname, parentURL, hash); err != nil {
|
||||
return index, fmt.Errorf("failed adding to %s to index: %w", fname, err)
|
||||
}
|
||||
}
|
||||
return index, nil
|
||||
}
|
||||
|
||||
// loadIndex loads an index file and does minimal validity checking.
|
||||
//
|
||||
// The source parameter is only used for logging.
|
||||
// This will fail if API Version is not set (ErrNoAPIVersion) or if the unmarshal fails.
|
||||
func loadIndex(data []byte, source string) (*IndexFile, error) {
|
||||
i := &IndexFile{}
|
||||
|
||||
if len(data) == 0 {
|
||||
return i, ErrEmptyIndexYaml
|
||||
}
|
||||
|
||||
if err := jsonOrYamlUnmarshal(data, i); err != nil {
|
||||
return i, err
|
||||
}
|
||||
|
||||
for name, cvs := range i.Entries {
|
||||
for idx := len(cvs) - 1; idx >= 0; idx-- {
|
||||
if cvs[idx] == nil {
|
||||
slog.Warn(fmt.Sprintf("skipping loading invalid entry for chart %q from %s: empty entry", name, source))
|
||||
cvs = append(cvs[:idx], cvs[idx+1:]...)
|
||||
continue
|
||||
}
|
||||
// When metadata section missing, initialize with no data
|
||||
if cvs[idx].Metadata == nil {
|
||||
cvs[idx].Metadata = &chart.Metadata{}
|
||||
}
|
||||
if cvs[idx].APIVersion == "" {
|
||||
cvs[idx].APIVersion = chart.APIVersionV1
|
||||
}
|
||||
if err := cvs[idx].Validate(); ignoreSkippableChartValidationError(err) != nil {
|
||||
slog.Warn(fmt.Sprintf("skipping loading invalid entry for chart %q %q from %s: %s", name, cvs[idx].Version, source, err))
|
||||
cvs = append(cvs[:idx], cvs[idx+1:]...)
|
||||
}
|
||||
}
|
||||
// adjust slice to only contain a set of valid versions
|
||||
i.Entries[name] = cvs
|
||||
}
|
||||
i.SortEntries()
|
||||
if i.APIVersion == "" {
|
||||
return i, ErrNoAPIVersion
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
// jsonOrYamlUnmarshal unmarshals the given byte slice containing JSON or YAML
|
||||
// into the provided interface.
|
||||
//
|
||||
// It automatically detects whether the data is in JSON or YAML format by
|
||||
// checking its validity as JSON. If the data is valid JSON, it will use the
|
||||
// `encoding/json` package to unmarshal it. Otherwise, it will use the
|
||||
// `sigs.k8s.io/yaml` package to unmarshal the YAML data.
|
||||
func jsonOrYamlUnmarshal(b []byte, i interface{}) error {
|
||||
if json.Valid(b) {
|
||||
return json.Unmarshal(b, i)
|
||||
}
|
||||
return yaml.UnmarshalStrict(b, i)
|
||||
}
|
||||
|
||||
// ignoreSkippableChartValidationError inspect the given error and returns nil if
|
||||
// the error isn't important for index loading
|
||||
//
|
||||
// In particular, charts may introduce validations that don't impact repository indexes
|
||||
// And repository indexes may be generated by older/non-compliant software, which doesn't
|
||||
// conform to all validations.
|
||||
func ignoreSkippableChartValidationError(err error) error {
|
||||
verr, ok := err.(chart.ValidationError)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
// https://github.com/helm/helm/issues/12748 (JFrog repository strips alias field)
|
||||
if strings.HasPrefix(verr.Error(), "validation: more than one dependency with name or alias") {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
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 repo // import "helm.sh/helm/v4/pkg/repo/v1"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"sigs.k8s.io/yaml"
|
||||
)
|
||||
|
||||
// File represents the repositories.yaml file
|
||||
type File struct {
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Generated time.Time `json:"generated"`
|
||||
Repositories []*Entry `json:"repositories"`
|
||||
}
|
||||
|
||||
// NewFile generates an empty repositories file.
|
||||
//
|
||||
// Generated and APIVersion are automatically set.
|
||||
func NewFile() *File {
|
||||
return &File{
|
||||
APIVersion: APIVersionV1,
|
||||
Generated: time.Now(),
|
||||
Repositories: []*Entry{},
|
||||
}
|
||||
}
|
||||
|
||||
// LoadFile takes a file at the given path and returns a File object
|
||||
func LoadFile(path string) (*File, error) {
|
||||
r := new(File)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return r, fmt.Errorf("couldn't load repositories file (%s): %w", path, err)
|
||||
}
|
||||
|
||||
err = yaml.Unmarshal(b, r)
|
||||
return r, err
|
||||
}
|
||||
|
||||
// Add adds one or more repo entries to a repo file.
|
||||
func (r *File) Add(re ...*Entry) {
|
||||
r.Repositories = append(r.Repositories, re...)
|
||||
}
|
||||
|
||||
// Update attempts to replace one or more repo entries in a repo file. If an
|
||||
// entry with the same name doesn't exist in the repo file it will add it.
|
||||
func (r *File) Update(re ...*Entry) {
|
||||
for _, target := range re {
|
||||
r.update(target)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *File) update(e *Entry) {
|
||||
for j, repo := range r.Repositories {
|
||||
if repo.Name == e.Name {
|
||||
r.Repositories[j] = e
|
||||
return
|
||||
}
|
||||
}
|
||||
r.Add(e)
|
||||
}
|
||||
|
||||
// Has returns true if the given name is already a repository name.
|
||||
func (r *File) Has(name string) bool {
|
||||
entry := r.Get(name)
|
||||
return entry != nil
|
||||
}
|
||||
|
||||
// Get returns an entry with the given name if it exists, otherwise returns nil
|
||||
func (r *File) Get(name string) *Entry {
|
||||
for _, entry := range r.Repositories {
|
||||
if entry.Name == name {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove removes the entry from the list of repositories.
|
||||
func (r *File) Remove(name string) bool {
|
||||
cp := []*Entry{}
|
||||
found := false
|
||||
for _, rf := range r.Repositories {
|
||||
if rf == nil {
|
||||
continue
|
||||
}
|
||||
if rf.Name == name {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
cp = append(cp, rf)
|
||||
}
|
||||
r.Repositories = cp
|
||||
return found
|
||||
}
|
||||
|
||||
// WriteFile writes a repositories file to the given path.
|
||||
func (r *File) WriteFile(path string, perm os.FileMode) error {
|
||||
data, err := yaml.Marshal(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, perm)
|
||||
}
|
||||
Reference in New Issue
Block a user