working commit

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