working commit
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
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 fileutil
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"helm.sh/helm/v4/internal/third_party/dep/fs"
|
||||
)
|
||||
|
||||
// AtomicWriteFile atomically (as atomic as os.Rename allows) writes a file to a
|
||||
// disk.
|
||||
func AtomicWriteFile(filename string, reader io.Reader, mode os.FileMode) error {
|
||||
tempFile, err := os.CreateTemp(filepath.Split(filename))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempName := tempFile.Name()
|
||||
|
||||
if _, err := io.Copy(tempFile, reader); err != nil {
|
||||
tempFile.Close() // return value is ignored as we are already on error path
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tempFile.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.Chmod(tempName, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return fs.RenameWithFallback(tempName, filename)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//go:build !windows
|
||||
|
||||
/*
|
||||
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 fileutil
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// PlatformAtomicWriteFile atomically writes a file to disk.
|
||||
//
|
||||
// On non-Windows platforms we don't need extra coordination, so this simply
|
||||
// delegates to AtomicWriteFile to preserve the existing overwrite behaviour.
|
||||
func PlatformAtomicWriteFile(filename string, reader io.Reader, mode os.FileMode) error {
|
||||
return AtomicWriteFile(filename, reader, mode)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//go:build windows
|
||||
|
||||
/*
|
||||
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 fileutil
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/gofrs/flock"
|
||||
)
|
||||
|
||||
// PlatformAtomicWriteFile atomically writes a file to disk with file locking to
|
||||
// prevent concurrent writes. This is particularly useful on Windows where
|
||||
// concurrent writes to the same file can cause "Access Denied" errors.
|
||||
//
|
||||
// The function acquires a lock on the target file and performs an atomic write,
|
||||
// preserving the existing behaviour of overwriting any previous content once
|
||||
// the lock is obtained.
|
||||
func PlatformAtomicWriteFile(filename string, reader io.Reader, mode os.FileMode) error {
|
||||
// Use a separate lock file to coordinate access between processes
|
||||
// We cannot lock the target file directly as it would prevent the atomic rename
|
||||
lockFileName := filename + ".lock"
|
||||
fileLock := flock.New(lockFileName)
|
||||
|
||||
// Lock() ensures serialized access - if another process is writing, this will wait
|
||||
if err := fileLock.Lock(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
fileLock.Unlock()
|
||||
// Clean up the lock file
|
||||
// Ignore errors as the file might not exist or be in use by another process
|
||||
os.Remove(lockFileName)
|
||||
}()
|
||||
|
||||
// Perform the atomic write while holding the lock
|
||||
return AtomicWriteFile(filename, reader, mode)
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// DebugEnabledFunc is a function type that determines if debug logging is enabled
|
||||
// We use a function because we want to check the setting at log time, not when the logger is created
|
||||
type DebugEnabledFunc func() bool
|
||||
|
||||
// DebugCheckHandler checks settings.Debug at log time
|
||||
type DebugCheckHandler struct {
|
||||
handler slog.Handler
|
||||
debugEnabled DebugEnabledFunc
|
||||
}
|
||||
|
||||
// Enabled implements slog.Handler.Enabled
|
||||
func (h *DebugCheckHandler) Enabled(_ context.Context, level slog.Level) bool {
|
||||
if level == slog.LevelDebug {
|
||||
if h.debugEnabled == nil {
|
||||
return false
|
||||
}
|
||||
return h.debugEnabled()
|
||||
}
|
||||
return true // Always log other levels
|
||||
}
|
||||
|
||||
// Handle implements slog.Handler.Handle
|
||||
func (h *DebugCheckHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
return h.handler.Handle(ctx, r)
|
||||
}
|
||||
|
||||
// WithAttrs implements slog.Handler.WithAttrs
|
||||
func (h *DebugCheckHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return &DebugCheckHandler{
|
||||
handler: h.handler.WithAttrs(attrs),
|
||||
debugEnabled: h.debugEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
// WithGroup implements slog.Handler.WithGroup
|
||||
func (h *DebugCheckHandler) WithGroup(name string) slog.Handler {
|
||||
return &DebugCheckHandler{
|
||||
handler: h.handler.WithGroup(name),
|
||||
debugEnabled: h.debugEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
// NewLogger creates a new logger with dynamic debug checking
|
||||
func NewLogger(debugEnabled DebugEnabledFunc) *slog.Logger {
|
||||
// Create base handler that removes timestamps
|
||||
baseHandler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
// Always use LevelDebug here to allow all messages through
|
||||
// Our custom handler will do the filtering
|
||||
Level: slog.LevelDebug,
|
||||
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
|
||||
// Remove the time attribute
|
||||
if a.Key == slog.TimeKey {
|
||||
return slog.Attr{}
|
||||
}
|
||||
return a
|
||||
},
|
||||
})
|
||||
|
||||
// Wrap with our dynamic debug-checking handler
|
||||
dynamicHandler := &DebugCheckHandler{
|
||||
handler: baseHandler,
|
||||
debugEnabled: debugEnabled,
|
||||
}
|
||||
|
||||
return slog.New(dynamicHandler)
|
||||
}
|
||||
|
||||
// LoggerSetterGetter is an interface that can set and get a logger
|
||||
type LoggerSetterGetter interface {
|
||||
// SetLogger sets a new slog.Handler
|
||||
SetLogger(newHandler slog.Handler)
|
||||
// Logger returns the slog.Logger created from the slog.Handler
|
||||
Logger() *slog.Logger
|
||||
}
|
||||
|
||||
type LogHolder struct {
|
||||
// logger is an atomic.Pointer[slog.Logger] to store the slog.Logger
|
||||
// We use atomic.Pointer for thread safety
|
||||
logger atomic.Pointer[slog.Logger]
|
||||
}
|
||||
|
||||
// Logger returns the logger for the LogHolder. If nil, returns slog.Default().
|
||||
func (l *LogHolder) Logger() *slog.Logger {
|
||||
if lg := l.logger.Load(); lg != nil {
|
||||
return lg
|
||||
}
|
||||
return slog.New(slog.DiscardHandler) // Should never be reached
|
||||
}
|
||||
|
||||
// SetLogger sets the logger for the LogHolder. If nil, sets the default logger.
|
||||
func (l *LogHolder) SetLogger(newHandler slog.Handler) {
|
||||
if newHandler == nil {
|
||||
l.logger.Store(slog.New(slog.DiscardHandler)) // Assume nil as discarding logs
|
||||
return
|
||||
}
|
||||
l.logger.Store(slog.New(newHandler))
|
||||
}
|
||||
|
||||
// Ensure LogHolder implements LoggerSetterGetter
|
||||
var _ LoggerSetterGetter = &LogHolder{}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
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 plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
// Config represents a plugin type specific configuration
|
||||
// It is expected to type assert (cast) the Config to its expected underlying type (schema.ConfigCLIV1, schema.ConfigGetterV1, etc).
|
||||
type Config interface {
|
||||
Validate() error
|
||||
}
|
||||
|
||||
func unmarshalConfig(pluginType string, configData map[string]any) (Config, error) {
|
||||
pluginTypeMeta, ok := pluginTypesIndex[pluginType]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown plugin type %q", pluginType)
|
||||
}
|
||||
|
||||
// TODO: Avoid (yaml) serialization/deserialization for type conversion here
|
||||
|
||||
data, err := yaml.Marshal(configData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshel config data (plugin type %s): %w", pluginType, err)
|
||||
}
|
||||
|
||||
config := reflect.New(pluginTypeMeta.configType)
|
||||
d := yaml.NewDecoder(bytes.NewReader(data))
|
||||
d.KnownFields(true)
|
||||
if err := d.Decode(config.Interface()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config.Interface().(Config), nil
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
// Descriptor describes a plugin to find
|
||||
type Descriptor struct {
|
||||
// Name is the name of the plugin
|
||||
Name string
|
||||
// Type is the type of the plugin (cli, getter, postrenderer)
|
||||
Type string
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/*
|
||||
---
|
||||
TODO: move this section to public plugin package
|
||||
|
||||
Package plugin provides the implementation of the Helm plugin system.
|
||||
|
||||
Conceptually, "plugins" enable extending Helm's functionality external to Helm's core codebase. The plugin system allows
|
||||
code to fetch plugins by type, then invoke the plugin with an input as required by that plugin type. The plugin
|
||||
returning an output for the caller to consume.
|
||||
|
||||
An example of a plugin invocation:
|
||||
```
|
||||
d := plugin.Descriptor{
|
||||
Type: "example/v1", //
|
||||
}
|
||||
plgs, err := plugin.FindPlugins([]string{settings.PluginsDirectory}, d)
|
||||
|
||||
for _, plg := range plgs {
|
||||
input := &plugin.Input{
|
||||
Message: schema.InputMessageExampleV1{ // The type of the input message is defined by the plugin's "type" (example/v1 here)
|
||||
...
|
||||
},
|
||||
}
|
||||
output, err := plg.Invoke(context.Background(), input)
|
||||
if err != nil {
|
||||
...
|
||||
}
|
||||
|
||||
// consume the output, using type assertion to convert to the expected output type (as defined by the plugin's "type")
|
||||
outputMessage, ok := output.Message.(schema.OutputMessageExampleV1)
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
Package `plugin` provides the implementation of the Helm plugin system.
|
||||
|
||||
Helm plugins are exposed to uses as the "Plugin" type, the basic interface that primarily support the "Invoke" method.
|
||||
|
||||
# Plugin Runtimes
|
||||
Internally, plugins must be implemented by a "runtime" that is responsible for creating the plugin instance, and dispatching the plugin's invocation to the plugin's implementation.
|
||||
For example:
|
||||
- forming environment variables and command line args for subprocess execution
|
||||
- converting input to JSON and invoking a function in a Wasm runtime
|
||||
|
||||
Internally, the code structure is:
|
||||
Runtime.CreatePlugin()
|
||||
|
|
||||
| (creates)
|
||||
|
|
||||
\---> PluginRuntime
|
||||
|
|
||||
| (implements)
|
||||
v
|
||||
Plugin.Invoke()
|
||||
|
||||
# Plugin Types
|
||||
Each plugin implements a specific functionality, denoted by the plugin's "type" e.g. "getter/v1". The "type" includes a version, in order to allow a given types messaging schema and invocation options to evolve.
|
||||
|
||||
Specifically, the plugin's "type" specifies the contract for the input and output messages that are expected to be passed to the plugin, and returned from the plugin. The plugin's "type" also defines the options that can be passed to the plugin when invoking it.
|
||||
|
||||
# Metadata
|
||||
Each plugin must have a `plugin.yaml`, that defines the plugin's metadata. The metadata includes the plugin's name, version, and other information.
|
||||
|
||||
For legacy plugins, the type is inferred by which fields are set on the plugin: a downloader plugin is inferred when metadata contains a "downloaders" yaml node, otherwise it is assumed to define a Helm CLI subcommand.
|
||||
|
||||
For v1 plugins, the metadata includes explicit apiVersion and type fields. It will also contain type-specific Config, and RuntimeConfig fields.
|
||||
|
||||
# Runtime and type cardinality
|
||||
From a cardinality perspective, this means there a "few" runtimes, and "many" plugins types. It is also expected that the subprocess runtime will not be extended to support extra plugin types, and deprecated in a future version of Helm.
|
||||
|
||||
Future ideas that are intended to be implemented include extending the plugin system to support future Wasm standards. Or allowing Helm SDK user's to inject "plugins" that are actually implemented as native go modules. Or even moving Helm's internal functionality e.g. yaml rendering engine to be used as an "in-built" plugin, along side other plugins that may implement other (non-go template) rendering engines.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
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 plugin
|
||||
|
||||
// InvokeExecError is returned when a plugin invocation returns a non-zero status/exit code
|
||||
// - subprocess plugin: child process exit code
|
||||
// - extism plugin: wasm function return code
|
||||
type InvokeExecError struct {
|
||||
ExitCode int // Exit code from plugin code execution
|
||||
Err error // Underlying error
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
func (e *InvokeExecError) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
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 plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
"github.com/tetratelabs/wazero"
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"helm.sh/helm/v4/pkg/helmpath"
|
||||
)
|
||||
|
||||
func peekAPIVersion(r io.Reader) (string, error) {
|
||||
type apiVersion struct {
|
||||
APIVersion string `yaml:"apiVersion"`
|
||||
}
|
||||
|
||||
var v apiVersion
|
||||
d := yaml.NewDecoder(r)
|
||||
if err := d.Decode(&v); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return v.APIVersion, nil
|
||||
}
|
||||
|
||||
func loadMetadataLegacy(metadataData []byte) (*Metadata, error) {
|
||||
|
||||
var ml MetadataLegacy
|
||||
d := yaml.NewDecoder(bytes.NewReader(metadataData))
|
||||
// NOTE: No strict unmarshalling for legacy plugins - maintain backwards compatibility
|
||||
if err := d.Decode(&ml); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := ml.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m := fromMetadataLegacy(ml)
|
||||
if err := m.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func loadMetadataV1(metadataData []byte) (*Metadata, error) {
|
||||
|
||||
var mv1 MetadataV1
|
||||
d := yaml.NewDecoder(bytes.NewReader(metadataData))
|
||||
d.KnownFields(true)
|
||||
if err := d.Decode(&mv1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := mv1.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m, err := fromMetadataV1(mv1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert MetadataV1 to Metadata: %w", err)
|
||||
}
|
||||
|
||||
if err := m.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func loadMetadata(metadataData []byte) (*Metadata, error) {
|
||||
apiVersion, err := peekAPIVersion(bytes.NewReader(metadataData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to peek %s API version: %w", PluginFileName, err)
|
||||
}
|
||||
|
||||
switch apiVersion {
|
||||
case "": // legacy
|
||||
return loadMetadataLegacy(metadataData)
|
||||
case "v1":
|
||||
return loadMetadataV1(metadataData)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid plugin apiVersion: %q", apiVersion)
|
||||
}
|
||||
|
||||
type prototypePluginManager struct {
|
||||
runtimes map[string]Runtime
|
||||
}
|
||||
|
||||
func newPrototypePluginManager() (*prototypePluginManager, error) {
|
||||
|
||||
cc, err := wazero.NewCompilationCacheWithDir(helmpath.CachePath("wazero-build"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create wazero compilation cache: %w", err)
|
||||
}
|
||||
|
||||
return &prototypePluginManager{
|
||||
runtimes: map[string]Runtime{
|
||||
"subprocess": &RuntimeSubprocess{},
|
||||
"extism/v1": &RuntimeExtismV1{
|
||||
HostFunctions: map[string]extism.HostFunction{},
|
||||
CompilationCache: cc,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (pm *prototypePluginManager) RegisterRuntime(runtimeName string, runtime Runtime) {
|
||||
pm.runtimes[runtimeName] = runtime
|
||||
}
|
||||
|
||||
func (pm *prototypePluginManager) CreatePlugin(pluginPath string, metadata *Metadata) (Plugin, error) {
|
||||
rt, ok := pm.runtimes[metadata.Runtime]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported plugin runtime type: %q", metadata.Runtime)
|
||||
}
|
||||
|
||||
return rt.CreatePlugin(pluginPath, metadata)
|
||||
}
|
||||
|
||||
// LoadDir loads a plugin from the given directory.
|
||||
func LoadDir(dirname string) (Plugin, error) {
|
||||
pluginfile := filepath.Join(dirname, PluginFileName)
|
||||
metadataData, err := os.ReadFile(pluginfile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read plugin at %q: %w", pluginfile, err)
|
||||
}
|
||||
|
||||
m, err := loadMetadata(metadataData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load plugin %q: %w", dirname, err)
|
||||
}
|
||||
|
||||
pm, err := newPrototypePluginManager()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create plugin manager: %w", err)
|
||||
}
|
||||
return pm.CreatePlugin(dirname, m)
|
||||
}
|
||||
|
||||
// LoadAll loads all plugins found beneath the base directory.
|
||||
//
|
||||
// This scans only one directory level.
|
||||
func LoadAll(basedir string) ([]Plugin, error) {
|
||||
var plugins []Plugin
|
||||
// We want basedir/*/plugin.yaml
|
||||
scanpath := filepath.Join(basedir, "*", PluginFileName)
|
||||
matches, err := filepath.Glob(scanpath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to search for plugins in %q: %w", scanpath, err)
|
||||
}
|
||||
|
||||
// empty dir should load
|
||||
if len(matches) == 0 {
|
||||
return plugins, nil
|
||||
}
|
||||
|
||||
for _, yamlFile := range matches {
|
||||
dir := filepath.Dir(yamlFile)
|
||||
p, err := LoadDir(dir)
|
||||
if err != nil {
|
||||
return plugins, err
|
||||
}
|
||||
plugins = append(plugins, p)
|
||||
}
|
||||
return plugins, detectDuplicates(plugins)
|
||||
}
|
||||
|
||||
// findFunc is a function that finds plugins in a directory
|
||||
type findFunc func(pluginsDir string) ([]Plugin, error)
|
||||
|
||||
// filterFunc is a function that filters plugins
|
||||
type filterFunc func(Plugin) bool
|
||||
|
||||
// FindPlugins returns a list of plugins that match the descriptor
|
||||
func FindPlugins(pluginsDirs []string, descriptor Descriptor) ([]Plugin, error) {
|
||||
return findPlugins(pluginsDirs, LoadAll, makeDescriptorFilter(descriptor))
|
||||
}
|
||||
|
||||
// findPlugins is the internal implementation that uses the find and filter functions
|
||||
func findPlugins(pluginsDirs []string, findFn findFunc, filterFn filterFunc) ([]Plugin, error) {
|
||||
var found []Plugin
|
||||
for _, pluginsDir := range pluginsDirs {
|
||||
ps, err := findFn(pluginsDir)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, p := range ps {
|
||||
if filterFn(p) {
|
||||
found = append(found, p)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// makeDescriptorFilter creates a filter function from a descriptor
|
||||
// Additional plugin filter criteria we wish to support can be added here
|
||||
func makeDescriptorFilter(descriptor Descriptor) filterFunc {
|
||||
return func(p Plugin) bool {
|
||||
// If name is specified, it must match
|
||||
if descriptor.Name != "" && p.Metadata().Name != descriptor.Name {
|
||||
return false
|
||||
|
||||
}
|
||||
// If type is specified, it must match
|
||||
if descriptor.Type != "" && p.Metadata().Type != descriptor.Type {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// FindPlugin returns a single plugin that matches the descriptor
|
||||
func FindPlugin(dirs []string, descriptor Descriptor) (Plugin, error) {
|
||||
plugins, err := FindPlugins(dirs, descriptor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(plugins) > 0 {
|
||||
return plugins[0], nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("plugin: %+v not found", descriptor)
|
||||
}
|
||||
|
||||
func detectDuplicates(plugs []Plugin) error {
|
||||
names := map[string]string{}
|
||||
|
||||
for _, plug := range plugs {
|
||||
if oldpath, ok := names[plug.Metadata().Name]; ok {
|
||||
return fmt.Errorf(
|
||||
"two plugins claim the name %q at %q and %q",
|
||||
plug.Metadata().Name,
|
||||
oldpath,
|
||||
plug.Dir(),
|
||||
)
|
||||
}
|
||||
names[plug.Metadata().Name] = plug.Dir()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
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 plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"helm.sh/helm/v4/internal/plugin/schema"
|
||||
)
|
||||
|
||||
// Metadata of a plugin, converted from the "on-disk" legacy or v1 plugin.yaml
|
||||
// Specifically, Config and RuntimeConfig are converted to their respective types based on the plugin type and runtime
|
||||
type Metadata struct {
|
||||
// APIVersion specifies the plugin API version
|
||||
APIVersion string
|
||||
|
||||
// Name is the name of the plugin
|
||||
Name string
|
||||
|
||||
// Type of plugin (eg, cli/v1, getter/v1, postrenderer/v1)
|
||||
Type string
|
||||
|
||||
// Runtime specifies the runtime type (subprocess, wasm)
|
||||
Runtime string
|
||||
|
||||
// Version is the SemVer 2 version of the plugin.
|
||||
Version string
|
||||
|
||||
// SourceURL is the URL where this plugin can be found
|
||||
SourceURL string
|
||||
|
||||
// Config contains the type-specific configuration for this plugin
|
||||
Config Config
|
||||
|
||||
// RuntimeConfig contains the runtime-specific configuration
|
||||
RuntimeConfig RuntimeConfig
|
||||
}
|
||||
|
||||
func (m Metadata) Validate() error {
|
||||
var errs []error
|
||||
|
||||
if !validPluginName.MatchString(m.Name) {
|
||||
errs = append(errs, fmt.Errorf("invalid plugin name %q: must contain only a-z, A-Z, 0-9, _ and -", m.Name))
|
||||
}
|
||||
|
||||
if m.APIVersion == "" {
|
||||
errs = append(errs, fmt.Errorf("empty APIVersion"))
|
||||
}
|
||||
|
||||
if m.Type == "" {
|
||||
errs = append(errs, fmt.Errorf("empty type field"))
|
||||
}
|
||||
|
||||
if m.Runtime == "" {
|
||||
errs = append(errs, fmt.Errorf("empty runtime field"))
|
||||
}
|
||||
|
||||
if m.Config == nil {
|
||||
errs = append(errs, fmt.Errorf("missing config field"))
|
||||
}
|
||||
|
||||
if m.RuntimeConfig == nil {
|
||||
errs = append(errs, fmt.Errorf("missing runtimeConfig field"))
|
||||
}
|
||||
|
||||
// Validate the config itself
|
||||
if m.Config != nil {
|
||||
if err := m.Config.Validate(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("config validation failed: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the runtime config itself
|
||||
if m.RuntimeConfig != nil {
|
||||
if err := m.RuntimeConfig.Validate(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("runtime config validation failed: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func fromMetadataLegacy(m MetadataLegacy) *Metadata {
|
||||
pluginType := "cli/v1"
|
||||
|
||||
if len(m.Downloaders) > 0 {
|
||||
pluginType = "getter/v1"
|
||||
}
|
||||
|
||||
return &Metadata{
|
||||
APIVersion: "legacy",
|
||||
Name: m.Name,
|
||||
Version: m.Version,
|
||||
Type: pluginType,
|
||||
Runtime: "subprocess",
|
||||
Config: buildLegacyConfig(m, pluginType),
|
||||
RuntimeConfig: buildLegacyRuntimeConfig(m),
|
||||
}
|
||||
}
|
||||
|
||||
func buildLegacyConfig(m MetadataLegacy, pluginType string) Config {
|
||||
switch pluginType {
|
||||
case "getter/v1":
|
||||
var protocols []string
|
||||
for _, d := range m.Downloaders {
|
||||
protocols = append(protocols, d.Protocols...)
|
||||
}
|
||||
return &schema.ConfigGetterV1{
|
||||
Protocols: protocols,
|
||||
}
|
||||
case "cli/v1":
|
||||
return &schema.ConfigCLIV1{
|
||||
Usage: "", // Legacy plugins don't have Usage field for command syntax
|
||||
ShortHelp: m.Usage, // Map legacy usage to shortHelp
|
||||
LongHelp: m.Description, // Map legacy description to longHelp
|
||||
IgnoreFlags: m.IgnoreFlags,
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildLegacyRuntimeConfig(m MetadataLegacy) RuntimeConfig {
|
||||
var protocolCommands []SubprocessProtocolCommand
|
||||
if len(m.Downloaders) > 0 {
|
||||
protocolCommands =
|
||||
make([]SubprocessProtocolCommand, 0, len(m.Downloaders))
|
||||
for _, d := range m.Downloaders {
|
||||
protocolCommands = append(protocolCommands, SubprocessProtocolCommand{
|
||||
Protocols: d.Protocols,
|
||||
PlatformCommand: []PlatformCommand{{Command: d.Command}},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
platformCommand := m.PlatformCommand
|
||||
if len(platformCommand) == 0 && len(m.Command) > 0 {
|
||||
platformCommand = []PlatformCommand{{Command: m.Command}}
|
||||
}
|
||||
|
||||
platformHooks := m.PlatformHooks
|
||||
expandHookArgs := true
|
||||
if len(platformHooks) == 0 && len(m.Hooks) > 0 {
|
||||
platformHooks = make(PlatformHooks, len(m.Hooks))
|
||||
for hookName, hookCommand := range m.Hooks {
|
||||
platformHooks[hookName] = []PlatformCommand{{Command: "sh", Args: []string{"-c", hookCommand}}}
|
||||
expandHookArgs = false
|
||||
}
|
||||
}
|
||||
return &RuntimeConfigSubprocess{
|
||||
PlatformCommand: platformCommand,
|
||||
PlatformHooks: platformHooks,
|
||||
ProtocolCommands: protocolCommands,
|
||||
expandHookArgs: expandHookArgs,
|
||||
}
|
||||
}
|
||||
|
||||
func fromMetadataV1(mv1 MetadataV1) (*Metadata, error) {
|
||||
config, err := unmarshalConfig(mv1.Type, mv1.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
runtimeConfig, err := convertMetadataRuntimeConfig(mv1.Runtime, mv1.RuntimeConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Metadata{
|
||||
APIVersion: mv1.APIVersion,
|
||||
Name: mv1.Name,
|
||||
Type: mv1.Type,
|
||||
Runtime: mv1.Runtime,
|
||||
Version: mv1.Version,
|
||||
SourceURL: mv1.SourceURL,
|
||||
Config: config,
|
||||
RuntimeConfig: runtimeConfig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func convertMetadataRuntimeConfig(runtimeType string, runtimeConfigRaw map[string]any) (RuntimeConfig, error) {
|
||||
var runtimeConfig RuntimeConfig
|
||||
var err error
|
||||
|
||||
switch runtimeType {
|
||||
case "subprocess":
|
||||
runtimeConfig, err = remarshalRuntimeConfig[*RuntimeConfigSubprocess](runtimeConfigRaw)
|
||||
case "extism/v1":
|
||||
runtimeConfig, err = remarshalRuntimeConfig[*RuntimeConfigExtismV1](runtimeConfigRaw)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported plugin runtime type: %q", runtimeType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal runtimeConfig for %s runtime: %w", runtimeType, err)
|
||||
}
|
||||
return runtimeConfig, nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
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 plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Downloaders represents the plugins capability if it can retrieve
|
||||
// charts from special sources
|
||||
type Downloaders struct {
|
||||
// Protocols are the list of schemes from the charts URL.
|
||||
Protocols []string `yaml:"protocols"`
|
||||
// Command is the executable path with which the plugin performs
|
||||
// the actual download for the corresponding Protocols
|
||||
Command string `yaml:"command"`
|
||||
}
|
||||
|
||||
// MetadataLegacy is the legacy plugin.yaml format
|
||||
type MetadataLegacy struct {
|
||||
// Name is the name of the plugin
|
||||
Name string `yaml:"name"`
|
||||
|
||||
// Version is a SemVer 2 version of the plugin.
|
||||
Version string `yaml:"version"`
|
||||
|
||||
// Usage is the single-line usage text shown in help
|
||||
Usage string `yaml:"usage"`
|
||||
|
||||
// Description is a long description shown in places like `helm help`
|
||||
Description string `yaml:"description"`
|
||||
|
||||
// PlatformCommand is the plugin command, with a platform selector and support for args.
|
||||
PlatformCommand []PlatformCommand `yaml:"platformCommand"`
|
||||
|
||||
// Command is the plugin command, as a single string.
|
||||
// DEPRECATED: Use PlatformCommand instead. Removed in subprocess/v1 plugins.
|
||||
Command string `yaml:"command"`
|
||||
|
||||
// IgnoreFlags ignores any flags passed in from Helm
|
||||
IgnoreFlags bool `yaml:"ignoreFlags"`
|
||||
|
||||
// PlatformHooks are commands that will run on plugin events, with a platform selector and support for args.
|
||||
PlatformHooks PlatformHooks `yaml:"platformHooks"`
|
||||
|
||||
// Hooks are commands that will run on plugin events, as a single string.
|
||||
// DEPRECATED: Use PlatformHooks instead. Removed in subprocess/v1 plugins.
|
||||
Hooks Hooks `yaml:"hooks"`
|
||||
|
||||
// Downloaders field is used if the plugin supply downloader mechanism
|
||||
// for special protocols.
|
||||
Downloaders []Downloaders `yaml:"downloaders"`
|
||||
}
|
||||
|
||||
func (m *MetadataLegacy) Validate() error {
|
||||
if !validPluginName.MatchString(m.Name) {
|
||||
return fmt.Errorf("invalid plugin name %q: must contain only a-z, A-Z, 0-9, _ and -", m.Name)
|
||||
}
|
||||
m.Usage = sanitizeString(m.Usage)
|
||||
|
||||
if len(m.PlatformCommand) > 0 && len(m.Command) > 0 {
|
||||
return fmt.Errorf("both platformCommand and command are set")
|
||||
}
|
||||
|
||||
if len(m.PlatformHooks) > 0 && len(m.Hooks) > 0 {
|
||||
return fmt.Errorf("both platformHooks and hooks are set")
|
||||
}
|
||||
|
||||
// Validate downloader plugins
|
||||
for i, downloader := range m.Downloaders {
|
||||
if downloader.Command == "" {
|
||||
return fmt.Errorf("downloader %d has empty command", i)
|
||||
}
|
||||
if len(downloader.Protocols) == 0 {
|
||||
return fmt.Errorf("downloader %d has no protocols", i)
|
||||
}
|
||||
for j, protocol := range downloader.Protocols {
|
||||
if protocol == "" {
|
||||
return fmt.Errorf("downloader %d has empty protocol at index %d", i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sanitizeString normalize spaces and removes non-printable characters.
|
||||
func sanitizeString(str string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if unicode.IsSpace(r) {
|
||||
return ' '
|
||||
}
|
||||
if unicode.IsPrint(r) {
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, str)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
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 plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// MetadataV1 is the APIVersion V1 plugin.yaml format
|
||||
type MetadataV1 struct {
|
||||
// APIVersion specifies the plugin API version
|
||||
APIVersion string `yaml:"apiVersion"`
|
||||
|
||||
// Name is the name of the plugin
|
||||
Name string `yaml:"name"`
|
||||
|
||||
// Type of plugin (eg, cli/v1, getter/v1, postrenderer/v1)
|
||||
Type string `yaml:"type"`
|
||||
|
||||
// Runtime specifies the runtime type (subprocess, wasm)
|
||||
Runtime string `yaml:"runtime"`
|
||||
|
||||
// Version is a SemVer 2 version of the plugin.
|
||||
Version string `yaml:"version"`
|
||||
|
||||
// SourceURL is the URL where this plugin can be found
|
||||
SourceURL string `yaml:"sourceURL,omitempty"`
|
||||
|
||||
// Config contains the type-specific configuration for this plugin
|
||||
Config map[string]any `yaml:"config"`
|
||||
|
||||
// RuntimeConfig contains the runtime-specific configuration
|
||||
RuntimeConfig map[string]any `yaml:"runtimeConfig"`
|
||||
}
|
||||
|
||||
func (m *MetadataV1) Validate() error {
|
||||
if !validPluginName.MatchString(m.Name) {
|
||||
return fmt.Errorf("invalid plugin `name`")
|
||||
}
|
||||
|
||||
if m.APIVersion != "v1" {
|
||||
return fmt.Errorf("invalid `apiVersion`: %q", m.APIVersion)
|
||||
}
|
||||
|
||||
if m.Type == "" {
|
||||
return fmt.Errorf("`type` missing")
|
||||
}
|
||||
|
||||
if m.Runtime == "" {
|
||||
return fmt.Errorf("`runtime` missing")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
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 plugin // import "helm.sh/helm/v4/internal/plugin"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
const PluginFileName = "plugin.yaml"
|
||||
|
||||
// Plugin defines a plugin instance. The client (Helm codebase) facing type that can be used to introspect and invoke a plugin
|
||||
type Plugin interface {
|
||||
// Dir return the plugin directory (as an absolute path) on the filesystem
|
||||
Dir() string
|
||||
|
||||
// Metadata describes the plugin's type, version, etc.
|
||||
// (This metadata type is the converted and plugin version independented in-memory representation of the plugin.yaml file)
|
||||
Metadata() Metadata
|
||||
|
||||
// Invoke takes the given input, and dispatches the contents to plugin instance
|
||||
// The input is expected to be a JSON-serializable object, which the plugin will interpret according to its type
|
||||
// The plugin is expected to return a JSON-serializable object, which the invoker
|
||||
// will interpret according to the plugin's type
|
||||
//
|
||||
// Invoke can be thought of as a request/response mechanism. Similar to e.g. http.RoundTripper
|
||||
//
|
||||
// If plugin's execution fails with a non-zero "return code" (this is plugin runtime implementation specific)
|
||||
// an InvokeExecError is returned
|
||||
Invoke(ctx context.Context, input *Input) (*Output, error)
|
||||
}
|
||||
|
||||
// PluginHook allows plugins to implement hooks that are invoked on plugin management events (install, upgrade, etc)
|
||||
type PluginHook interface { //nolint:revive
|
||||
InvokeHook(event string) error
|
||||
}
|
||||
|
||||
// Input defines the input message and parameters to be passed to the plugin
|
||||
type Input struct {
|
||||
// Message represents the type-elided value to be passed to the plugin.
|
||||
// The plugin is expected to interpret the message according to its type
|
||||
// The message object must be JSON-serializable
|
||||
Message any
|
||||
|
||||
// Optional: Reader to be consumed plugin's "stdin"
|
||||
Stdin io.Reader
|
||||
|
||||
// Optional: Writers to consume the plugin's "stdout" and "stderr"
|
||||
Stdout, Stderr io.Writer
|
||||
|
||||
// Optional: Env represents the environment as a list of "key=value" strings
|
||||
// see os.Environ
|
||||
Env []string
|
||||
}
|
||||
|
||||
// Output defines the output message and parameters the passed from the plugin
|
||||
type Output struct {
|
||||
// Message represents the type-elided value returned from the plugin
|
||||
// The invoker is expected to interpret the message according to the plugin's type
|
||||
// The message object must be JSON-serializable
|
||||
Message any
|
||||
}
|
||||
|
||||
// validPluginName is a regular expression that validates plugin names.
|
||||
//
|
||||
// Plugin names can only contain the ASCII characters a-z, A-Z, 0-9, _ and -.
|
||||
var validPluginName = regexp.MustCompile("^[A-Za-z0-9_-]+$")
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/*
|
||||
This file contains a "registry" of supported plugin types.
|
||||
|
||||
It enables "dyanmic" operations on the go type associated with a given plugin type (see: `helm.sh/helm/v4/internal/plugin/schema` package)
|
||||
|
||||
Examples:
|
||||
|
||||
```
|
||||
|
||||
// Create a new instance of the output message type for a given plugin type:
|
||||
|
||||
pluginType := "cli/v1" // for example
|
||||
ptm, ok := pluginTypesIndex[pluginType]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown plugin type %q", pluginType)
|
||||
}
|
||||
|
||||
outputMessageType := reflect.Zero(ptm.outputType).Interface()
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
// Create a new instance of the config type for a given plugin type
|
||||
|
||||
pluginType := "cli/v1" // for example
|
||||
ptm, ok := pluginTypesIndex[pluginType]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
config := reflect.New(ptm.configType).Interface().(Config) // `config` is variable of type `Config`, with
|
||||
|
||||
// validate
|
||||
err := config.Validate()
|
||||
if err != nil { // handle error }
|
||||
|
||||
// assert to concrete type if needed
|
||||
cliConfig := config.(*schema.ConfigCLIV1)
|
||||
|
||||
```
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"helm.sh/helm/v4/internal/plugin/schema"
|
||||
)
|
||||
|
||||
type pluginTypeMeta struct {
|
||||
pluginType string
|
||||
inputType reflect.Type
|
||||
outputType reflect.Type
|
||||
configType reflect.Type
|
||||
}
|
||||
|
||||
var pluginTypes = []pluginTypeMeta{
|
||||
{
|
||||
pluginType: "test/v1",
|
||||
inputType: reflect.TypeFor[schema.InputMessageTestV1](),
|
||||
outputType: reflect.TypeFor[schema.OutputMessageTestV1](),
|
||||
configType: reflect.TypeFor[schema.ConfigTestV1](),
|
||||
},
|
||||
{
|
||||
pluginType: "cli/v1",
|
||||
inputType: reflect.TypeFor[schema.InputMessageCLIV1](),
|
||||
outputType: reflect.TypeFor[schema.OutputMessageCLIV1](),
|
||||
configType: reflect.TypeFor[schema.ConfigCLIV1](),
|
||||
},
|
||||
{
|
||||
pluginType: "getter/v1",
|
||||
inputType: reflect.TypeFor[schema.InputMessageGetterV1](),
|
||||
outputType: reflect.TypeFor[schema.OutputMessageGetterV1](),
|
||||
configType: reflect.TypeFor[schema.ConfigGetterV1](),
|
||||
},
|
||||
{
|
||||
pluginType: "postrenderer/v1",
|
||||
inputType: reflect.TypeFor[schema.InputMessagePostRendererV1](),
|
||||
outputType: reflect.TypeFor[schema.OutputMessagePostRendererV1](),
|
||||
configType: reflect.TypeFor[schema.ConfigPostRendererV1](),
|
||||
},
|
||||
}
|
||||
|
||||
var pluginTypesIndex = func() map[string]*pluginTypeMeta {
|
||||
result := make(map[string]*pluginTypeMeta, len(pluginTypes))
|
||||
for _, m := range pluginTypes {
|
||||
result[m.pluginType] = &m
|
||||
}
|
||||
return result
|
||||
}()
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
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 plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
// Runtime represents a plugin runtime (subprocess, extism, etc) ie. how a plugin should be executed
|
||||
// Runtime is responsible for instantiating plugins that implement the runtime
|
||||
// TODO: could call this something more like "PluginRuntimeCreator"?
|
||||
type Runtime interface {
|
||||
// CreatePlugin creates a plugin instance from the given metadata
|
||||
CreatePlugin(pluginDir string, metadata *Metadata) (Plugin, error)
|
||||
|
||||
// TODO: move config unmarshalling to the runtime?
|
||||
// UnmarshalConfig(runtimeConfigRaw map[string]any) (RuntimeConfig, error)
|
||||
}
|
||||
|
||||
// RuntimeConfig represents the assertable type for a plugin's runtime configuration.
|
||||
// It is expected to type assert (cast) the a RuntimeConfig to its expected type
|
||||
type RuntimeConfig interface {
|
||||
Validate() error
|
||||
}
|
||||
|
||||
func remarshalRuntimeConfig[T RuntimeConfig](runtimeData map[string]any) (RuntimeConfig, error) {
|
||||
data, err := yaml.Marshal(runtimeData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var config T
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// ParseEnv takes a list of "KEY=value" environment variable strings
|
||||
// and transforms the result into a map[KEY]=value
|
||||
//
|
||||
// - empty input strings are ignored
|
||||
// - input strings with no value are stored as empty strings
|
||||
// - duplicate keys overwrite earlier values
|
||||
func ParseEnv(env []string) map[string]string {
|
||||
result := make(map[string]string, len(env))
|
||||
for _, envVar := range env {
|
||||
parts := strings.SplitN(envVar, "=", 2)
|
||||
if len(parts) > 0 && parts[0] != "" {
|
||||
key := parts[0]
|
||||
var value string
|
||||
if len(parts) > 1 {
|
||||
value = parts[1]
|
||||
}
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// FormatEnv takes a map[KEY]=value and transforms it into
|
||||
// a list of "KEY=value" environment variable strings
|
||||
func FormatEnv(env map[string]string) []string {
|
||||
result := make([]string, 0, len(env))
|
||||
for key, value := range env {
|
||||
result = append(result, fmt.Sprintf("%s=%s", key, value))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -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 plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
"github.com/tetratelabs/wazero"
|
||||
)
|
||||
|
||||
const ExtismV1WasmBinaryFilename = "plugin.wasm"
|
||||
|
||||
// RuntimeConfigExtismV1Memory exposes the Wasm/Extism memory options for the plugin
|
||||
type RuntimeConfigExtismV1Memory struct {
|
||||
// The max amount of pages the plugin can allocate
|
||||
// One page is 64Kib. e.g. 16 pages would require 1MiB.
|
||||
// Default is 4 pages (256KiB)
|
||||
MaxPages uint32 `yaml:"maxPages,omitempty"`
|
||||
|
||||
// The max size of an Extism HTTP response in bytes
|
||||
// Default is 4096 bytes (4KiB)
|
||||
MaxHTTPResponseBytes int64 `yaml:"maxHttpResponseBytes,omitempty"`
|
||||
|
||||
// The max size of all Extism vars in bytes
|
||||
// Default is 4096 bytes (4KiB)
|
||||
MaxVarBytes int64 `yaml:"maxVarBytes,omitempty"`
|
||||
}
|
||||
|
||||
// RuntimeConfigExtismV1FileSystem exposes filesystem options for the configuration
|
||||
// TODO: should Helm expose AllowedPaths?
|
||||
type RuntimeConfigExtismV1FileSystem struct {
|
||||
// If specified, a temporary directory will be created and mapped to /tmp in the plugin's filesystem.
|
||||
// Data written to the directory will be visible on the host filesystem.
|
||||
// The directory will be removed when the plugin invocation completes.
|
||||
CreateTempDir bool `yaml:"createTempDir,omitempty"`
|
||||
}
|
||||
|
||||
// RuntimeConfigExtismV1 defines the user-configurable options the plugin's Extism runtime
|
||||
// The format loosely follows the Extism Manifest format: https://extism.org/docs/concepts/manifest/
|
||||
type RuntimeConfigExtismV1 struct {
|
||||
// Describes the limits on the memory the plugin may be allocated.
|
||||
Memory RuntimeConfigExtismV1Memory `yaml:"memory"`
|
||||
|
||||
// The "config" key is a free-form map that can be passed to the plugin.
|
||||
// The plugin must interpret arbitrary data this map may contain
|
||||
Config map[string]string `yaml:"config,omitempty"`
|
||||
|
||||
// An optional set of hosts this plugin can communicate with.
|
||||
// This only has an effect if the plugin makes HTTP requests.
|
||||
// If not specified, then no hosts are allowed.
|
||||
AllowedHosts []string `yaml:"allowedHosts,omitempty"`
|
||||
|
||||
FileSystem RuntimeConfigExtismV1FileSystem `yaml:"fileSystem,omitempty"`
|
||||
|
||||
// The timeout in milliseconds for the plugin to execute
|
||||
Timeout uint64 `yaml:"timeout,omitempty"`
|
||||
|
||||
// HostFunction names exposed in Helm the plugin may access
|
||||
// see: https://extism.org/docs/concepts/host-functions/
|
||||
HostFunctions []string `yaml:"hostFunctions,omitempty"`
|
||||
|
||||
// The name of entry function name to call in the plugin
|
||||
// Defaults to "helm_plugin_main".
|
||||
EntryFuncName string `yaml:"entryFuncName,omitempty"`
|
||||
}
|
||||
|
||||
var _ RuntimeConfig = (*RuntimeConfigExtismV1)(nil)
|
||||
|
||||
func (r *RuntimeConfigExtismV1) Validate() error {
|
||||
// TODO
|
||||
return nil
|
||||
}
|
||||
|
||||
type RuntimeExtismV1 struct {
|
||||
HostFunctions map[string]extism.HostFunction
|
||||
CompilationCache wazero.CompilationCache
|
||||
}
|
||||
|
||||
var _ Runtime = (*RuntimeExtismV1)(nil)
|
||||
|
||||
func (r *RuntimeExtismV1) CreatePlugin(pluginDir string, metadata *Metadata) (Plugin, error) {
|
||||
|
||||
rc, ok := metadata.RuntimeConfig.(*RuntimeConfigExtismV1)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid extism/v1 plugin runtime config type: %T", metadata.RuntimeConfig)
|
||||
}
|
||||
|
||||
wasmFile := filepath.Join(pluginDir, ExtismV1WasmBinaryFilename)
|
||||
if _, err := os.Stat(wasmFile); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("wasm binary missing for extism/v1 plugin: %q", wasmFile)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to stat extism/v1 plugin wasm binary %q: %w", wasmFile, err)
|
||||
}
|
||||
|
||||
return &ExtismV1PluginRuntime{
|
||||
metadata: *metadata,
|
||||
dir: pluginDir,
|
||||
rc: rc,
|
||||
r: r,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type ExtismV1PluginRuntime struct {
|
||||
metadata Metadata
|
||||
dir string
|
||||
rc *RuntimeConfigExtismV1
|
||||
r *RuntimeExtismV1
|
||||
}
|
||||
|
||||
var _ Plugin = (*ExtismV1PluginRuntime)(nil)
|
||||
|
||||
func (p *ExtismV1PluginRuntime) Metadata() Metadata {
|
||||
return p.metadata
|
||||
}
|
||||
|
||||
func (p *ExtismV1PluginRuntime) Dir() string {
|
||||
return p.dir
|
||||
}
|
||||
|
||||
func (p *ExtismV1PluginRuntime) Invoke(ctx context.Context, input *Input) (*Output, error) {
|
||||
|
||||
var tmpDir string
|
||||
if p.rc.FileSystem.CreateTempDir {
|
||||
tmpDirInner, err := os.MkdirTemp(os.TempDir(), "helm-plugin-*")
|
||||
slog.Debug("created plugin temp dir", slog.String("dir", tmpDirInner), slog.String("plugin", p.metadata.Name))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp dir for extism compilation cache: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.RemoveAll(tmpDir); err != nil {
|
||||
slog.Warn("failed to remove plugin temp dir", slog.String("dir", tmpDir), slog.String("plugin", p.metadata.Name), slog.String("error", err.Error()))
|
||||
}
|
||||
}()
|
||||
|
||||
tmpDir = tmpDirInner
|
||||
}
|
||||
|
||||
manifest, err := buildManifest(p.dir, tmpDir, p.rc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config := buildPluginConfig(input, p.r)
|
||||
|
||||
hostFunctions, err := buildHostFunctions(p.r.HostFunctions, p.rc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pe, err := extism.NewPlugin(ctx, manifest, config, hostFunctions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create existing plugin: %w", err)
|
||||
}
|
||||
|
||||
pe.SetLogger(func(logLevel extism.LogLevel, s string) {
|
||||
slog.Debug(s, slog.String("level", logLevel.String()), slog.String("plugin", p.metadata.Name))
|
||||
})
|
||||
|
||||
inputData, err := json.Marshal(input.Message)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to json marshal plugin input message: %T: %w", input.Message, err)
|
||||
}
|
||||
|
||||
slog.Debug("plugin input", slog.String("plugin", p.metadata.Name), slog.String("inputData", string(inputData)))
|
||||
|
||||
entryFuncName := p.rc.EntryFuncName
|
||||
if entryFuncName == "" {
|
||||
entryFuncName = "helm_plugin_main"
|
||||
}
|
||||
|
||||
exitCode, outputData, err := pe.Call(entryFuncName, inputData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugin error: %w", err)
|
||||
}
|
||||
|
||||
if exitCode != 0 {
|
||||
return nil, &InvokeExecError{
|
||||
ExitCode: int(exitCode),
|
||||
}
|
||||
}
|
||||
|
||||
slog.Debug("plugin output", slog.String("plugin", p.metadata.Name), slog.Int("exitCode", int(exitCode)), slog.String("outputData", string(outputData)))
|
||||
|
||||
outputMessage := reflect.New(pluginTypesIndex[p.metadata.Type].outputType)
|
||||
if err := json.Unmarshal(outputData, outputMessage.Interface()); err != nil {
|
||||
return nil, fmt.Errorf("failed to json marshal plugin output message: %T: %w", outputMessage, err)
|
||||
}
|
||||
|
||||
output := &Output{
|
||||
Message: outputMessage.Elem().Interface(),
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func buildManifest(pluginDir string, tmpDir string, rc *RuntimeConfigExtismV1) (extism.Manifest, error) {
|
||||
wasmFile := filepath.Join(pluginDir, ExtismV1WasmBinaryFilename)
|
||||
|
||||
allowedHosts := rc.AllowedHosts
|
||||
if allowedHosts == nil {
|
||||
allowedHosts = []string{}
|
||||
}
|
||||
|
||||
allowedPaths := map[string]string{}
|
||||
if tmpDir != "" {
|
||||
allowedPaths[tmpDir] = "/tmp"
|
||||
}
|
||||
|
||||
return extism.Manifest{
|
||||
Wasm: []extism.Wasm{
|
||||
extism.WasmFile{
|
||||
Path: wasmFile,
|
||||
Name: wasmFile,
|
||||
},
|
||||
},
|
||||
Memory: &extism.ManifestMemory{
|
||||
MaxPages: rc.Memory.MaxPages,
|
||||
MaxHttpResponseBytes: rc.Memory.MaxHTTPResponseBytes,
|
||||
MaxVarBytes: rc.Memory.MaxVarBytes,
|
||||
},
|
||||
Config: rc.Config,
|
||||
AllowedHosts: allowedHosts,
|
||||
AllowedPaths: allowedPaths,
|
||||
Timeout: rc.Timeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildPluginConfig(input *Input, r *RuntimeExtismV1) extism.PluginConfig {
|
||||
mc := wazero.NewModuleConfig().
|
||||
WithSysWalltime()
|
||||
if input.Stdin != nil {
|
||||
mc = mc.WithStdin(input.Stdin)
|
||||
}
|
||||
if input.Stdout != nil {
|
||||
mc = mc.WithStdout(input.Stdout)
|
||||
}
|
||||
if input.Stderr != nil {
|
||||
mc = mc.WithStderr(input.Stderr)
|
||||
}
|
||||
if len(input.Env) > 0 {
|
||||
env := ParseEnv(input.Env)
|
||||
for k, v := range env {
|
||||
mc = mc.WithEnv(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
config := extism.PluginConfig{
|
||||
ModuleConfig: mc,
|
||||
RuntimeConfig: wazero.NewRuntimeConfigCompiler().
|
||||
WithCloseOnContextDone(true).
|
||||
WithCompilationCache(r.CompilationCache),
|
||||
EnableWasi: true,
|
||||
EnableHttpResponseHeaders: true,
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
func buildHostFunctions(hostFunctions map[string]extism.HostFunction, rc *RuntimeConfigExtismV1) ([]extism.HostFunction, error) {
|
||||
result := make([]extism.HostFunction, len(rc.HostFunctions))
|
||||
for _, fnName := range rc.HostFunctions {
|
||||
fn, ok := hostFunctions[fnName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plugin requested host function %q not found", fnName)
|
||||
}
|
||||
|
||||
result = append(result, fn)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
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 plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
|
||||
"helm.sh/helm/v4/internal/plugin/schema"
|
||||
)
|
||||
|
||||
// SubprocessProtocolCommand maps a given protocol to the getter command used to retrieve artifacts for that protocol
|
||||
type SubprocessProtocolCommand struct {
|
||||
// Protocols are the list of schemes from the charts URL.
|
||||
Protocols []string `yaml:"protocols"`
|
||||
// PlatformCommand is the platform based command which the plugin performs
|
||||
// to download for the corresponding getter Protocols.
|
||||
PlatformCommand []PlatformCommand `yaml:"platformCommand"`
|
||||
}
|
||||
|
||||
// RuntimeConfigSubprocess implements RuntimeConfig for RuntimeSubprocess
|
||||
type RuntimeConfigSubprocess struct {
|
||||
// PlatformCommand is a list containing a plugin command, with a platform selector and support for args.
|
||||
PlatformCommand []PlatformCommand `yaml:"platformCommand"`
|
||||
// PlatformHooks are commands that will run on plugin events, with a platform selector and support for args.
|
||||
PlatformHooks PlatformHooks `yaml:"platformHooks"`
|
||||
// ProtocolCommands allows the plugin to specify protocol specific commands
|
||||
//
|
||||
// Obsolete/deprecated: This is a compatibility hangover from the old plugin downloader mechanism, which was extended
|
||||
// to support multiple protocols in a given plugin. The command supplied in PlatformCommand should implement protocol
|
||||
// specific logic by inspecting the download URL
|
||||
ProtocolCommands []SubprocessProtocolCommand `yaml:"protocolCommands,omitempty"`
|
||||
|
||||
expandHookArgs bool
|
||||
}
|
||||
|
||||
var _ RuntimeConfig = (*RuntimeConfigSubprocess)(nil)
|
||||
|
||||
func (r *RuntimeConfigSubprocess) GetType() string { return "subprocess" }
|
||||
|
||||
func (r *RuntimeConfigSubprocess) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type RuntimeSubprocess struct {
|
||||
EnvVars map[string]string
|
||||
}
|
||||
|
||||
var _ Runtime = (*RuntimeSubprocess)(nil)
|
||||
|
||||
// CreatePlugin implementation for Runtime
|
||||
func (r *RuntimeSubprocess) CreatePlugin(pluginDir string, metadata *Metadata) (Plugin, error) {
|
||||
return &SubprocessPluginRuntime{
|
||||
metadata: *metadata,
|
||||
pluginDir: pluginDir,
|
||||
RuntimeConfig: *(metadata.RuntimeConfig.(*RuntimeConfigSubprocess)),
|
||||
EnvVars: maps.Clone(r.EnvVars),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SubprocessPluginRuntime implements the Plugin interface for subprocess execution
|
||||
type SubprocessPluginRuntime struct {
|
||||
metadata Metadata
|
||||
pluginDir string
|
||||
RuntimeConfig RuntimeConfigSubprocess
|
||||
EnvVars map[string]string
|
||||
}
|
||||
|
||||
var _ Plugin = (*SubprocessPluginRuntime)(nil)
|
||||
|
||||
func (r *SubprocessPluginRuntime) Dir() string {
|
||||
return r.pluginDir
|
||||
}
|
||||
|
||||
func (r *SubprocessPluginRuntime) Metadata() Metadata {
|
||||
return r.metadata
|
||||
}
|
||||
|
||||
func (r *SubprocessPluginRuntime) Invoke(_ context.Context, input *Input) (*Output, error) {
|
||||
switch input.Message.(type) {
|
||||
case schema.InputMessageCLIV1:
|
||||
return r.runCLI(input)
|
||||
case schema.InputMessageGetterV1:
|
||||
return r.runGetter(input)
|
||||
case schema.InputMessagePostRendererV1:
|
||||
return r.runPostrenderer(input)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported subprocess plugin type %q", r.metadata.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// InvokeWithEnv executes a plugin command with custom environment and I/O streams
|
||||
// This method allows execution with different command/args than the plugin's default
|
||||
func (r *SubprocessPluginRuntime) InvokeWithEnv(main string, argv []string, env []string, stdin io.Reader, stdout, stderr io.Writer) error {
|
||||
mainCmdExp := os.ExpandEnv(main)
|
||||
cmd := exec.Command(mainCmdExp, argv...)
|
||||
cmd.Env = slices.Clone(os.Environ())
|
||||
cmd.Env = append(
|
||||
cmd.Env,
|
||||
fmt.Sprintf("HELM_PLUGIN_NAME=%s", r.metadata.Name),
|
||||
fmt.Sprintf("HELM_PLUGIN_DIR=%s", r.pluginDir))
|
||||
cmd.Env = append(cmd.Env, env...)
|
||||
|
||||
cmd.Stdin = stdin
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
|
||||
if err := executeCmd(cmd, r.metadata.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SubprocessPluginRuntime) InvokeHook(event string) error {
|
||||
cmds := r.RuntimeConfig.PlatformHooks[event]
|
||||
|
||||
if len(cmds) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
env := ParseEnv(os.Environ())
|
||||
maps.Insert(env, maps.All(r.EnvVars))
|
||||
env["HELM_PLUGIN_NAME"] = r.metadata.Name
|
||||
env["HELM_PLUGIN_DIR"] = r.pluginDir
|
||||
|
||||
main, argv, err := PrepareCommands(cmds, r.RuntimeConfig.expandHookArgs, []string{}, env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command(main, argv...)
|
||||
cmd.Env = FormatEnv(env)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
slog.Debug("executing plugin hook command", slog.String("pluginName", r.metadata.Name), slog.String("command", cmd.String()))
|
||||
if err := cmd.Run(); err != nil {
|
||||
if eerr, ok := err.(*exec.ExitError); ok {
|
||||
os.Stderr.Write(eerr.Stderr)
|
||||
return fmt.Errorf("plugin %s hook for %q exited with error", event, r.metadata.Name)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO decide the best way to handle this code
|
||||
// right now we implement status and error return in 3 slightly different ways in this file
|
||||
// then replace the other three with a call to this func
|
||||
func executeCmd(prog *exec.Cmd, pluginName string) error {
|
||||
if err := prog.Run(); err != nil {
|
||||
if eerr, ok := err.(*exec.ExitError); ok {
|
||||
slog.Debug(
|
||||
"plugin execution failed",
|
||||
slog.String("pluginName", pluginName),
|
||||
slog.String("error", err.Error()),
|
||||
slog.Int("exitCode", eerr.ExitCode()),
|
||||
slog.String("stderr", string(bytes.TrimSpace(eerr.Stderr))))
|
||||
return &InvokeExecError{
|
||||
Err: fmt.Errorf("plugin %q exited with error", pluginName),
|
||||
ExitCode: eerr.ExitCode(),
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SubprocessPluginRuntime) runCLI(input *Input) (*Output, error) {
|
||||
if _, ok := input.Message.(schema.InputMessageCLIV1); !ok {
|
||||
return nil, fmt.Errorf("plugin %q input message does not implement InputMessageCLIV1", r.metadata.Name)
|
||||
}
|
||||
|
||||
extraArgs := input.Message.(schema.InputMessageCLIV1).ExtraArgs
|
||||
|
||||
cmds := r.RuntimeConfig.PlatformCommand
|
||||
|
||||
env := ParseEnv(os.Environ())
|
||||
maps.Insert(env, maps.All(r.EnvVars))
|
||||
maps.Insert(env, maps.All(ParseEnv(input.Env)))
|
||||
env["HELM_PLUGIN_NAME"] = r.metadata.Name
|
||||
env["HELM_PLUGIN_DIR"] = r.pluginDir
|
||||
|
||||
command, args, err := PrepareCommands(cmds, true, extraArgs, env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to prepare plugin command: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(command, args...)
|
||||
cmd.Env = FormatEnv(env)
|
||||
|
||||
cmd.Stdin = input.Stdin
|
||||
cmd.Stdout = input.Stdout
|
||||
cmd.Stderr = input.Stderr
|
||||
|
||||
slog.Debug("executing plugin command", slog.String("pluginName", r.metadata.Name), slog.String("command", cmd.String()))
|
||||
if err := executeCmd(cmd, r.metadata.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Output{
|
||||
Message: schema.OutputMessageCLIV1{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *SubprocessPluginRuntime) runPostrenderer(input *Input) (*Output, error) {
|
||||
if _, ok := input.Message.(schema.InputMessagePostRendererV1); !ok {
|
||||
return nil, fmt.Errorf("plugin %q input message does not implement InputMessagePostRendererV1", r.metadata.Name)
|
||||
}
|
||||
|
||||
env := ParseEnv(os.Environ())
|
||||
maps.Insert(env, maps.All(r.EnvVars))
|
||||
maps.Insert(env, maps.All(ParseEnv(input.Env)))
|
||||
env["HELM_PLUGIN_NAME"] = r.metadata.Name
|
||||
env["HELM_PLUGIN_DIR"] = r.pluginDir
|
||||
|
||||
msg := input.Message.(schema.InputMessagePostRendererV1)
|
||||
cmds := r.RuntimeConfig.PlatformCommand
|
||||
command, args, err := PrepareCommands(cmds, true, msg.ExtraArgs, env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to prepare plugin command: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(
|
||||
command,
|
||||
args...)
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer stdin.Close()
|
||||
io.Copy(stdin, msg.Manifests)
|
||||
}()
|
||||
|
||||
postRendered := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
|
||||
cmd.Env = FormatEnv(env)
|
||||
cmd.Stdout = postRendered
|
||||
cmd.Stderr = stderr
|
||||
|
||||
slog.Debug("executing plugin command", slog.String("pluginName", r.metadata.Name), slog.String("command", cmd.String()))
|
||||
if err := executeCmd(cmd, r.metadata.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Output{
|
||||
Message: schema.OutputMessagePostRendererV1{
|
||||
Manifests: postRendered,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
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 plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
|
||||
"helm.sh/helm/v4/internal/plugin/schema"
|
||||
)
|
||||
|
||||
func getProtocolCommand(commands []SubprocessProtocolCommand, protocol string) *SubprocessProtocolCommand {
|
||||
for _, c := range commands {
|
||||
if slices.Contains(c.Protocols, protocol) {
|
||||
return &c
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO can we replace a lot of this func with RuntimeSubprocess.invokeWithEnv?
|
||||
func (r *SubprocessPluginRuntime) runGetter(input *Input) (*Output, error) {
|
||||
msg, ok := (input.Message).(schema.InputMessageGetterV1)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected input type schema.InputMessageGetterV1, got %T", input)
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp(os.TempDir(), fmt.Sprintf("helm-plugin-%s-", r.metadata.Name))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temporary directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
d := getProtocolCommand(r.RuntimeConfig.ProtocolCommands, msg.Protocol)
|
||||
if d == nil {
|
||||
return nil, fmt.Errorf("no downloader found for protocol %q", msg.Protocol)
|
||||
}
|
||||
|
||||
env := ParseEnv(os.Environ())
|
||||
maps.Insert(env, maps.All(r.EnvVars))
|
||||
maps.Insert(env, maps.All(ParseEnv(input.Env)))
|
||||
env["HELM_PLUGIN_NAME"] = r.metadata.Name
|
||||
env["HELM_PLUGIN_DIR"] = r.pluginDir
|
||||
env["HELM_PLUGIN_USERNAME"] = msg.Options.Username
|
||||
env["HELM_PLUGIN_PASSWORD"] = msg.Options.Password
|
||||
env["HELM_PLUGIN_PASS_CREDENTIALS_ALL"] = fmt.Sprintf("%t", msg.Options.PassCredentialsAll)
|
||||
|
||||
command, args, err := PrepareCommands(d.PlatformCommand, false, []string{}, env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to prepare commands for protocol %q: %w", msg.Protocol, err)
|
||||
}
|
||||
|
||||
args = append(
|
||||
args,
|
||||
msg.Options.CertFile,
|
||||
msg.Options.KeyFile,
|
||||
msg.Options.CAFile,
|
||||
msg.Href)
|
||||
|
||||
buf := bytes.Buffer{} // subprocess getters are expected to write content to stdout
|
||||
|
||||
pluginCommand := filepath.Join(r.pluginDir, command)
|
||||
cmd := exec.Command(
|
||||
pluginCommand,
|
||||
args...)
|
||||
cmd.Env = FormatEnv(env)
|
||||
cmd.Stdout = &buf
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
slog.Debug("executing plugin command", slog.String("pluginName", r.metadata.Name), slog.String("command", cmd.String()))
|
||||
if err := executeCmd(cmd, r.metadata.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Output{
|
||||
Message: schema.OutputMessageGetterV1{
|
||||
Data: buf.Bytes(),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
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 plugin // import "helm.sh/helm/v4/internal/plugin"
|
||||
|
||||
// Types of hooks
|
||||
const (
|
||||
// Install is executed after the plugin is added.
|
||||
Install = "install"
|
||||
// Delete is executed after the plugin is removed.
|
||||
Delete = "delete"
|
||||
// Update is executed after the plugin is updated.
|
||||
Update = "update"
|
||||
)
|
||||
|
||||
// PlatformHooks is a map of events to a command for a particular operating system and architecture.
|
||||
type PlatformHooks map[string][]PlatformCommand
|
||||
|
||||
// Hooks is a map of events to commands.
|
||||
type Hooks map[string]string
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
)
|
||||
|
||||
type InputMessageCLIV1 struct {
|
||||
ExtraArgs []string `json:"extraArgs"`
|
||||
}
|
||||
|
||||
type OutputMessageCLIV1 struct {
|
||||
Data *bytes.Buffer `json:"data"`
|
||||
}
|
||||
|
||||
// ConfigCLIV1 represents the configuration for CLI plugins
|
||||
type ConfigCLIV1 struct {
|
||||
// Usage is the single-line usage text shown in help
|
||||
// For recommended syntax, see [spf13/cobra.command.Command] Use field comment:
|
||||
// https://pkg.go.dev/github.com/spf13/cobra#Command
|
||||
Usage string `yaml:"usage"`
|
||||
// ShortHelp is the short description shown in the 'helm help' output
|
||||
ShortHelp string `yaml:"shortHelp"`
|
||||
// LongHelp is the long message shown in the 'helm help <this-command>' output
|
||||
LongHelp string `yaml:"longHelp"`
|
||||
// IgnoreFlags ignores any flags passed in from Helm
|
||||
IgnoreFlags bool `yaml:"ignoreFlags"`
|
||||
}
|
||||
|
||||
func (c *ConfigCLIV1) Validate() error {
|
||||
// Config validation for CLI plugins
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
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 schema
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
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 schema
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TODO: can we generate these plugin input/output messages?
|
||||
|
||||
type GetterOptionsV1 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
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type InputMessageGetterV1 struct {
|
||||
Href string `json:"href"`
|
||||
Protocol string `json:"protocol"`
|
||||
Options GetterOptionsV1 `json:"options"`
|
||||
}
|
||||
|
||||
type OutputMessageGetterV1 struct {
|
||||
Data []byte `json:"data"`
|
||||
}
|
||||
|
||||
// ConfigGetterV1 represents the configuration for download plugins
|
||||
type ConfigGetterV1 struct {
|
||||
// Protocols are the list of URL schemes supported by this downloader
|
||||
Protocols []string `yaml:"protocols"`
|
||||
}
|
||||
|
||||
func (c *ConfigGetterV1) Validate() error {
|
||||
if len(c.Protocols) == 0 {
|
||||
return fmt.Errorf("getter has no protocols")
|
||||
}
|
||||
for i, protocol := range c.Protocols {
|
||||
if protocol == "" {
|
||||
return fmt.Errorf("getter has empty protocol at index %d", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
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 schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
)
|
||||
|
||||
// InputMessagePostRendererV1 implements Input.Message
|
||||
type InputMessagePostRendererV1 struct {
|
||||
Manifests *bytes.Buffer `json:"manifests"`
|
||||
// from CLI --post-renderer-args
|
||||
ExtraArgs []string `json:"extraArgs"`
|
||||
}
|
||||
|
||||
type OutputMessagePostRendererV1 struct {
|
||||
Manifests *bytes.Buffer `json:"manifests"`
|
||||
}
|
||||
|
||||
type ConfigPostRendererV1 struct{}
|
||||
|
||||
func (c *ConfigPostRendererV1) Validate() error {
|
||||
return nil
|
||||
}
|
||||
@@ -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 schema
|
||||
|
||||
type InputMessageTestV1 struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
type OutputMessageTestV1 struct {
|
||||
Greeting string
|
||||
}
|
||||
|
||||
type ConfigTestV1 struct{}
|
||||
|
||||
func (c *ConfigTestV1) Validate() error {
|
||||
return nil
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
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 plugin
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"sigs.k8s.io/yaml"
|
||||
|
||||
"helm.sh/helm/v4/pkg/provenance"
|
||||
)
|
||||
|
||||
// SignPlugin signs a plugin using the SHA256 hash of the tarball data.
|
||||
//
|
||||
// This is used when packaging and signing a plugin from tarball data.
|
||||
// It creates a signature that includes the tarball hash and plugin metadata,
|
||||
// allowing verification of the original tarball later.
|
||||
func SignPlugin(tarballData []byte, filename string, signer *provenance.Signatory) (string, error) {
|
||||
// Extract plugin metadata from tarball data
|
||||
pluginMeta, err := ExtractTgzPluginMetadata(bytes.NewReader(tarballData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to extract plugin metadata: %w", err)
|
||||
}
|
||||
|
||||
// Marshal plugin metadata to YAML bytes
|
||||
metadataBytes, err := yaml.Marshal(pluginMeta)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal plugin metadata: %w", err)
|
||||
}
|
||||
|
||||
// Use the generic provenance signing function
|
||||
return signer.ClearSign(tarballData, filename, metadataBytes)
|
||||
}
|
||||
|
||||
// ExtractTgzPluginMetadata extracts plugin metadata from a gzipped tarball reader
|
||||
func ExtractTgzPluginMetadata(r io.Reader) (*Metadata, error) {
|
||||
gzr, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer gzr.Close()
|
||||
|
||||
tr := tar.NewReader(gzr)
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Look for plugin.yaml file
|
||||
if filepath.Base(header.Name) == "plugin.yaml" {
|
||||
data, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the plugin metadata
|
||||
metadata, err := loadMetadata(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New("plugin.yaml not found in tarball")
|
||||
}
|
||||
|
||||
// parsePluginMessageBlock parses a signed message block to extract plugin metadata and checksums
|
||||
func parsePluginMessageBlock(data []byte) (*Metadata, *provenance.SumCollection, error) {
|
||||
sc := &provenance.SumCollection{}
|
||||
|
||||
// We only need the checksums for verification, not the full metadata
|
||||
if err := provenance.ParseMessageBlock(data, nil, sc); err != nil {
|
||||
return nil, sc, err
|
||||
}
|
||||
return nil, sc, nil
|
||||
}
|
||||
|
||||
// CreatePluginTarball creates a gzipped tarball from a plugin directory
|
||||
func CreatePluginTarball(sourceDir, pluginName string, w io.Writer) error {
|
||||
gzw := gzip.NewWriter(w)
|
||||
defer gzw.Close()
|
||||
|
||||
tw := tar.NewWriter(gzw)
|
||||
defer tw.Close()
|
||||
|
||||
// Use the plugin name as the base directory in the tarball
|
||||
baseDir := pluginName
|
||||
|
||||
// Walk the directory tree
|
||||
return filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create header
|
||||
header, err := tar.FileInfoHeader(info, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update the name to be relative to the source directory
|
||||
relPath, err := filepath.Rel(sourceDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Include the base directory name in the tarball
|
||||
header.Name = filepath.Join(baseDir, relPath)
|
||||
|
||||
// Write header
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If it's a regular file, write its content
|
||||
if info.Mode().IsRegular() {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := io.Copy(tw, file); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
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 plugin
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp/clearsign" //nolint
|
||||
|
||||
"helm.sh/helm/v4/pkg/helmpath"
|
||||
)
|
||||
|
||||
// SigningInfo contains information about a plugin's signing status
|
||||
type SigningInfo struct {
|
||||
// Status can be:
|
||||
// - "local dev": Plugin is a symlink (development mode)
|
||||
// - "unsigned": No provenance file found
|
||||
// - "invalid provenance": Provenance file is malformed
|
||||
// - "mismatched provenance": Provenance file does not match the installed tarball
|
||||
// - "signed": Valid signature exists for the installed tarball
|
||||
Status string
|
||||
IsSigned bool // True if plugin has a valid signature (even if not verified against keyring)
|
||||
}
|
||||
|
||||
// GetPluginSigningInfo returns signing information for an installed plugin
|
||||
func GetPluginSigningInfo(metadata Metadata) (*SigningInfo, error) {
|
||||
pluginName := metadata.Name
|
||||
pluginDir := helmpath.DataPath("plugins", pluginName)
|
||||
|
||||
// Check if plugin directory exists
|
||||
fi, err := os.Lstat(pluginDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugin %s not found: %w", pluginName, err)
|
||||
}
|
||||
|
||||
// Check if it's a symlink (local development)
|
||||
if fi.Mode()&os.ModeSymlink != 0 {
|
||||
return &SigningInfo{
|
||||
Status: "local dev",
|
||||
IsSigned: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Find the exact tarball file for this plugin
|
||||
pluginsDir := helmpath.DataPath("plugins")
|
||||
tarballPath := filepath.Join(pluginsDir, fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version))
|
||||
if _, err := os.Stat(tarballPath); err != nil {
|
||||
return &SigningInfo{
|
||||
Status: "unsigned",
|
||||
IsSigned: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Check for .prov file associated with the tarball
|
||||
provFile := tarballPath + ".prov"
|
||||
provData, err := os.ReadFile(provFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &SigningInfo{
|
||||
Status: "unsigned",
|
||||
IsSigned: false,
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read provenance file: %w", err)
|
||||
}
|
||||
|
||||
// Parse the provenance file to check validity
|
||||
block, _ := clearsign.Decode(provData)
|
||||
if block == nil {
|
||||
return &SigningInfo{
|
||||
Status: "invalid provenance",
|
||||
IsSigned: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Check if provenance matches the actual tarball
|
||||
blockContent := string(block.Plaintext)
|
||||
if !validateProvenanceHash(blockContent, tarballPath) {
|
||||
return &SigningInfo{
|
||||
Status: "mismatched provenance",
|
||||
IsSigned: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// We have a provenance file that is valid for this plugin
|
||||
// Without a keyring, we can't verify the signature, but we know:
|
||||
// 1. A .prov file exists
|
||||
// 2. It's a valid clearsigned document (cryptographically signed)
|
||||
// 3. The provenance contains valid checksums
|
||||
return &SigningInfo{
|
||||
Status: "signed",
|
||||
IsSigned: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateProvenanceHash(blockContent string, tarballPath string) bool {
|
||||
// Parse provenance to get the expected hash
|
||||
_, sums, err := parsePluginMessageBlock([]byte(blockContent))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must have file checksums
|
||||
if len(sums.Files) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Calculate actual hash of the tarball
|
||||
actualHash, err := calculateFileHash(tarballPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the actual hash matches the expected hash in the provenance
|
||||
for filename, expectedHash := range sums.Files {
|
||||
if strings.Contains(filename, filepath.Base(tarballPath)) && expectedHash == actualHash {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// calculateFileHash calculates the SHA256 hash of a file
|
||||
func calculateFileHash(filePath string) (string, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("sha256:%x", hasher.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// GetSigningInfoForPlugins returns signing info for multiple plugins
|
||||
func GetSigningInfoForPlugins(plugins []Plugin) map[string]*SigningInfo {
|
||||
result := make(map[string]*SigningInfo)
|
||||
|
||||
for _, p := range plugins {
|
||||
m := p.Metadata()
|
||||
|
||||
info, err := GetPluginSigningInfo(m)
|
||||
if err != nil {
|
||||
// If there's an error, treat as unsigned
|
||||
result[m.Name] = &SigningInfo{
|
||||
Status: "unknown",
|
||||
IsSigned: false,
|
||||
}
|
||||
} else {
|
||||
result[m.Name] = info
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
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 plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PlatformCommand represents a command for a particular operating system and architecture
|
||||
type PlatformCommand struct {
|
||||
OperatingSystem string `yaml:"os"`
|
||||
Architecture string `yaml:"arch"`
|
||||
Command string `yaml:"command"`
|
||||
Args []string `yaml:"args"`
|
||||
}
|
||||
|
||||
// Returns command and args strings based on the following rules in priority order:
|
||||
// - From the PlatformCommand where OS and Arch match the current platform
|
||||
// - From the PlatformCommand where OS matches the current platform and Arch is empty/unspecified
|
||||
// - From the PlatformCommand where OS is empty/unspecified and Arch matches the current platform
|
||||
// - From the PlatformCommand where OS and Arch are both empty/unspecified
|
||||
// - Return nil, nil
|
||||
func getPlatformCommand(cmds []PlatformCommand) ([]string, []string) {
|
||||
var command, args []string
|
||||
found := false
|
||||
foundOs := false
|
||||
|
||||
eq := strings.EqualFold
|
||||
for _, c := range cmds {
|
||||
if eq(c.OperatingSystem, runtime.GOOS) && eq(c.Architecture, runtime.GOARCH) {
|
||||
// Return early for an exact match
|
||||
return strings.Split(c.Command, " "), c.Args
|
||||
}
|
||||
|
||||
if (len(c.OperatingSystem) > 0 && !eq(c.OperatingSystem, runtime.GOOS)) || len(c.Architecture) > 0 {
|
||||
// Skip if OS is not empty and doesn't match or if arch is set as a set arch requires an OS match
|
||||
continue
|
||||
}
|
||||
|
||||
if !foundOs && len(c.OperatingSystem) > 0 && eq(c.OperatingSystem, runtime.GOOS) {
|
||||
// First OS match with empty arch, can only be overridden by a direct match
|
||||
command = strings.Split(c.Command, " ")
|
||||
args = c.Args
|
||||
found = true
|
||||
foundOs = true
|
||||
} else if !found {
|
||||
// First empty match, can be overridden by a direct match or an OS match
|
||||
command = strings.Split(c.Command, " ")
|
||||
args = c.Args
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
return command, args
|
||||
}
|
||||
|
||||
// PrepareCommands takes a []Plugin.PlatformCommand
|
||||
// and prepares the command and arguments for execution.
|
||||
//
|
||||
// It merges extraArgs into any arguments supplied in the plugin. It
|
||||
// returns the main command and an args array.
|
||||
//
|
||||
// The result is suitable to pass to exec.Command.
|
||||
func PrepareCommands(cmds []PlatformCommand, expandArgs bool, extraArgs []string, env map[string]string) (string, []string, error) {
|
||||
cmdParts, args := getPlatformCommand(cmds)
|
||||
if len(cmdParts) == 0 || cmdParts[0] == "" {
|
||||
return "", nil, fmt.Errorf("no plugin command is applicable")
|
||||
}
|
||||
envMappingFunc := func(key string) string {
|
||||
return env[key]
|
||||
}
|
||||
|
||||
main := os.Expand(cmdParts[0], envMappingFunc)
|
||||
baseArgs := []string{}
|
||||
if len(cmdParts) > 1 {
|
||||
for _, cmdPart := range cmdParts[1:] {
|
||||
if expandArgs {
|
||||
baseArgs = append(baseArgs, os.Expand(cmdPart, envMappingFunc))
|
||||
} else {
|
||||
baseArgs = append(baseArgs, cmdPart)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, arg := range args {
|
||||
if expandArgs {
|
||||
baseArgs = append(baseArgs, os.Expand(arg, envMappingFunc))
|
||||
} else {
|
||||
baseArgs = append(baseArgs, arg)
|
||||
}
|
||||
}
|
||||
|
||||
if len(extraArgs) > 0 {
|
||||
baseArgs = append(baseArgs, extraArgs...)
|
||||
}
|
||||
|
||||
return main, baseArgs, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
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 plugin
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"helm.sh/helm/v4/pkg/provenance"
|
||||
)
|
||||
|
||||
// VerifyPlugin verifies plugin data against a signature using data in memory.
|
||||
func VerifyPlugin(archiveData, provData []byte, filename, keyring string) (*provenance.Verification, error) {
|
||||
// Create signatory from keyring
|
||||
sig, err := provenance.NewFromKeyring(keyring, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Use the new VerifyData method directly
|
||||
return sig.Verify(archiveData, provData, filename)
|
||||
}
|
||||
|
||||
// isTarball checks if a file has a tarball extension
|
||||
func IsTarball(filename string) bool {
|
||||
return filepath.Ext(filename) == ".gz" || filepath.Ext(filename) == ".tgz"
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
This file was initially copied and modified from
|
||||
https://github.com/fluxcd/kustomize-controller/blob/main/internal/statusreaders/job.go
|
||||
Copyright 2022 The Flux 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 statusreaders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
|
||||
"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/object"
|
||||
)
|
||||
|
||||
type customJobStatusReader struct {
|
||||
genericStatusReader engine.StatusReader
|
||||
}
|
||||
|
||||
func NewCustomJobStatusReader(mapper meta.RESTMapper) engine.StatusReader {
|
||||
genericStatusReader := statusreaders.NewGenericStatusReader(mapper, jobConditions)
|
||||
return &customJobStatusReader{
|
||||
genericStatusReader: genericStatusReader,
|
||||
}
|
||||
}
|
||||
|
||||
func (j *customJobStatusReader) Supports(gk schema.GroupKind) bool {
|
||||
return gk == batchv1.SchemeGroupVersion.WithKind("Job").GroupKind()
|
||||
}
|
||||
|
||||
func (j *customJobStatusReader) ReadStatus(ctx context.Context, reader engine.ClusterReader, resource object.ObjMetadata) (*event.ResourceStatus, error) {
|
||||
return j.genericStatusReader.ReadStatus(ctx, reader, resource)
|
||||
}
|
||||
|
||||
func (j *customJobStatusReader) ReadStatusForObject(ctx context.Context, reader engine.ClusterReader, resource *unstructured.Unstructured) (*event.ResourceStatus, error) {
|
||||
return j.genericStatusReader.ReadStatusForObject(ctx, reader, resource)
|
||||
}
|
||||
|
||||
// Ref: https://github.com/kubernetes-sigs/cli-utils/blob/v0.29.4/pkg/kstatus/status/core.go
|
||||
// Modified to return Current status only when the Job has completed as opposed to when it's in progress.
|
||||
func jobConditions(u *unstructured.Unstructured) (*status.Result, error) {
|
||||
obj := u.UnstructuredContent()
|
||||
|
||||
parallelism := status.GetIntField(obj, ".spec.parallelism", 1)
|
||||
completions := status.GetIntField(obj, ".spec.completions", parallelism)
|
||||
succeeded := status.GetIntField(obj, ".status.succeeded", 0)
|
||||
failed := status.GetIntField(obj, ".status.failed", 0)
|
||||
|
||||
// Conditions
|
||||
// https://github.com/kubernetes/kubernetes/blob/master/pkg/controller/job/utils.go#L24
|
||||
objc, err := status.GetObjectWithConditions(obj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range objc.Status.Conditions {
|
||||
switch c.Type {
|
||||
case "Complete":
|
||||
if c.Status == corev1.ConditionTrue {
|
||||
message := fmt.Sprintf("Job Completed. succeeded: %d/%d", succeeded, completions)
|
||||
return &status.Result{
|
||||
Status: status.CurrentStatus,
|
||||
Message: message,
|
||||
Conditions: []status.Condition{},
|
||||
}, nil
|
||||
}
|
||||
case "Failed":
|
||||
message := fmt.Sprintf("Job Failed. failed: %d/%d", failed, completions)
|
||||
if c.Status == corev1.ConditionTrue {
|
||||
return &status.Result{
|
||||
Status: status.FailedStatus,
|
||||
Message: message,
|
||||
Conditions: []status.Condition{
|
||||
{
|
||||
Type: status.ConditionStalled,
|
||||
Status: corev1.ConditionTrue,
|
||||
Reason: "JobFailed",
|
||||
Message: message,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
message := "Job in progress"
|
||||
return &status.Result{
|
||||
Status: status.InProgressStatus,
|
||||
Message: message,
|
||||
Conditions: []status.Condition{
|
||||
{
|
||||
Type: status.ConditionReconciling,
|
||||
Status: corev1.ConditionTrue,
|
||||
Reason: "JobInProgress",
|
||||
Message: message,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
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 statusreaders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
|
||||
"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/object"
|
||||
)
|
||||
|
||||
type customPodStatusReader struct {
|
||||
genericStatusReader engine.StatusReader
|
||||
}
|
||||
|
||||
func NewCustomPodStatusReader(mapper meta.RESTMapper) engine.StatusReader {
|
||||
genericStatusReader := statusreaders.NewGenericStatusReader(mapper, podConditions)
|
||||
return &customPodStatusReader{
|
||||
genericStatusReader: genericStatusReader,
|
||||
}
|
||||
}
|
||||
|
||||
func (j *customPodStatusReader) Supports(gk schema.GroupKind) bool {
|
||||
return gk == corev1.SchemeGroupVersion.WithKind("Pod").GroupKind()
|
||||
}
|
||||
|
||||
func (j *customPodStatusReader) ReadStatus(ctx context.Context, reader engine.ClusterReader, resource object.ObjMetadata) (*event.ResourceStatus, error) {
|
||||
return j.genericStatusReader.ReadStatus(ctx, reader, resource)
|
||||
}
|
||||
|
||||
func (j *customPodStatusReader) ReadStatusForObject(ctx context.Context, reader engine.ClusterReader, resource *unstructured.Unstructured) (*event.ResourceStatus, error) {
|
||||
return j.genericStatusReader.ReadStatusForObject(ctx, reader, resource)
|
||||
}
|
||||
|
||||
func podConditions(u *unstructured.Unstructured) (*status.Result, error) {
|
||||
obj := u.UnstructuredContent()
|
||||
phase := status.GetStringField(obj, ".status.phase", "")
|
||||
switch corev1.PodPhase(phase) {
|
||||
case corev1.PodSucceeded:
|
||||
message := fmt.Sprintf("pod %s succeeded", u.GetName())
|
||||
return &status.Result{
|
||||
Status: status.CurrentStatus,
|
||||
Message: message,
|
||||
Conditions: []status.Condition{
|
||||
{
|
||||
Type: status.ConditionStalled,
|
||||
Status: corev1.ConditionTrue,
|
||||
Message: message,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
case corev1.PodFailed:
|
||||
message := fmt.Sprintf("pod %s failed", u.GetName())
|
||||
return &status.Result{
|
||||
Status: status.FailedStatus,
|
||||
Message: message,
|
||||
Conditions: []status.Condition{
|
||||
{
|
||||
Type: status.ConditionStalled,
|
||||
Status: corev1.ConditionTrue,
|
||||
Reason: "PodFailed",
|
||||
Message: message,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
default:
|
||||
message := "Pod in progress"
|
||||
return &status.Result{
|
||||
Status: status.InProgressStatus,
|
||||
Message: message,
|
||||
Conditions: []status.Condition{
|
||||
{
|
||||
Type: status.ConditionReconciling,
|
||||
Status: corev1.ConditionTrue,
|
||||
Reason: "PodInProgress",
|
||||
Message: message,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
/*
|
||||
Copyright (c) for portions of fs.go are held by The Go Authors, 2016 and are provided under
|
||||
the BSD license.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
* Neither the name of Google Inc. nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
package fs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// fs contains a copy of a few functions from dep tool code to avoid a dependency on golang/dep.
|
||||
// This code is copied from https://github.com/golang/dep/blob/37d6c560cdf407be7b6cd035b23dba89df9275cf/internal/fs/fs.go
|
||||
// No changes to the code were made other than removing some unused functions
|
||||
|
||||
// RenameWithFallback attempts to rename a file or directory, but falls back to
|
||||
// copying in the event of a cross-device link error. If the fallback copy
|
||||
// succeeds, src is still removed, emulating normal rename behavior.
|
||||
func RenameWithFallback(src, dst string) error {
|
||||
_, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot stat %s: %w", src, err)
|
||||
}
|
||||
|
||||
err = os.Rename(src, dst)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return renameFallback(err, src, dst)
|
||||
}
|
||||
|
||||
// renameByCopy attempts to rename a file or directory by copying it to the
|
||||
// destination and then removing the src thus emulating the rename behavior.
|
||||
func renameByCopy(src, dst string) error {
|
||||
var cerr error
|
||||
if dir, _ := IsDir(src); dir {
|
||||
cerr = CopyDir(src, dst)
|
||||
if cerr != nil {
|
||||
cerr = fmt.Errorf("copying directory failed: %w", cerr)
|
||||
}
|
||||
} else {
|
||||
cerr = CopyFile(src, dst)
|
||||
if cerr != nil {
|
||||
cerr = fmt.Errorf("copying file failed: %w", cerr)
|
||||
}
|
||||
}
|
||||
|
||||
if cerr != nil {
|
||||
return fmt.Errorf("rename fallback failed: cannot rename %s to %s: %w", src, dst, cerr)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(src); err != nil {
|
||||
return fmt.Errorf("cannot delete %s: %w", src, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
errSrcNotDir = errors.New("source is not a directory")
|
||||
errDstExist = errors.New("destination already exists")
|
||||
)
|
||||
|
||||
// CopyDir recursively copies a directory tree, attempting to preserve permissions.
|
||||
// Source directory must exist, destination directory must *not* exist.
|
||||
func CopyDir(src, dst string) error {
|
||||
src = filepath.Clean(src)
|
||||
dst = filepath.Clean(dst)
|
||||
|
||||
// We use os.Lstat() here to ensure we don't fall in a loop where a symlink
|
||||
// actually links to a one of its parent directories.
|
||||
fi, err := os.Lstat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
return errSrcNotDir
|
||||
}
|
||||
|
||||
_, err = os.Stat(dst)
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err == nil {
|
||||
return errDstExist
|
||||
}
|
||||
|
||||
if err = os.MkdirAll(dst, fi.Mode()); err != nil {
|
||||
return fmt.Errorf("cannot mkdir %s: %w", dst, err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read directory %s: %w", dst, err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
srcPath := filepath.Join(src, entry.Name())
|
||||
dstPath := filepath.Join(dst, entry.Name())
|
||||
|
||||
if entry.IsDir() {
|
||||
if err = CopyDir(srcPath, dstPath); err != nil {
|
||||
return fmt.Errorf("copying directory failed: %w", err)
|
||||
}
|
||||
} else {
|
||||
// This will include symlinks, which is what we want when
|
||||
// copying things.
|
||||
if err = CopyFile(srcPath, dstPath); err != nil {
|
||||
return fmt.Errorf("copying file failed: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CopyFile copies the contents of the file named src to the file named
|
||||
// by dst. The file will be created if it does not already exist. If the
|
||||
// destination file exists, all its contents will be replaced by the contents
|
||||
// of the source file. The file mode will be copied from the source.
|
||||
func CopyFile(src, dst string) (err error) {
|
||||
if sym, err := IsSymlink(src); err != nil {
|
||||
return fmt.Errorf("symlink check failed: %w", err)
|
||||
} else if sym {
|
||||
if err := cloneSymlink(src, dst); err != nil {
|
||||
if runtime.GOOS == "windows" {
|
||||
// If cloning the symlink fails on Windows because the user
|
||||
// does not have the required privileges, ignore the error and
|
||||
// fall back to copying the file contents.
|
||||
//
|
||||
// ERROR_PRIVILEGE_NOT_HELD is 1314 (0x522):
|
||||
// https://msdn.microsoft.com/en-us/library/windows/desktop/ms681385(v=vs.85).aspx
|
||||
if lerr, ok := err.(*os.LinkError); ok && lerr.Err != syscall.Errno(1314) {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = io.Copy(out, in); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
// Check for write errors on Close
|
||||
if err = out.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
si, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Temporary fix for Go < 1.9
|
||||
//
|
||||
// See: https://github.com/golang/dep/issues/774
|
||||
// and https://github.com/golang/go/issues/20829
|
||||
if runtime.GOOS == "windows" {
|
||||
dst = fixLongPath(dst)
|
||||
}
|
||||
err = os.Chmod(dst, si.Mode())
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// cloneSymlink will create a new symlink that points to the resolved path of sl.
|
||||
// If sl is a relative symlink, dst will also be a relative symlink.
|
||||
func cloneSymlink(sl, dst string) error {
|
||||
resolved, err := os.Readlink(sl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.Symlink(resolved, dst)
|
||||
}
|
||||
|
||||
// IsDir determines is the path given is a directory or not.
|
||||
func IsDir(name string) (bool, error) {
|
||||
fi, err := os.Stat(name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
return false, fmt.Errorf("%q is not a directory", name)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// IsSymlink determines if the given path is a symbolic link.
|
||||
func IsSymlink(path string) (bool, error) {
|
||||
l, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return l.Mode()&os.ModeSymlink == os.ModeSymlink, nil
|
||||
}
|
||||
|
||||
// fixLongPath returns the extended-length (\\?\-prefixed) form of
|
||||
// path when needed, in order to avoid the default 260 character file
|
||||
// path limit imposed by Windows. If path is not easily converted to
|
||||
// the extended-length form (for example, if path is a relative path
|
||||
// or contains .. elements), or is short enough, fixLongPath returns
|
||||
// path unmodified.
|
||||
//
|
||||
// See https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx#maxpath
|
||||
func fixLongPath(path string) string {
|
||||
// Do nothing (and don't allocate) if the path is "short".
|
||||
// Empirically (at least on the Windows Server 2013 builder),
|
||||
// the kernel is arbitrarily okay with < 248 bytes. That
|
||||
// matches what the docs above say:
|
||||
// "When using an API to create a directory, the specified
|
||||
// path cannot be so long that you cannot append an 8.3 file
|
||||
// name (that is, the directory name cannot exceed MAX_PATH
|
||||
// minus 12)." Since MAX_PATH is 260, 260 - 12 = 248.
|
||||
//
|
||||
// The MSDN docs appear to say that a normal path that is 248 bytes long
|
||||
// will work; empirically the path must be less than 248 bytes long.
|
||||
if len(path) < 248 {
|
||||
// Don't fix. (This is how Go 1.7 and earlier worked,
|
||||
// not automatically generating the \\?\ form)
|
||||
return path
|
||||
}
|
||||
|
||||
// The extended form begins with \\?\, as in
|
||||
// \\?\c:\windows\foo.txt or \\?\UNC\server\share\foo.txt.
|
||||
// The extended form disables evaluation of . and .. path
|
||||
// elements and disables the interpretation of / as equivalent
|
||||
// to \. The conversion here rewrites / to \ and elides
|
||||
// . elements as well as trailing or duplicate separators. For
|
||||
// simplicity it avoids the conversion entirely for relative
|
||||
// paths or paths containing .. elements. For now,
|
||||
// \\server\share paths are not converted to
|
||||
// \\?\UNC\server\share paths because the rules for doing so
|
||||
// are less well-specified.
|
||||
if len(path) >= 2 && path[:2] == `\\` {
|
||||
// Don't canonicalize UNC paths.
|
||||
return path
|
||||
}
|
||||
if !isAbs(path) {
|
||||
// Relative path
|
||||
return path
|
||||
}
|
||||
|
||||
const prefix = `\\?`
|
||||
|
||||
pathbuf := make([]byte, len(prefix)+len(path)+len(`\`))
|
||||
copy(pathbuf, prefix)
|
||||
n := len(path)
|
||||
r, w := 0, len(prefix)
|
||||
for r < n {
|
||||
switch {
|
||||
case os.IsPathSeparator(path[r]):
|
||||
// empty block
|
||||
r++
|
||||
case path[r] == '.' && (r+1 == n || os.IsPathSeparator(path[r+1])):
|
||||
// /./
|
||||
r++
|
||||
case r+1 < n && path[r] == '.' && path[r+1] == '.' && (r+2 == n || os.IsPathSeparator(path[r+2])):
|
||||
// /../ is currently unhandled
|
||||
return path
|
||||
default:
|
||||
pathbuf[w] = '\\'
|
||||
w++
|
||||
for ; r < n && !os.IsPathSeparator(path[r]); r++ {
|
||||
pathbuf[w] = path[r]
|
||||
w++
|
||||
}
|
||||
}
|
||||
}
|
||||
// A drive's root directory needs a trailing \
|
||||
if w == len(`\\?\c:`) {
|
||||
pathbuf[w] = '\\'
|
||||
w++
|
||||
}
|
||||
return string(pathbuf[:w])
|
||||
}
|
||||
|
||||
func isAbs(path string) (b bool) {
|
||||
v := volumeName(path)
|
||||
if v == "" {
|
||||
return false
|
||||
}
|
||||
path = path[len(v):]
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
return os.IsPathSeparator(path[0])
|
||||
}
|
||||
|
||||
func volumeName(path string) (v string) {
|
||||
if len(path) < 2 {
|
||||
return ""
|
||||
}
|
||||
// with drive letter
|
||||
c := path[0]
|
||||
if path[1] == ':' &&
|
||||
('0' <= c && c <= '9' || 'a' <= c && c <= 'z' ||
|
||||
'A' <= c && c <= 'Z') {
|
||||
return path[:2]
|
||||
}
|
||||
// is it UNC
|
||||
if l := len(path); l >= 5 && os.IsPathSeparator(path[0]) && os.IsPathSeparator(path[1]) &&
|
||||
!os.IsPathSeparator(path[2]) && path[2] != '.' {
|
||||
// first, leading `\\` and next shouldn't be `\`. its server name.
|
||||
for n := 3; n < l-1; n++ {
|
||||
// second, next '\' shouldn't be repeated.
|
||||
if os.IsPathSeparator(path[n]) {
|
||||
n++
|
||||
// third, following something characters. its share name.
|
||||
if !os.IsPathSeparator(path[n]) {
|
||||
if path[n] == '.' {
|
||||
break
|
||||
}
|
||||
for ; n < l; n++ {
|
||||
if os.IsPathSeparator(path[n]) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return path[:n]
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//go:build !windows
|
||||
|
||||
/*
|
||||
Copyright (c) for portions of rename.go are held by The Go Authors, 2016 and are provided under
|
||||
the BSD license.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
* Neither the name of Google Inc. nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
package fs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// renameFallback attempts to determine the appropriate fallback to failed rename
|
||||
// operation depending on the resulting error.
|
||||
func renameFallback(err error, src, dst string) error {
|
||||
// Rename may fail if src and dst are on different devices; fall back to
|
||||
// copy if we detect that case. syscall.EXDEV is the common name for the
|
||||
// cross device link error which has varying output text across different
|
||||
// operating systems.
|
||||
terr, ok := err.(*os.LinkError)
|
||||
if !ok {
|
||||
return err
|
||||
} else if terr.Err != syscall.EXDEV {
|
||||
return fmt.Errorf("link error: cannot rename %s to %s: %w", src, dst, terr)
|
||||
}
|
||||
|
||||
return renameByCopy(src, dst)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//go:build windows
|
||||
|
||||
/*
|
||||
Copyright (c) for portions of rename_windows.go are held by The Go Authors, 2016 and are provided under
|
||||
the BSD license.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
* Neither the name of Google Inc. nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
package fs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// renameFallback attempts to determine the appropriate fallback to failed rename
|
||||
// operation depending on the resulting error.
|
||||
func renameFallback(err error, src, dst string) error {
|
||||
// Rename may fail if src and dst are on different devices; fall back to
|
||||
// copy if we detect that case. syscall.EXDEV is the common name for the
|
||||
// cross device link error which has varying output text across different
|
||||
// operating systems.
|
||||
terr, ok := err.(*os.LinkError)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
if terr.Err != syscall.EXDEV {
|
||||
// In windows it can drop down to an operating system call that
|
||||
// returns an operating system error with a different number and
|
||||
// message. Checking for that as a fall back.
|
||||
noerr, ok := terr.Err.(syscall.Errno)
|
||||
|
||||
// 0x11 (ERROR_NOT_SAME_DEVICE) is the windows error.
|
||||
// See https://msdn.microsoft.com/en-us/library/cc231199.aspx
|
||||
if ok && noerr != 0x11 {
|
||||
return fmt.Errorf("link error: cannot rename %s to %s: %w", src, dst, terr)
|
||||
}
|
||||
}
|
||||
|
||||
return renameByCopy(src, dst)
|
||||
}
|
||||
Vendored
+178
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
Copyright 2016 The Kubernetes 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 util
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
apps "k8s.io/api/apps/v1"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
apiequality "k8s.io/apimachinery/pkg/api/equality"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
intstrutil "k8s.io/apimachinery/pkg/util/intstr"
|
||||
appsclient "k8s.io/client-go/kubernetes/typed/apps/v1"
|
||||
)
|
||||
|
||||
// deploymentutil contains a copy of a few functions from Kubernetes controller code to avoid a dependency on k8s.io/kubernetes.
|
||||
// This code is copied from https://github.com/kubernetes/kubernetes/blob/e856613dd5bb00bcfaca6974431151b5c06cbed5/pkg/controller/deployment/util/deployment_util.go
|
||||
// No changes to the code were made other than removing some unused functions
|
||||
|
||||
// RsListFunc returns the ReplicaSet from the ReplicaSet namespace and the List metav1.ListOptions.
|
||||
type RsListFunc func(string, metav1.ListOptions) ([]*apps.ReplicaSet, error)
|
||||
|
||||
// ListReplicaSets returns a slice of RSes the given deployment targets.
|
||||
// Note that this does NOT attempt to reconcile ControllerRef (adopt/orphan),
|
||||
// because only the controller itself should do that.
|
||||
// However, it does filter out anything whose ControllerRef doesn't match.
|
||||
func ListReplicaSets(deployment *apps.Deployment, getRSList RsListFunc) ([]*apps.ReplicaSet, error) {
|
||||
// TODO: Right now we list replica sets by their labels. We should list them by selector, i.e. the replica set's selector
|
||||
// should be a superset of the deployment's selector, see https://github.com/kubernetes/kubernetes/issues/19830.
|
||||
namespace := deployment.Namespace
|
||||
selector, err := metav1.LabelSelectorAsSelector(deployment.Spec.Selector)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options := metav1.ListOptions{LabelSelector: selector.String()}
|
||||
all, err := getRSList(namespace, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Only include those whose ControllerRef matches the Deployment.
|
||||
owned := make([]*apps.ReplicaSet, 0, len(all))
|
||||
for _, rs := range all {
|
||||
if metav1.IsControlledBy(rs, deployment) {
|
||||
owned = append(owned, rs)
|
||||
}
|
||||
}
|
||||
return owned, nil
|
||||
}
|
||||
|
||||
// ReplicaSetsByCreationTimestamp sorts a list of ReplicaSet by creation timestamp, using their names as a tie breaker.
|
||||
type ReplicaSetsByCreationTimestamp []*apps.ReplicaSet
|
||||
|
||||
func (o ReplicaSetsByCreationTimestamp) Len() int { return len(o) }
|
||||
func (o ReplicaSetsByCreationTimestamp) Swap(i, j int) { o[i], o[j] = o[j], o[i] }
|
||||
func (o ReplicaSetsByCreationTimestamp) Less(i, j int) bool {
|
||||
if o[i].CreationTimestamp.Equal(&o[j].CreationTimestamp) {
|
||||
return o[i].Name < o[j].Name
|
||||
}
|
||||
return o[i].CreationTimestamp.Before(&o[j].CreationTimestamp)
|
||||
}
|
||||
|
||||
// FindNewReplicaSet returns the new RS this given deployment targets (the one with the same pod template).
|
||||
func FindNewReplicaSet(deployment *apps.Deployment, rsList []*apps.ReplicaSet) *apps.ReplicaSet {
|
||||
sort.Sort(ReplicaSetsByCreationTimestamp(rsList))
|
||||
for i := range rsList {
|
||||
if EqualIgnoreHash(&rsList[i].Spec.Template, &deployment.Spec.Template) {
|
||||
// In rare cases, such as after cluster upgrades, Deployment may end up with
|
||||
// having more than one new ReplicaSets that have the same template as its template,
|
||||
// see https://github.com/kubernetes/kubernetes/issues/40415
|
||||
// We deterministically choose the oldest new ReplicaSet.
|
||||
return rsList[i]
|
||||
}
|
||||
}
|
||||
// new ReplicaSet does not exist.
|
||||
return nil
|
||||
}
|
||||
|
||||
// EqualIgnoreHash returns true if two given podTemplateSpec are equal, ignoring the diff in value of Labels[pod-template-hash]
|
||||
// We ignore pod-template-hash because:
|
||||
// 1. The hash result would be different upon podTemplateSpec API changes
|
||||
// (e.g. the addition of a new field will cause the hash code to change)
|
||||
// 2. The deployment template won't have hash labels
|
||||
func EqualIgnoreHash(template1, template2 *v1.PodTemplateSpec) bool {
|
||||
t1Copy := template1.DeepCopy()
|
||||
t2Copy := template2.DeepCopy()
|
||||
// Remove hash labels from template.Labels before comparing
|
||||
delete(t1Copy.Labels, apps.DefaultDeploymentUniqueLabelKey)
|
||||
delete(t2Copy.Labels, apps.DefaultDeploymentUniqueLabelKey)
|
||||
return apiequality.Semantic.DeepEqual(t1Copy, t2Copy)
|
||||
}
|
||||
|
||||
// GetNewReplicaSet returns a replica set that matches the intent of the given deployment; get ReplicaSetList from client interface.
|
||||
// Returns nil if the new replica set doesn't exist yet.
|
||||
func GetNewReplicaSet(deployment *apps.Deployment, c appsclient.AppsV1Interface) (*apps.ReplicaSet, error) {
|
||||
rsList, err := ListReplicaSets(deployment, RsListFromClient(c))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return FindNewReplicaSet(deployment, rsList), nil
|
||||
}
|
||||
|
||||
// RsListFromClient returns an rsListFunc that wraps the given client.
|
||||
func RsListFromClient(c appsclient.AppsV1Interface) RsListFunc {
|
||||
return func(namespace string, options metav1.ListOptions) ([]*apps.ReplicaSet, error) {
|
||||
rsList, err := c.ReplicaSets(namespace).List(context.Background(), options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ret []*apps.ReplicaSet
|
||||
for i := range rsList.Items {
|
||||
ret = append(ret, &rsList.Items[i])
|
||||
}
|
||||
return ret, err
|
||||
}
|
||||
}
|
||||
|
||||
// IsRollingUpdate returns true if the strategy type is a rolling update.
|
||||
func IsRollingUpdate(deployment *apps.Deployment) bool {
|
||||
return deployment.Spec.Strategy.Type == apps.RollingUpdateDeploymentStrategyType
|
||||
}
|
||||
|
||||
// MaxUnavailable returns the maximum unavailable pods a rolling deployment can take.
|
||||
func MaxUnavailable(deployment apps.Deployment) int32 {
|
||||
if !IsRollingUpdate(&deployment) || *(deployment.Spec.Replicas) == 0 {
|
||||
return int32(0)
|
||||
}
|
||||
// Error caught by validation
|
||||
_, maxUnavailable, _ := ResolveFenceposts(deployment.Spec.Strategy.RollingUpdate.MaxSurge, deployment.Spec.Strategy.RollingUpdate.MaxUnavailable, *(deployment.Spec.Replicas))
|
||||
if maxUnavailable > *deployment.Spec.Replicas {
|
||||
return *deployment.Spec.Replicas
|
||||
}
|
||||
return maxUnavailable
|
||||
}
|
||||
|
||||
// ResolveFenceposts resolves both maxSurge and maxUnavailable. This needs to happen in one
|
||||
// step. For example:
|
||||
//
|
||||
// 2 desired, max unavailable 1%, surge 0% - should scale old(-1), then new(+1), then old(-1), then new(+1)
|
||||
// 1 desired, max unavailable 1%, surge 0% - should scale old(-1), then new(+1)
|
||||
// 2 desired, max unavailable 25%, surge 1% - should scale new(+1), then old(-1), then new(+1), then old(-1)
|
||||
// 1 desired, max unavailable 25%, surge 1% - should scale new(+1), then old(-1)
|
||||
// 2 desired, max unavailable 0%, surge 1% - should scale new(+1), then old(-1), then new(+1), then old(-1)
|
||||
// 1 desired, max unavailable 0%, surge 1% - should scale new(+1), then old(-1)
|
||||
func ResolveFenceposts(maxSurge, maxUnavailable *intstrutil.IntOrString, desired int32) (int32, int32, error) {
|
||||
surge, err := intstrutil.GetValueFromIntOrPercent(intstrutil.ValueOrDefault(maxSurge, intstrutil.FromInt(0)), int(desired), true)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
unavailable, err := intstrutil.GetValueFromIntOrPercent(intstrutil.ValueOrDefault(maxUnavailable, intstrutil.FromInt(0)), int(desired), false)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
if surge == 0 && unavailable == 0 {
|
||||
// Validation should never allow the user to explicitly use zero values for both maxSurge
|
||||
// maxUnavailable. Due to rounding down maxUnavailable though, it may resolve to zero.
|
||||
// If both fenceposts resolve to zero, then we should set maxUnavailable to 1 on the
|
||||
// theory that surge might not work due to quota.
|
||||
unavailable = 1
|
||||
}
|
||||
|
||||
return int32(surge), int32(unavailable), nil
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
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 tlsutil
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"errors"
|
||||
)
|
||||
|
||||
type TLSConfigOptions struct {
|
||||
insecureSkipTLSVerify bool
|
||||
certPEMBlock, keyPEMBlock []byte
|
||||
caPEMBlock []byte
|
||||
}
|
||||
|
||||
type TLSConfigOption func(options *TLSConfigOptions) error
|
||||
|
||||
func WithInsecureSkipVerify(insecureSkipTLSVerify bool) TLSConfigOption {
|
||||
return func(options *TLSConfigOptions) error {
|
||||
options.insecureSkipTLSVerify = insecureSkipTLSVerify
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithCertKeyPairFiles(certFile, keyFile string) TLSConfigOption {
|
||||
return func(options *TLSConfigOptions) error {
|
||||
if certFile == "" && keyFile == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
certPEMBlock, err := os.ReadFile(certFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to read cert file: %q: %w", certFile, err)
|
||||
}
|
||||
|
||||
keyPEMBlock, err := os.ReadFile(keyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to read key file: %q: %w", keyFile, err)
|
||||
}
|
||||
|
||||
options.certPEMBlock = certPEMBlock
|
||||
options.keyPEMBlock = keyPEMBlock
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithCAFile(caFile string) TLSConfigOption {
|
||||
return func(options *TLSConfigOptions) error {
|
||||
if caFile == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
caPEMBlock, err := os.ReadFile(caFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't read CA file: %q: %w", caFile, err)
|
||||
}
|
||||
|
||||
options.caPEMBlock = caPEMBlock
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func NewTLSConfig(options ...TLSConfigOption) (*tls.Config, error) {
|
||||
to := TLSConfigOptions{}
|
||||
|
||||
errs := []error{}
|
||||
for _, option := range options {
|
||||
err := option(&to)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return nil, errors.Join(errs...)
|
||||
}
|
||||
|
||||
config := tls.Config{
|
||||
InsecureSkipVerify: to.insecureSkipTLSVerify,
|
||||
}
|
||||
|
||||
if len(to.certPEMBlock) > 0 && len(to.keyPEMBlock) > 0 {
|
||||
cert, err := tls.X509KeyPair(to.certPEMBlock, to.keyPEMBlock)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to load cert from key pair: %w", err)
|
||||
}
|
||||
|
||||
config.Certificates = []tls.Certificate{cert}
|
||||
}
|
||||
|
||||
if len(to.caPEMBlock) > 0 {
|
||||
cp := x509.NewCertPool()
|
||||
if !cp.AppendCertsFromPEM(to.caPEMBlock) {
|
||||
return nil, fmt.Errorf("failed to append certificates from pem block")
|
||||
}
|
||||
|
||||
config.RootCAs = cp
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
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 urlutil
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"path"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// URLJoin joins a base URL to one or more path components.
|
||||
//
|
||||
// It's like filepath.Join for URLs. If the baseURL is pathish, this will still
|
||||
// perform a join.
|
||||
//
|
||||
// If the URL is unparsable, this returns an error.
|
||||
func URLJoin(baseURL string, paths ...string) (string, error) {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// We want path instead of filepath because path always uses /.
|
||||
all := []string{u.Path}
|
||||
all = append(all, paths...)
|
||||
u.Path = path.Join(all...)
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// Equal normalizes two URLs and then compares for equality.
|
||||
func Equal(a, b string) bool {
|
||||
au, err := url.Parse(a)
|
||||
if err != nil {
|
||||
a = filepath.Clean(a)
|
||||
b = filepath.Clean(b)
|
||||
// If urls are paths, return true only if they are an exact match
|
||||
return a == b
|
||||
}
|
||||
bu, err := url.Parse(b)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, u := range []*url.URL{au, bu} {
|
||||
if u.Path == "" {
|
||||
u.Path = "/"
|
||||
}
|
||||
u.Path = filepath.Clean(u.Path)
|
||||
}
|
||||
return au.String() == bu.String()
|
||||
}
|
||||
|
||||
// ExtractHostname returns hostname from URL
|
||||
func ExtractHostname(addr string) (string, error) {
|
||||
u, err := url.Parse(addr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.Hostname(), nil
|
||||
}
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/*
|
||||
Package cli describes the operating environment for the Helm CLI.
|
||||
|
||||
Helm's environment encapsulates all of the service dependencies Helm has.
|
||||
These dependencies are expressed as interfaces so that alternate implementations
|
||||
(mocks, etc.) can be easily generated.
|
||||
*/
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"k8s.io/cli-runtime/pkg/genericclioptions"
|
||||
"k8s.io/client-go/rest"
|
||||
|
||||
"helm.sh/helm/v4/internal/version"
|
||||
"helm.sh/helm/v4/pkg/helmpath"
|
||||
"helm.sh/helm/v4/pkg/kube"
|
||||
)
|
||||
|
||||
// defaultMaxHistory sets the maximum number of releases to 0: unlimited
|
||||
const defaultMaxHistory = 10
|
||||
|
||||
// defaultBurstLimit sets the default client-side throttling limit
|
||||
const defaultBurstLimit = 100
|
||||
|
||||
// defaultQPS sets the default QPS value to 0 to use library defaults unless specified
|
||||
const defaultQPS = float32(0)
|
||||
|
||||
// EnvSettings describes all of the environment settings.
|
||||
type EnvSettings struct {
|
||||
namespace string
|
||||
config *genericclioptions.ConfigFlags
|
||||
|
||||
// KubeConfig is the path to the kubeconfig file
|
||||
KubeConfig string
|
||||
// KubeContext is the name of the kubeconfig context.
|
||||
KubeContext string
|
||||
// Bearer KubeToken used for authentication
|
||||
KubeToken string
|
||||
// Username to impersonate for the operation
|
||||
KubeAsUser string
|
||||
// Groups to impersonate for the operation, multiple groups parsed from a comma delimited list
|
||||
KubeAsGroups []string
|
||||
// Kubernetes API Server Endpoint for authentication
|
||||
KubeAPIServer string
|
||||
// Custom certificate authority file.
|
||||
KubeCaFile string
|
||||
// KubeInsecureSkipTLSVerify indicates if server's certificate will not be checked for validity.
|
||||
// This makes the HTTPS connections insecure
|
||||
KubeInsecureSkipTLSVerify bool
|
||||
// KubeTLSServerName overrides the name to use for server certificate validation.
|
||||
// If it is not provided, the hostname used to contact the server is used
|
||||
KubeTLSServerName string
|
||||
// Debug indicates whether or not Helm is running in Debug mode.
|
||||
Debug bool
|
||||
// RegistryConfig is the path to the registry config file.
|
||||
RegistryConfig string
|
||||
// RepositoryConfig is the path to the repositories file.
|
||||
RepositoryConfig string
|
||||
// RepositoryCache is the path to the repository cache directory.
|
||||
RepositoryCache string
|
||||
// PluginsDirectory is the path to the plugins directory.
|
||||
PluginsDirectory string
|
||||
// MaxHistory is the max release history maintained.
|
||||
MaxHistory int
|
||||
// BurstLimit is the default client-side throttling limit.
|
||||
BurstLimit int
|
||||
// QPS is queries per second which may be used to avoid throttling.
|
||||
QPS float32
|
||||
// ColorMode controls colorized output (never, auto, always)
|
||||
ColorMode string
|
||||
// ContentCache is the location where cached charts are stored
|
||||
ContentCache string
|
||||
}
|
||||
|
||||
func New() *EnvSettings {
|
||||
env := &EnvSettings{
|
||||
namespace: os.Getenv("HELM_NAMESPACE"),
|
||||
MaxHistory: envIntOr("HELM_MAX_HISTORY", defaultMaxHistory),
|
||||
KubeConfig: os.Getenv("KUBECONFIG"),
|
||||
KubeContext: os.Getenv("HELM_KUBECONTEXT"),
|
||||
KubeToken: os.Getenv("HELM_KUBETOKEN"),
|
||||
KubeAsUser: os.Getenv("HELM_KUBEASUSER"),
|
||||
KubeAsGroups: envCSV("HELM_KUBEASGROUPS"),
|
||||
KubeAPIServer: os.Getenv("HELM_KUBEAPISERVER"),
|
||||
KubeCaFile: os.Getenv("HELM_KUBECAFILE"),
|
||||
KubeTLSServerName: os.Getenv("HELM_KUBETLS_SERVER_NAME"),
|
||||
KubeInsecureSkipTLSVerify: envBoolOr("HELM_KUBEINSECURE_SKIP_TLS_VERIFY", false),
|
||||
PluginsDirectory: envOr("HELM_PLUGINS", helmpath.DataPath("plugins")),
|
||||
RegistryConfig: envOr("HELM_REGISTRY_CONFIG", helmpath.ConfigPath("registry/config.json")),
|
||||
RepositoryConfig: envOr("HELM_REPOSITORY_CONFIG", helmpath.ConfigPath("repositories.yaml")),
|
||||
RepositoryCache: envOr("HELM_REPOSITORY_CACHE", helmpath.CachePath("repository")),
|
||||
ContentCache: envOr("HELM_CONTENT_CACHE", helmpath.CachePath("content")),
|
||||
BurstLimit: envIntOr("HELM_BURST_LIMIT", defaultBurstLimit),
|
||||
QPS: envFloat32Or("HELM_QPS", defaultQPS),
|
||||
ColorMode: envColorMode(),
|
||||
}
|
||||
env.Debug, _ = strconv.ParseBool(os.Getenv("HELM_DEBUG"))
|
||||
|
||||
// bind to kubernetes config flags
|
||||
config := &genericclioptions.ConfigFlags{
|
||||
Namespace: &env.namespace,
|
||||
Context: &env.KubeContext,
|
||||
BearerToken: &env.KubeToken,
|
||||
APIServer: &env.KubeAPIServer,
|
||||
CAFile: &env.KubeCaFile,
|
||||
KubeConfig: &env.KubeConfig,
|
||||
Impersonate: &env.KubeAsUser,
|
||||
Insecure: &env.KubeInsecureSkipTLSVerify,
|
||||
TLSServerName: &env.KubeTLSServerName,
|
||||
ImpersonateGroup: &env.KubeAsGroups,
|
||||
WrapConfigFn: func(config *rest.Config) *rest.Config {
|
||||
config.Burst = env.BurstLimit
|
||||
config.QPS = env.QPS
|
||||
config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
|
||||
return &kube.RetryingRoundTripper{Wrapped: rt}
|
||||
})
|
||||
config.UserAgent = version.GetUserAgent()
|
||||
return config
|
||||
},
|
||||
}
|
||||
if env.BurstLimit != defaultBurstLimit {
|
||||
config = config.WithDiscoveryBurst(env.BurstLimit)
|
||||
}
|
||||
env.config = config
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
// AddFlags binds flags to the given flagset.
|
||||
func (s *EnvSettings) AddFlags(fs *pflag.FlagSet) {
|
||||
fs.StringVarP(&s.namespace, "namespace", "n", s.namespace, "namespace scope for this request")
|
||||
fs.StringVar(&s.KubeConfig, "kubeconfig", "", "path to the kubeconfig file")
|
||||
fs.StringVar(&s.KubeContext, "kube-context", s.KubeContext, "name of the kubeconfig context to use")
|
||||
fs.StringVar(&s.KubeToken, "kube-token", s.KubeToken, "bearer token used for authentication")
|
||||
fs.StringVar(&s.KubeAsUser, "kube-as-user", s.KubeAsUser, "username to impersonate for the operation")
|
||||
fs.StringArrayVar(&s.KubeAsGroups, "kube-as-group", s.KubeAsGroups, "group to impersonate for the operation, this flag can be repeated to specify multiple groups.")
|
||||
fs.StringVar(&s.KubeAPIServer, "kube-apiserver", s.KubeAPIServer, "the address and the port for the Kubernetes API server")
|
||||
fs.StringVar(&s.KubeCaFile, "kube-ca-file", s.KubeCaFile, "the certificate authority file for the Kubernetes API server connection")
|
||||
fs.StringVar(&s.KubeTLSServerName, "kube-tls-server-name", s.KubeTLSServerName, "server name to use for Kubernetes API server certificate validation. If it is not provided, the hostname used to contact the server is used")
|
||||
fs.BoolVar(&s.KubeInsecureSkipTLSVerify, "kube-insecure-skip-tls-verify", s.KubeInsecureSkipTLSVerify, "if true, the Kubernetes API server's certificate will not be checked for validity. This will make your HTTPS connections insecure")
|
||||
fs.BoolVar(&s.Debug, "debug", s.Debug, "enable verbose output")
|
||||
fs.StringVar(&s.RegistryConfig, "registry-config", s.RegistryConfig, "path to the registry config file")
|
||||
fs.StringVar(&s.RepositoryConfig, "repository-config", s.RepositoryConfig, "path to the file containing repository names and URLs")
|
||||
fs.StringVar(&s.RepositoryCache, "repository-cache", s.RepositoryCache, "path to the directory containing cached repository indexes")
|
||||
fs.StringVar(&s.ContentCache, "content-cache", s.ContentCache, "path to the directory containing cached content (e.g. charts)")
|
||||
fs.IntVar(&s.BurstLimit, "burst-limit", s.BurstLimit, "client-side default throttling limit")
|
||||
fs.Float32Var(&s.QPS, "qps", s.QPS, "queries per second used when communicating with the Kubernetes API, not including bursting")
|
||||
fs.StringVar(&s.ColorMode, "color", s.ColorMode, "use colored output (never, auto, always)")
|
||||
fs.StringVar(&s.ColorMode, "colour", s.ColorMode, "use colored output (never, auto, always)")
|
||||
}
|
||||
|
||||
func envOr(name, def string) string {
|
||||
if v, ok := os.LookupEnv(name); ok {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envBoolOr(name string, def bool) bool {
|
||||
if name == "" {
|
||||
return def
|
||||
}
|
||||
envVal := envOr(name, strconv.FormatBool(def))
|
||||
ret, err := strconv.ParseBool(envVal)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func envIntOr(name string, def int) int {
|
||||
if name == "" {
|
||||
return def
|
||||
}
|
||||
envVal := envOr(name, strconv.Itoa(def))
|
||||
ret, err := strconv.Atoi(envVal)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func envFloat32Or(name string, def float32) float32 {
|
||||
if name == "" {
|
||||
return def
|
||||
}
|
||||
envVal := envOr(name, strconv.FormatFloat(float64(def), 'f', 2, 32))
|
||||
ret, err := strconv.ParseFloat(envVal, 32)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return float32(ret)
|
||||
}
|
||||
|
||||
func envCSV(name string) (ls []string) {
|
||||
trimmed := strings.Trim(os.Getenv(name), ", ")
|
||||
if trimmed != "" {
|
||||
ls = strings.Split(trimmed, ",")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func envColorMode() string {
|
||||
// Check NO_COLOR environment variable first (standard)
|
||||
if v, ok := os.LookupEnv("NO_COLOR"); ok && v != "" {
|
||||
return "never"
|
||||
}
|
||||
// Check HELM_COLOR environment variable
|
||||
if v, ok := os.LookupEnv("HELM_COLOR"); ok {
|
||||
v = strings.ToLower(v)
|
||||
switch v {
|
||||
case "never", "auto", "always":
|
||||
return v
|
||||
}
|
||||
}
|
||||
// Default to auto
|
||||
return "auto"
|
||||
}
|
||||
|
||||
func (s *EnvSettings) EnvVars() map[string]string {
|
||||
envvars := map[string]string{
|
||||
"HELM_BIN": os.Args[0],
|
||||
"HELM_CACHE_HOME": helmpath.CachePath(""),
|
||||
"HELM_CONFIG_HOME": helmpath.ConfigPath(""),
|
||||
"HELM_DATA_HOME": helmpath.DataPath(""),
|
||||
"HELM_DEBUG": fmt.Sprint(s.Debug),
|
||||
"HELM_PLUGINS": s.PluginsDirectory,
|
||||
"HELM_REGISTRY_CONFIG": s.RegistryConfig,
|
||||
"HELM_REPOSITORY_CACHE": s.RepositoryCache,
|
||||
"HELM_CONTENT_CACHE": s.ContentCache,
|
||||
"HELM_REPOSITORY_CONFIG": s.RepositoryConfig,
|
||||
"HELM_NAMESPACE": s.Namespace(),
|
||||
"HELM_MAX_HISTORY": strconv.Itoa(s.MaxHistory),
|
||||
"HELM_BURST_LIMIT": strconv.Itoa(s.BurstLimit),
|
||||
"HELM_QPS": strconv.FormatFloat(float64(s.QPS), 'f', 2, 32),
|
||||
|
||||
// broken, these are populated from helm flags and not kubeconfig.
|
||||
"HELM_KUBECONTEXT": s.KubeContext,
|
||||
"HELM_KUBETOKEN": s.KubeToken,
|
||||
"HELM_KUBEASUSER": s.KubeAsUser,
|
||||
"HELM_KUBEASGROUPS": strings.Join(s.KubeAsGroups, ","),
|
||||
"HELM_KUBEAPISERVER": s.KubeAPIServer,
|
||||
"HELM_KUBECAFILE": s.KubeCaFile,
|
||||
"HELM_KUBEINSECURE_SKIP_TLS_VERIFY": strconv.FormatBool(s.KubeInsecureSkipTLSVerify),
|
||||
"HELM_KUBETLS_SERVER_NAME": s.KubeTLSServerName,
|
||||
}
|
||||
if s.KubeConfig != "" {
|
||||
envvars["KUBECONFIG"] = s.KubeConfig
|
||||
}
|
||||
return envvars
|
||||
}
|
||||
|
||||
// Namespace gets the namespace from the configuration
|
||||
func (s *EnvSettings) Namespace() string {
|
||||
if s.config != nil {
|
||||
if ns, _, err := s.config.ToRawKubeConfigLoader().Namespace(); err == nil {
|
||||
return ns
|
||||
}
|
||||
}
|
||||
if s.namespace != "" {
|
||||
return s.namespace
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
// SetNamespace sets the namespace in the configuration
|
||||
func (s *EnvSettings) SetNamespace(namespace string) {
|
||||
s.namespace = namespace
|
||||
}
|
||||
|
||||
// RESTClientGetter gets the kubeconfig from EnvSettings
|
||||
func (s *EnvSettings) RESTClientGetter() genericclioptions.RESTClientGetter {
|
||||
return s.config
|
||||
}
|
||||
|
||||
// ShouldDisableColor returns true if color output should be disabled
|
||||
func (s *EnvSettings) ShouldDisableColor() bool {
|
||||
return s.ColorMode == "never"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/*
|
||||
Package getter provides a generalize tool for fetching data by scheme.
|
||||
|
||||
This provides a method by which the plugin system can load arbitrary protocol
|
||||
handlers based upon a URL scheme.
|
||||
*/
|
||||
package getter
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package getter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"helm.sh/helm/v4/pkg/cli"
|
||||
"helm.sh/helm/v4/pkg/registry"
|
||||
)
|
||||
|
||||
// getterOptions are generic parameters to be provided to the getter during instantiation.
|
||||
//
|
||||
// Getters may or may not ignore these parameters as they are passed in.
|
||||
// TODO what is the difference between this and schema.GetterOptionsV1?
|
||||
type getterOptions struct {
|
||||
url string
|
||||
certFile string
|
||||
keyFile string
|
||||
caFile string
|
||||
unTar bool
|
||||
insecureSkipVerifyTLS bool
|
||||
plainHTTP bool
|
||||
acceptHeader string
|
||||
username string
|
||||
password string
|
||||
passCredentialsAll bool
|
||||
userAgent string
|
||||
version string
|
||||
registryClient *registry.Client
|
||||
timeout time.Duration
|
||||
transport *http.Transport
|
||||
artifactType string
|
||||
}
|
||||
|
||||
// Option allows specifying various settings configurable by the user for overriding the defaults
|
||||
// used when performing Get operations with the Getter.
|
||||
type Option func(*getterOptions)
|
||||
|
||||
// WithURL informs the getter the server name that will be used when fetching objects. Used in conjunction with
|
||||
// WithTLSClientConfig to set the TLSClientConfig's server name.
|
||||
func WithURL(url string) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.url = url
|
||||
}
|
||||
}
|
||||
|
||||
// WithAcceptHeader sets the request's Accept header as some REST APIs serve multiple content types
|
||||
func WithAcceptHeader(header string) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.acceptHeader = header
|
||||
}
|
||||
}
|
||||
|
||||
// WithBasicAuth sets the request's Authorization header to use the provided credentials
|
||||
func WithBasicAuth(username, password string) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.username = username
|
||||
opts.password = password
|
||||
}
|
||||
}
|
||||
|
||||
func WithPassCredentialsAll(pass bool) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.passCredentialsAll = pass
|
||||
}
|
||||
}
|
||||
|
||||
// WithUserAgent sets the request's User-Agent header to use the provided agent name.
|
||||
func WithUserAgent(userAgent string) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.userAgent = userAgent
|
||||
}
|
||||
}
|
||||
|
||||
// WithInsecureSkipVerifyTLS determines if a TLS Certificate will be checked
|
||||
func WithInsecureSkipVerifyTLS(insecureSkipVerifyTLS bool) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.insecureSkipVerifyTLS = insecureSkipVerifyTLS
|
||||
}
|
||||
}
|
||||
|
||||
// WithTLSClientConfig sets the client auth with the provided credentials.
|
||||
func WithTLSClientConfig(certFile, keyFile, caFile string) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.certFile = certFile
|
||||
opts.keyFile = keyFile
|
||||
opts.caFile = caFile
|
||||
}
|
||||
}
|
||||
|
||||
func WithPlainHTTP(plainHTTP bool) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.plainHTTP = plainHTTP
|
||||
}
|
||||
}
|
||||
|
||||
// WithTimeout sets the timeout for requests
|
||||
func WithTimeout(timeout time.Duration) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.timeout = timeout
|
||||
}
|
||||
}
|
||||
|
||||
func WithTagName(tagname string) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.version = tagname
|
||||
}
|
||||
}
|
||||
|
||||
func WithRegistryClient(client *registry.Client) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.registryClient = client
|
||||
}
|
||||
}
|
||||
|
||||
func WithUntar() Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.unTar = true
|
||||
}
|
||||
}
|
||||
|
||||
// WithTransport sets the http.Transport to allow overwriting the HTTPGetter default.
|
||||
func WithTransport(transport *http.Transport) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.transport = transport
|
||||
}
|
||||
}
|
||||
|
||||
// WithArtifactType sets the type of OCI artifact ("chart" or "plugin")
|
||||
func WithArtifactType(artifactType string) Option {
|
||||
return func(opts *getterOptions) {
|
||||
opts.artifactType = artifactType
|
||||
}
|
||||
}
|
||||
|
||||
// Getter is an interface to support GET to the specified URL.
|
||||
type Getter interface {
|
||||
// Get file content by url string
|
||||
Get(url string, options ...Option) (*bytes.Buffer, error)
|
||||
}
|
||||
|
||||
// Constructor is the function for every getter which creates a specific instance
|
||||
// according to the configuration
|
||||
type Constructor func(options ...Option) (Getter, error)
|
||||
|
||||
// Provider represents any getter and the schemes that it supports.
|
||||
//
|
||||
// For example, an HTTP provider may provide one getter that handles both
|
||||
// 'http' and 'https' schemes.
|
||||
type Provider struct {
|
||||
Schemes []string
|
||||
New Constructor
|
||||
}
|
||||
|
||||
// Provides returns true if the given scheme is supported by this Provider.
|
||||
func (p Provider) Provides(scheme string) bool {
|
||||
return slices.Contains(p.Schemes, scheme)
|
||||
}
|
||||
|
||||
// Providers is a collection of Provider objects.
|
||||
type Providers []Provider
|
||||
|
||||
// ByScheme returns a Provider that handles the given scheme.
|
||||
//
|
||||
// If no provider handles this scheme, this will return an error.
|
||||
func (p Providers) ByScheme(scheme string) (Getter, error) {
|
||||
for _, pp := range p {
|
||||
if pp.Provides(scheme) {
|
||||
return pp.New()
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("scheme %q not supported", scheme)
|
||||
}
|
||||
|
||||
const (
|
||||
// The cost timeout references curl's default connection timeout.
|
||||
// https://github.com/curl/curl/blob/master/lib/connect.h#L40C21-L40C21
|
||||
// The helm commands are usually executed manually. Considering the acceptable waiting time, we reduced the entire request time to 120s.
|
||||
DefaultHTTPTimeout = 120
|
||||
)
|
||||
|
||||
var defaultOptions = []Option{WithTimeout(time.Second * DefaultHTTPTimeout)}
|
||||
|
||||
func Getters(extraOpts ...Option) Providers {
|
||||
return Providers{
|
||||
Provider{
|
||||
Schemes: []string{"http", "https"},
|
||||
New: func(options ...Option) (Getter, error) {
|
||||
options = append(options, defaultOptions...)
|
||||
options = append(options, extraOpts...)
|
||||
return NewHTTPGetter(options...)
|
||||
},
|
||||
},
|
||||
Provider{
|
||||
Schemes: []string{registry.OCIScheme},
|
||||
New: func(options ...Option) (Getter, error) {
|
||||
options = append(options, defaultOptions...)
|
||||
options = append(options, extraOpts...)
|
||||
return NewOCIGetter(options...)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// All finds all of the registered getters as a list of Provider instances.
|
||||
// Currently, the built-in getters and the discovered plugins with downloader
|
||||
// notations are collected.
|
||||
func All(settings *cli.EnvSettings, opts ...Option) Providers {
|
||||
result := Getters(opts...)
|
||||
pluginDownloaders, _ := collectGetterPlugins(settings)
|
||||
result = append(result, pluginDownloaders...)
|
||||
return result
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package getter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
|
||||
"helm.sh/helm/v4/internal/tlsutil"
|
||||
"helm.sh/helm/v4/internal/version"
|
||||
)
|
||||
|
||||
// HTTPGetter is the default HTTP(/S) backend handler
|
||||
type HTTPGetter struct {
|
||||
opts getterOptions
|
||||
transport *http.Transport
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// Get performs a Get from repo.Getter and returns the body.
|
||||
func (g *HTTPGetter) Get(href string, options ...Option) (*bytes.Buffer, error) {
|
||||
for _, opt := range options {
|
||||
opt(&g.opts)
|
||||
}
|
||||
return g.get(href)
|
||||
}
|
||||
|
||||
func (g *HTTPGetter) get(href string) (*bytes.Buffer, error) {
|
||||
// Set a helm specific user agent so that a repo server and metrics can
|
||||
// separate helm calls from other tools interacting with repos.
|
||||
req, err := http.NewRequest(http.MethodGet, href, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if g.opts.acceptHeader != "" {
|
||||
req.Header.Set("Accept", g.opts.acceptHeader)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", version.GetUserAgent())
|
||||
if g.opts.userAgent != "" {
|
||||
req.Header.Set("User-Agent", g.opts.userAgent)
|
||||
}
|
||||
|
||||
// Before setting the basic auth credentials, make sure the URL associated
|
||||
// with the basic auth is the one being fetched.
|
||||
u1, err := url.Parse(g.opts.url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse getter URL: %w", err)
|
||||
}
|
||||
u2, err := url.Parse(href)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse URL getting from: %w", err)
|
||||
}
|
||||
|
||||
// Host on URL (returned from url.Parse) contains the port if present.
|
||||
// This check ensures credentials are not passed between different
|
||||
// services on different ports.
|
||||
if g.opts.passCredentialsAll || (u1.Scheme == u2.Scheme && u1.Host == u2.Host) {
|
||||
if g.opts.username != "" && g.opts.password != "" {
|
||||
req.SetBasicAuth(g.opts.username, g.opts.password)
|
||||
}
|
||||
}
|
||||
|
||||
client, err := g.httpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to fetch %s : %s", href, resp.Status)
|
||||
}
|
||||
|
||||
buf := bytes.NewBuffer(nil)
|
||||
_, err = io.Copy(buf, resp.Body)
|
||||
return buf, err
|
||||
}
|
||||
|
||||
// NewHTTPGetter constructs a valid http/https client as a Getter
|
||||
func NewHTTPGetter(options ...Option) (Getter, error) {
|
||||
var client HTTPGetter
|
||||
|
||||
for _, opt := range options {
|
||||
opt(&client.opts)
|
||||
}
|
||||
|
||||
return &client, nil
|
||||
}
|
||||
|
||||
func (g *HTTPGetter) httpClient() (*http.Client, error) {
|
||||
if g.opts.transport != nil {
|
||||
return &http.Client{
|
||||
Transport: g.opts.transport,
|
||||
Timeout: g.opts.timeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
g.once.Do(func() {
|
||||
g.transport = &http.Transport{
|
||||
DisableCompression: true,
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
// Being nil would cause the tls.Config default to be used
|
||||
// "NewTLSConfig" modifies an empty TLS config, not the default one
|
||||
TLSClientConfig: &tls.Config{},
|
||||
}
|
||||
})
|
||||
|
||||
if (g.opts.certFile != "" && g.opts.keyFile != "") || g.opts.caFile != "" || g.opts.insecureSkipVerifyTLS {
|
||||
tlsConf, err := tlsutil.NewTLSConfig(
|
||||
tlsutil.WithInsecureSkipVerify(g.opts.insecureSkipVerifyTLS),
|
||||
tlsutil.WithCertKeyPairFiles(g.opts.certFile, g.opts.keyFile),
|
||||
tlsutil.WithCAFile(g.opts.caFile),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't create TLS config for client: %w", err)
|
||||
}
|
||||
|
||||
g.transport.TLSClientConfig = tlsConf
|
||||
}
|
||||
|
||||
if g.opts.insecureSkipVerifyTLS {
|
||||
if g.transport.TLSClientConfig == nil {
|
||||
g.transport.TLSClientConfig = &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
} else {
|
||||
g.transport.TLSClientConfig.InsecureSkipVerify = true
|
||||
}
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: g.transport,
|
||||
Timeout: g.opts.timeout,
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package getter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"helm.sh/helm/v4/internal/tlsutil"
|
||||
"helm.sh/helm/v4/internal/urlutil"
|
||||
"helm.sh/helm/v4/pkg/registry"
|
||||
)
|
||||
|
||||
// OCIGetter is the default HTTP(/S) backend handler
|
||||
type OCIGetter struct {
|
||||
opts getterOptions
|
||||
transport *http.Transport
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// Get performs a Get from repo.Getter and returns the body.
|
||||
func (g *OCIGetter) Get(href string, options ...Option) (*bytes.Buffer, error) {
|
||||
for _, opt := range options {
|
||||
opt(&g.opts)
|
||||
}
|
||||
return g.get(href)
|
||||
}
|
||||
|
||||
func (g *OCIGetter) get(href string) (*bytes.Buffer, error) {
|
||||
client := g.opts.registryClient
|
||||
// if the user has already provided a configured registry client, use it,
|
||||
// this is particularly true when user has his own way of handling the client credentials.
|
||||
if client == nil {
|
||||
c, err := g.newRegistryClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client = c
|
||||
}
|
||||
|
||||
ref := strings.TrimPrefix(href, fmt.Sprintf("%s://", registry.OCIScheme))
|
||||
|
||||
if version := g.opts.version; version != "" && !strings.Contains(path.Base(ref), ":") {
|
||||
ref = fmt.Sprintf("%s:%s", ref, version)
|
||||
}
|
||||
// Check if this is a plugin request
|
||||
if g.opts.artifactType == "plugin" {
|
||||
return g.getPlugin(client, ref)
|
||||
}
|
||||
|
||||
// Default to chart behavior for backward compatibility
|
||||
var pullOpts []registry.PullOption
|
||||
requestingProv := strings.HasSuffix(ref, ".prov")
|
||||
if requestingProv {
|
||||
ref = strings.TrimSuffix(ref, ".prov")
|
||||
pullOpts = append(pullOpts,
|
||||
registry.PullOptWithChart(false),
|
||||
registry.PullOptWithProv(true))
|
||||
}
|
||||
|
||||
result, err := client.Pull(ref, pullOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if requestingProv {
|
||||
return bytes.NewBuffer(result.Prov.Data), nil
|
||||
}
|
||||
return bytes.NewBuffer(result.Chart.Data), nil
|
||||
}
|
||||
|
||||
// NewOCIGetter constructs a valid http/https client as a Getter
|
||||
func NewOCIGetter(ops ...Option) (Getter, error) {
|
||||
var client OCIGetter
|
||||
|
||||
for _, opt := range ops {
|
||||
opt(&client.opts)
|
||||
}
|
||||
|
||||
return &client, nil
|
||||
}
|
||||
|
||||
func (g *OCIGetter) newRegistryClient() (*registry.Client, error) {
|
||||
if g.opts.transport != nil {
|
||||
client, err := registry.NewClient(
|
||||
registry.ClientOptHTTPClient(&http.Client{
|
||||
Transport: g.opts.transport,
|
||||
Timeout: g.opts.timeout,
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
g.once.Do(func() {
|
||||
g.transport = &http.Transport{
|
||||
// From https://github.com/google/go-containerregistry/blob/31786c6cbb82d6ec4fb8eb79cd9387905130534e/pkg/v1/remote/options.go#L87
|
||||
DisableCompression: true,
|
||||
DialContext: (&net.Dialer{
|
||||
// By default we wrap the transport in retries, so reduce the
|
||||
// default dial timeout to 5s to avoid 5x 30s of connection
|
||||
// timeouts when doing the "ping" on certain http registries.
|
||||
Timeout: 5 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
// Being nil would cause the tls.Config default to be used
|
||||
// "NewTLSConfig" modifies an empty TLS config, not the default one
|
||||
TLSClientConfig: &tls.Config{},
|
||||
}
|
||||
})
|
||||
|
||||
if (g.opts.certFile != "" && g.opts.keyFile != "") || g.opts.caFile != "" || g.opts.insecureSkipVerifyTLS {
|
||||
tlsConf, err := tlsutil.NewTLSConfig(
|
||||
tlsutil.WithInsecureSkipVerify(g.opts.insecureSkipVerifyTLS),
|
||||
tlsutil.WithCertKeyPairFiles(g.opts.certFile, g.opts.keyFile),
|
||||
tlsutil.WithCAFile(g.opts.caFile),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't create TLS config for client: %w", err)
|
||||
}
|
||||
|
||||
sni, err := urlutil.ExtractHostname(g.opts.url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsConf.ServerName = sni
|
||||
|
||||
g.transport.TLSClientConfig = tlsConf
|
||||
}
|
||||
|
||||
opts := []registry.ClientOption{registry.ClientOptHTTPClient(&http.Client{
|
||||
Transport: g.transport,
|
||||
Timeout: g.opts.timeout,
|
||||
})}
|
||||
if g.opts.plainHTTP {
|
||||
opts = append(opts, registry.ClientOptPlainHTTP())
|
||||
}
|
||||
|
||||
client, err := registry.NewClient(opts...)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// getPlugin handles plugin-specific OCI pulls
|
||||
func (g *OCIGetter) getPlugin(client *registry.Client, ref string) (*bytes.Buffer, error) {
|
||||
// Check if this is a provenance file request
|
||||
requestingProv := strings.HasSuffix(ref, ".prov")
|
||||
if requestingProv {
|
||||
ref = strings.TrimSuffix(ref, ".prov")
|
||||
}
|
||||
|
||||
// Extract plugin name from the reference
|
||||
// e.g., "ghcr.io/user/plugin-name:v1.0.0" -> "plugin-name"
|
||||
parts := strings.Split(ref, "/")
|
||||
if len(parts) < 2 {
|
||||
return nil, fmt.Errorf("invalid OCI reference: %s", ref)
|
||||
}
|
||||
lastPart := parts[len(parts)-1]
|
||||
pluginName := lastPart
|
||||
if idx := strings.LastIndex(lastPart, ":"); idx > 0 {
|
||||
pluginName = lastPart[:idx]
|
||||
}
|
||||
if idx := strings.LastIndex(lastPart, "@"); idx > 0 {
|
||||
pluginName = lastPart[:idx]
|
||||
}
|
||||
|
||||
var pullOpts []registry.PluginPullOption
|
||||
if requestingProv {
|
||||
pullOpts = append(pullOpts, registry.PullPluginOptWithProv(true))
|
||||
}
|
||||
|
||||
result, err := client.PullPlugin(ref, pluginName, pullOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if requestingProv {
|
||||
return bytes.NewBuffer(result.Prov.Data), nil
|
||||
}
|
||||
return bytes.NewBuffer(result.PluginData), nil
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package getter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"net/url"
|
||||
|
||||
"helm.sh/helm/v4/internal/plugin"
|
||||
|
||||
"helm.sh/helm/v4/internal/plugin/schema"
|
||||
"helm.sh/helm/v4/pkg/cli"
|
||||
)
|
||||
|
||||
// collectGetterPlugins scans for getter plugins.
|
||||
// This will load plugins according to the cli.
|
||||
func collectGetterPlugins(settings *cli.EnvSettings) (Providers, error) {
|
||||
d := plugin.Descriptor{
|
||||
Type: "getter/v1",
|
||||
}
|
||||
plgs, err := plugin.FindPlugins([]string{settings.PluginsDirectory}, d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env := plugin.FormatEnv(settings.EnvVars())
|
||||
pluginConstructorBuilder := func(plg plugin.Plugin) Constructor {
|
||||
return func(option ...Option) (Getter, error) {
|
||||
|
||||
return &getterPlugin{
|
||||
options: append([]Option{}, option...),
|
||||
plg: plg,
|
||||
env: env,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
results := make([]Provider, 0, len(plgs))
|
||||
for _, plg := range plgs {
|
||||
if c, ok := plg.Metadata().Config.(*schema.ConfigGetterV1); ok {
|
||||
results = append(results, Provider{
|
||||
Schemes: c.Protocols,
|
||||
New: pluginConstructorBuilder(plg),
|
||||
})
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func convertOptions(globalOptions, options []Option) schema.GetterOptionsV1 {
|
||||
opts := getterOptions{}
|
||||
for _, opt := range globalOptions {
|
||||
opt(&opts)
|
||||
}
|
||||
for _, opt := range options {
|
||||
opt(&opts)
|
||||
}
|
||||
|
||||
result := schema.GetterOptionsV1{
|
||||
URL: opts.url,
|
||||
CertFile: opts.certFile,
|
||||
KeyFile: opts.keyFile,
|
||||
CAFile: opts.caFile,
|
||||
UNTar: opts.unTar,
|
||||
InsecureSkipVerifyTLS: opts.insecureSkipVerifyTLS,
|
||||
PlainHTTP: opts.plainHTTP,
|
||||
AcceptHeader: opts.acceptHeader,
|
||||
Username: opts.username,
|
||||
Password: opts.password,
|
||||
PassCredentialsAll: opts.passCredentialsAll,
|
||||
UserAgent: opts.userAgent,
|
||||
Version: opts.version,
|
||||
Timeout: opts.timeout,
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type getterPlugin struct {
|
||||
options []Option
|
||||
plg plugin.Plugin
|
||||
env []string
|
||||
}
|
||||
|
||||
func (g *getterPlugin) Get(href string, options ...Option) (*bytes.Buffer, error) {
|
||||
opts := convertOptions(g.options, options)
|
||||
|
||||
// TODO optimization: pass this along to Get() instead of re-parsing here
|
||||
u, err := url.Parse(href)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
input := &plugin.Input{
|
||||
Message: schema.InputMessageGetterV1{
|
||||
Href: href,
|
||||
Options: opts,
|
||||
Protocol: u.Scheme,
|
||||
},
|
||||
Env: g.env,
|
||||
// TODO should we pass Stdin, Stdout, and Stderr through Input here to getter plugins?
|
||||
// Stdout: os.Stdout,
|
||||
}
|
||||
output, err := g.plg.Invoke(context.Background(), input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugin %q failed to invoke: %w", g.plg, err)
|
||||
}
|
||||
|
||||
outputMessage, ok := output.Message.(schema.OutputMessageGetterV1)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid output message type from plugin %q", g.plg.Metadata().Name)
|
||||
}
|
||||
|
||||
return bytes.NewBuffer(outputMessage.Data), nil
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
// Copyright The Helm Authors.
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package helmpath calculates filesystem paths to Helm's configuration, cache and data.
|
||||
package helmpath
|
||||
|
||||
// This helper builds paths to Helm's configuration, cache and data paths.
|
||||
const lp = lazypath("helm")
|
||||
|
||||
// ConfigPath returns the path where Helm stores configuration.
|
||||
func ConfigPath(elem ...string) string { return lp.configPath(elem...) }
|
||||
|
||||
// CachePath returns the path where Helm stores cached objects.
|
||||
func CachePath(elem ...string) string { return lp.cachePath(elem...) }
|
||||
|
||||
// DataPath returns the path where Helm stores data.
|
||||
func DataPath(elem ...string) string { return lp.dataPath(elem...) }
|
||||
|
||||
// CacheIndexFile returns the path to an index for the given named repository.
|
||||
func CacheIndexFile(name string) string {
|
||||
if name != "" {
|
||||
name += "-"
|
||||
}
|
||||
return name + "index.yaml"
|
||||
}
|
||||
|
||||
// CacheChartsFile returns the path to a text file listing all the charts
|
||||
// within the given named repository.
|
||||
func CacheChartsFile(name string) string {
|
||||
if name != "" {
|
||||
name += "-"
|
||||
}
|
||||
return name + "charts.txt"
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
// Copyright The Helm Authors.
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helmpath
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"helm.sh/helm/v4/pkg/helmpath/xdg"
|
||||
)
|
||||
|
||||
const (
|
||||
// CacheHomeEnvVar is the environment variable used by Helm
|
||||
// for the cache directory. When no value is set a default is used.
|
||||
CacheHomeEnvVar = "HELM_CACHE_HOME"
|
||||
|
||||
// ConfigHomeEnvVar is the environment variable used by Helm
|
||||
// for the config directory. When no value is set a default is used.
|
||||
ConfigHomeEnvVar = "HELM_CONFIG_HOME"
|
||||
|
||||
// DataHomeEnvVar is the environment variable used by Helm
|
||||
// for the data directory. When no value is set a default is used.
|
||||
DataHomeEnvVar = "HELM_DATA_HOME"
|
||||
)
|
||||
|
||||
// lazypath is a lazy-loaded path buffer for the XDG base directory specification.
|
||||
type lazypath string
|
||||
|
||||
func (l lazypath) path(helmEnvVar, xdgEnvVar string, defaultFn func() string, elem ...string) string {
|
||||
|
||||
// There is an order to checking for a path.
|
||||
// 1. See if a Helm specific environment variable has been set.
|
||||
// 2. Check if an XDG environment variable is set
|
||||
// 3. Fall back to a default
|
||||
base := os.Getenv(helmEnvVar)
|
||||
if base != "" {
|
||||
return filepath.Join(base, filepath.Join(elem...))
|
||||
}
|
||||
base = os.Getenv(xdgEnvVar)
|
||||
if base == "" {
|
||||
base = defaultFn()
|
||||
}
|
||||
return filepath.Join(base, string(l), filepath.Join(elem...))
|
||||
}
|
||||
|
||||
// cachePath defines the base directory relative to which user specific non-essential data files
|
||||
// should be stored.
|
||||
func (l lazypath) cachePath(elem ...string) string {
|
||||
return l.path(CacheHomeEnvVar, xdg.CacheHomeEnvVar, cacheHome, filepath.Join(elem...))
|
||||
}
|
||||
|
||||
// configPath defines the base directory relative to which user specific configuration files should
|
||||
// be stored.
|
||||
func (l lazypath) configPath(elem ...string) string {
|
||||
return l.path(ConfigHomeEnvVar, xdg.ConfigHomeEnvVar, configHome, filepath.Join(elem...))
|
||||
}
|
||||
|
||||
// dataPath defines the base directory relative to which user specific data files should be stored.
|
||||
func (l lazypath) dataPath(elem ...string) string {
|
||||
return l.path(DataHomeEnvVar, xdg.DataHomeEnvVar, dataHome, filepath.Join(elem...))
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright The Helm Authors.
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build darwin
|
||||
|
||||
package helmpath
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"k8s.io/client-go/util/homedir"
|
||||
)
|
||||
|
||||
func dataHome() string {
|
||||
return filepath.Join(homedir.HomeDir(), "Library")
|
||||
}
|
||||
|
||||
func configHome() string {
|
||||
return filepath.Join(homedir.HomeDir(), "Library", "Preferences")
|
||||
}
|
||||
|
||||
func cacheHome() string {
|
||||
return filepath.Join(homedir.HomeDir(), "Library", "Caches")
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright The Helm Authors.
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build !windows && !darwin
|
||||
|
||||
package helmpath
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"k8s.io/client-go/util/homedir"
|
||||
)
|
||||
|
||||
// dataHome defines the base directory relative to which user specific data files should be stored.
|
||||
//
|
||||
// If $XDG_DATA_HOME is either not set or empty, a default equal to $HOME/.local/share is used.
|
||||
func dataHome() string {
|
||||
return filepath.Join(homedir.HomeDir(), ".local", "share")
|
||||
}
|
||||
|
||||
// configHome defines the base directory relative to which user specific configuration files should
|
||||
// be stored.
|
||||
//
|
||||
// If $XDG_CONFIG_HOME is either not set or empty, a default equal to $HOME/.config is used.
|
||||
func configHome() string {
|
||||
return filepath.Join(homedir.HomeDir(), ".config")
|
||||
}
|
||||
|
||||
// cacheHome defines the base directory relative to which user specific non-essential data files
|
||||
// should be stored.
|
||||
//
|
||||
// If $XDG_CACHE_HOME is either not set or empty, a default equal to $HOME/.cache is used.
|
||||
func cacheHome() string {
|
||||
return filepath.Join(homedir.HomeDir(), ".cache")
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright The Helm Authors.
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build windows
|
||||
|
||||
package helmpath
|
||||
|
||||
import "os"
|
||||
|
||||
func dataHome() string { return configHome() }
|
||||
|
||||
func configHome() string { return os.Getenv("APPDATA") }
|
||||
|
||||
func cacheHome() string { return os.Getenv("TEMP") }
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// Package xdg holds constants pertaining to XDG Base Directory Specification.
|
||||
//
|
||||
// The XDG Base Directory Specification https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
|
||||
// specifies the environment variables that define user-specific base directories for various categories of files.
|
||||
package xdg
|
||||
|
||||
const (
|
||||
// CacheHomeEnvVar is the environment variable used by the
|
||||
// XDG base directory specification for the cache directory.
|
||||
CacheHomeEnvVar = "XDG_CACHE_HOME"
|
||||
|
||||
// ConfigHomeEnvVar is the environment variable used by the
|
||||
// XDG base directory specification for the config directory.
|
||||
ConfigHomeEnvVar = "XDG_CONFIG_HOME"
|
||||
|
||||
// DataHomeEnvVar is the environment variable used by the
|
||||
// XDG base directory specification for the data directory.
|
||||
DataHomeEnvVar = "XDG_DATA_HOME"
|
||||
)
|
||||
+1297
File diff suppressed because it is too large
Load Diff
+69
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package kube // import "helm.sh/helm/v4/pkg/kube"
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
||||
apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/cli-runtime/pkg/resource"
|
||||
"k8s.io/client-go/kubernetes/scheme"
|
||||
)
|
||||
|
||||
var k8sNativeScheme *runtime.Scheme
|
||||
var k8sNativeSchemeOnce sync.Once
|
||||
|
||||
// AsVersioned converts the given info into a runtime.Object with the correct
|
||||
// group and version set
|
||||
func AsVersioned(info *resource.Info) runtime.Object {
|
||||
return convertWithMapper(info.Object, info.Mapping)
|
||||
}
|
||||
|
||||
// convertWithMapper converts the given object with the optional provided
|
||||
// RESTMapping. If no mapping is provided, the default schema versioner is used
|
||||
func convertWithMapper(obj runtime.Object, mapping *meta.RESTMapping) runtime.Object {
|
||||
s := kubernetesNativeScheme()
|
||||
var gv = runtime.GroupVersioner(schema.GroupVersions(s.PrioritizedVersionsAllGroups()))
|
||||
if mapping != nil {
|
||||
gv = mapping.GroupVersionKind.GroupVersion()
|
||||
}
|
||||
if obj, err := runtime.ObjectConvertor(s).ConvertToVersion(obj, gv); err == nil {
|
||||
return obj
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
// kubernetesNativeScheme returns a clean *runtime.Scheme with _only_ Kubernetes
|
||||
// native resources added to it. This is required to break free of custom resources
|
||||
// that may have been added to scheme.Scheme due to Helm being used as a package in
|
||||
// combination with e.g. a versioned kube client. If we would not do this, the client
|
||||
// may attempt to perform e.g. a 3-way-merge strategy patch for custom resources.
|
||||
func kubernetesNativeScheme() *runtime.Scheme {
|
||||
k8sNativeSchemeOnce.Do(func() {
|
||||
k8sNativeScheme = runtime.NewScheme()
|
||||
scheme.AddToScheme(k8sNativeScheme)
|
||||
// API extensions are not in the above scheme set,
|
||||
// and must thus be added separately.
|
||||
apiextensionsv1beta1.AddToScheme(k8sNativeScheme)
|
||||
apiextensionsv1.AddToScheme(k8sNativeScheme)
|
||||
})
|
||||
return k8sNativeScheme
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package kube // import "helm.sh/helm/v4/pkg/kube"
|
||||
|
||||
import (
|
||||
"k8s.io/cli-runtime/pkg/resource"
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
"k8s.io/kubectl/pkg/validation"
|
||||
)
|
||||
|
||||
// Factory provides abstractions that allow the Kubectl command to be extended across multiple types
|
||||
// of resources and different API sets.
|
||||
// This interface is a minimal copy of the kubectl Factory interface containing only the functions
|
||||
// needed by Helm. Since Kubernetes Go APIs, including interfaces, can change in any minor release
|
||||
// this interface is not covered by the Helm backwards compatibility guarantee. The reasons for the
|
||||
// minimal copy is that it does not include the full interface. Changes or additions to functions
|
||||
// Helm does not need are not impacted or exposed. This minimizes the impact of Kubernetes changes
|
||||
// being exposed.
|
||||
type Factory interface {
|
||||
// ToRESTConfig returns restconfig
|
||||
ToRESTConfig() (*rest.Config, error)
|
||||
|
||||
// ToRawKubeConfigLoader return kubeconfig loader as-is
|
||||
ToRawKubeConfigLoader() clientcmd.ClientConfig
|
||||
|
||||
// DynamicClient returns a dynamic client ready for use
|
||||
DynamicClient() (dynamic.Interface, error)
|
||||
|
||||
// KubernetesClientSet gives you back an external clientset
|
||||
KubernetesClientSet() (*kubernetes.Clientset, error)
|
||||
|
||||
// NewBuilder returns an object that assists in loading objects from both disk and the server
|
||||
// and which implements the common patterns for CLI interactions with generic resources.
|
||||
NewBuilder() *resource.Builder
|
||||
|
||||
// Returns a schema that can validate objects stored on disk.
|
||||
Validator(validationDirective string) (validation.Schema, error)
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package kube
|
||||
|
||||
import (
|
||||
"io"
|
||||
"time"
|
||||
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
// Interface represents a client capable of communicating with the Kubernetes API.
|
||||
//
|
||||
// A KubernetesClient must be concurrency safe.
|
||||
type Interface interface {
|
||||
// Get details of deployed resources.
|
||||
// The first argument is a list of resources to get. The second argument
|
||||
// specifies if related pods should be fetched. For example, the pods being
|
||||
// managed by a deployment.
|
||||
Get(resources ResourceList, related bool) (map[string][]runtime.Object, error)
|
||||
|
||||
// Create creates one or more resources.
|
||||
Create(resources ResourceList, options ...ClientCreateOption) (*Result, error)
|
||||
|
||||
// Delete destroys one or more resources using the specified deletion propagation policy.
|
||||
// The 'policy' parameter determines how child resources are handled during deletion.
|
||||
Delete(resources ResourceList, policy metav1.DeletionPropagation) (*Result, []error)
|
||||
|
||||
// Update updates one or more resources or creates the resource
|
||||
// if it doesn't exist.
|
||||
Update(original, target ResourceList, options ...ClientUpdateOption) (*Result, error)
|
||||
|
||||
// Build creates a resource list from a Reader.
|
||||
//
|
||||
// Reader must contain a YAML stream (one or more YAML documents separated
|
||||
// by "\n---\n")
|
||||
//
|
||||
// Validates against OpenAPI schema if validate is true.
|
||||
Build(reader io.Reader, validate bool) (ResourceList, error)
|
||||
// IsReachable checks whether the client is able to connect to the cluster.
|
||||
IsReachable() error
|
||||
|
||||
// GetWaiter gets the Kube.Waiter.
|
||||
GetWaiter(ws WaitStrategy) (Waiter, error)
|
||||
|
||||
// GetPodList lists all pods that match the specified listOptions
|
||||
GetPodList(namespace string, listOptions metav1.ListOptions) (*v1.PodList, error)
|
||||
|
||||
// OutputContainerLogsForPodList outputs the logs for a pod list
|
||||
OutputContainerLogsForPodList(podList *v1.PodList, namespace string, writerFunc func(namespace, pod, container string) io.Writer) error
|
||||
|
||||
// BuildTable creates a resource list from a Reader. This differs from
|
||||
// Interface.Build() in that a table kind is returned. A table is useful
|
||||
// if you want to use a printer to display the information.
|
||||
//
|
||||
// Reader must contain a YAML stream (one or more YAML documents separated
|
||||
// by "\n---\n")
|
||||
//
|
||||
// Validates against OpenAPI schema if validate is true.
|
||||
// TODO Helm 4: Integrate into Build with an argument
|
||||
BuildTable(reader io.Reader, validate bool) (ResourceList, error)
|
||||
}
|
||||
|
||||
// Waiter defines methods related to waiting for resource states.
|
||||
type Waiter interface {
|
||||
// Wait waits up to the given timeout for the specified resources to be ready.
|
||||
Wait(resources ResourceList, timeout time.Duration) error
|
||||
|
||||
// WaitWithJobs wait up to the given timeout for the specified resources to be ready, including jobs.
|
||||
WaitWithJobs(resources ResourceList, timeout time.Duration) error
|
||||
|
||||
// WaitForDelete wait up to the given timeout for the specified resources to be deleted.
|
||||
WaitForDelete(resources ResourceList, timeout time.Duration) error
|
||||
|
||||
// WatchUntilReady watches the resources given and waits until it is ready.
|
||||
//
|
||||
// This method is mainly for hook implementations. It watches for a resource to
|
||||
// hit a particular milestone. The milestone depends on the Kind.
|
||||
//
|
||||
// For Jobs, "ready" means the Job ran to completion (exited without error).
|
||||
// For Pods, "ready" means the Pod phase is marked "succeeded".
|
||||
// For all other kinds, it means the kind was created or modified without
|
||||
// error.
|
||||
WatchUntilReady(resources ResourceList, timeout time.Duration) error
|
||||
}
|
||||
|
||||
// InterfaceWaitOptions defines an interface that extends Interface with
|
||||
// methods that accept wait options.
|
||||
//
|
||||
// TODO Helm 5: Remove InterfaceWaitOptions and integrate its method(s) into the Interface.
|
||||
type InterfaceWaitOptions interface {
|
||||
// GetWaiter gets the Kube.Waiter with options.
|
||||
GetWaiterWithOptions(ws WaitStrategy, opts ...WaitOption) (Waiter, error)
|
||||
}
|
||||
|
||||
var _ InterfaceWaitOptions = (*Client)(nil)
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package kube
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
|
||||
)
|
||||
|
||||
// WaitOption is a function that configures an option for waiting on resources.
|
||||
type WaitOption func(*waitOptions)
|
||||
|
||||
// WithWaitContext sets the context for waiting on resources.
|
||||
// If unset, context.Background() will be used.
|
||||
func WithWaitContext(ctx context.Context) WaitOption {
|
||||
return func(wo *waitOptions) {
|
||||
wo.ctx = ctx
|
||||
}
|
||||
}
|
||||
|
||||
// WithWatchUntilReadyMethodContext sets the context specifically for the WatchUntilReady method.
|
||||
// If unset, the context set by `WithWaitContext` will be used (falling back to `context.Background()`).
|
||||
func WithWatchUntilReadyMethodContext(ctx context.Context) WaitOption {
|
||||
return func(wo *waitOptions) {
|
||||
wo.watchUntilReadyCtx = ctx
|
||||
}
|
||||
}
|
||||
|
||||
// WithWaitMethodContext sets the context specifically for the Wait method.
|
||||
// If unset, the context set by `WithWaitContext` will be used (falling back to `context.Background()`).
|
||||
func WithWaitMethodContext(ctx context.Context) WaitOption {
|
||||
return func(wo *waitOptions) {
|
||||
wo.waitCtx = ctx
|
||||
}
|
||||
}
|
||||
|
||||
// WithWaitWithJobsMethodContext sets the context specifically for the WaitWithJobs method.
|
||||
// If unset, the context set by `WithWaitContext` will be used (falling back to `context.Background()`).
|
||||
func WithWaitWithJobsMethodContext(ctx context.Context) WaitOption {
|
||||
return func(wo *waitOptions) {
|
||||
wo.waitWithJobsCtx = ctx
|
||||
}
|
||||
}
|
||||
|
||||
// WithWaitForDeleteMethodContext sets the context specifically for the WaitForDelete method.
|
||||
// If unset, the context set by `WithWaitContext` will be used (falling back to `context.Background()`).
|
||||
func WithWaitForDeleteMethodContext(ctx context.Context) WaitOption {
|
||||
return func(wo *waitOptions) {
|
||||
wo.waitForDeleteCtx = ctx
|
||||
}
|
||||
}
|
||||
|
||||
// WithKStatusReaders sets the status readers to be used while waiting on resources.
|
||||
func WithKStatusReaders(readers ...engine.StatusReader) WaitOption {
|
||||
return func(wo *waitOptions) {
|
||||
wo.statusReaders = readers
|
||||
}
|
||||
}
|
||||
|
||||
type waitOptions struct {
|
||||
ctx context.Context
|
||||
watchUntilReadyCtx context.Context
|
||||
waitCtx context.Context
|
||||
waitWithJobsCtx context.Context
|
||||
waitForDeleteCtx context.Context
|
||||
statusReaders []engine.StatusReader
|
||||
}
|
||||
+466
@@ -0,0 +1,466 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package kube // import "helm.sh/helm/v4/pkg/kube"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
||||
apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
"k8s.io/cli-runtime/pkg/resource"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/kubernetes/scheme"
|
||||
|
||||
deploymentutil "helm.sh/helm/v4/internal/third_party/k8s.io/kubernetes/deployment/util"
|
||||
)
|
||||
|
||||
// ReadyCheckerOption is a function that configures a ReadyChecker.
|
||||
type ReadyCheckerOption func(*ReadyChecker)
|
||||
|
||||
// PausedAsReady returns a ReadyCheckerOption that configures a ReadyChecker
|
||||
// to consider paused resources to be ready. For example a Deployment
|
||||
// with spec.paused equal to true would be considered ready.
|
||||
func PausedAsReady(pausedAsReady bool) ReadyCheckerOption {
|
||||
return func(c *ReadyChecker) {
|
||||
c.pausedAsReady = pausedAsReady
|
||||
}
|
||||
}
|
||||
|
||||
// CheckJobs returns a ReadyCheckerOption that configures a ReadyChecker
|
||||
// to consider readiness of Job resources.
|
||||
func CheckJobs(checkJobs bool) ReadyCheckerOption {
|
||||
return func(c *ReadyChecker) {
|
||||
c.checkJobs = checkJobs
|
||||
}
|
||||
}
|
||||
|
||||
// NewReadyChecker creates a new checker. Passed ReadyCheckerOptions can
|
||||
// be used to override defaults.
|
||||
func NewReadyChecker(cl kubernetes.Interface, opts ...ReadyCheckerOption) ReadyChecker {
|
||||
c := ReadyChecker{
|
||||
client: cl,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(&c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ReadyChecker is a type that can check core Kubernetes types for readiness.
|
||||
type ReadyChecker struct {
|
||||
client kubernetes.Interface
|
||||
checkJobs bool
|
||||
pausedAsReady bool
|
||||
}
|
||||
|
||||
// IsReady checks if v is ready. It supports checking readiness for pods,
|
||||
// deployments, persistent volume claims, services, daemon sets, custom
|
||||
// resource definitions, stateful sets, replication controllers, jobs (optional),
|
||||
// and replica sets. All other resource kinds are always considered ready.
|
||||
//
|
||||
// IsReady will fetch the latest state of the object from the server prior to
|
||||
// performing readiness checks, and it will return any error encountered.
|
||||
func (c *ReadyChecker) IsReady(ctx context.Context, v *resource.Info) (bool, error) {
|
||||
switch value := AsVersioned(v).(type) {
|
||||
case *corev1.Pod:
|
||||
pod, err := c.client.CoreV1().Pods(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{})
|
||||
if err != nil || !c.isPodReady(pod) {
|
||||
return false, err
|
||||
}
|
||||
case *batchv1.Job:
|
||||
if c.checkJobs {
|
||||
job, err := c.client.BatchV1().Jobs(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
ready, err := c.jobReady(job)
|
||||
return ready, err
|
||||
}
|
||||
case *appsv1.Deployment:
|
||||
currentDeployment, err := c.client.AppsV1().Deployments(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// If paused deployment will never be ready
|
||||
if currentDeployment.Spec.Paused {
|
||||
return c.pausedAsReady, nil
|
||||
}
|
||||
// Find RS associated with deployment
|
||||
newReplicaSet, err := deploymentutil.GetNewReplicaSet(currentDeployment, c.client.AppsV1())
|
||||
if err != nil || newReplicaSet == nil {
|
||||
return false, err
|
||||
}
|
||||
if !c.deploymentReady(newReplicaSet, currentDeployment) {
|
||||
return false, nil
|
||||
}
|
||||
case *corev1.PersistentVolumeClaim:
|
||||
claim, err := c.client.CoreV1().PersistentVolumeClaims(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !c.volumeReady(claim) {
|
||||
return false, nil
|
||||
}
|
||||
case *corev1.Service:
|
||||
svc, err := c.client.CoreV1().Services(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !c.serviceReady(svc) {
|
||||
return false, nil
|
||||
}
|
||||
case *appsv1.DaemonSet:
|
||||
ds, err := c.client.AppsV1().DaemonSets(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !c.daemonSetReady(ds) {
|
||||
return false, nil
|
||||
}
|
||||
case *apiextv1beta1.CustomResourceDefinition:
|
||||
if err := v.Get(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
crd := &apiextv1beta1.CustomResourceDefinition{}
|
||||
if err := scheme.Scheme.Convert(v.Object, crd, nil); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !c.crdBetaReady(*crd) {
|
||||
return false, nil
|
||||
}
|
||||
case *apiextv1.CustomResourceDefinition:
|
||||
if err := v.Get(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
crd := &apiextv1.CustomResourceDefinition{}
|
||||
if err := scheme.Scheme.Convert(v.Object, crd, nil); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !c.crdReady(*crd) {
|
||||
return false, nil
|
||||
}
|
||||
case *appsv1.StatefulSet:
|
||||
sts, err := c.client.AppsV1().StatefulSets(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !c.statefulSetReady(sts) {
|
||||
return false, nil
|
||||
}
|
||||
case *corev1.ReplicationController:
|
||||
rc, err := c.client.CoreV1().ReplicationControllers(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !c.replicationControllerReady(rc) {
|
||||
return false, nil
|
||||
}
|
||||
ready, err := c.podsReadyForObject(ctx, v.Namespace, value)
|
||||
if !ready || err != nil {
|
||||
return false, err
|
||||
}
|
||||
case *appsv1.ReplicaSet:
|
||||
rs, err := c.client.AppsV1().ReplicaSets(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !c.replicaSetReady(rs) {
|
||||
return false, nil
|
||||
}
|
||||
ready, err := c.podsReadyForObject(ctx, v.Namespace, value)
|
||||
if !ready || err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *ReadyChecker) podsReadyForObject(ctx context.Context, namespace string, obj runtime.Object) (bool, error) {
|
||||
pods, err := c.podsforObject(ctx, namespace, obj)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, pod := range pods {
|
||||
if !c.isPodReady(&pod) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *ReadyChecker) podsforObject(ctx context.Context, namespace string, obj runtime.Object) ([]corev1.Pod, error) {
|
||||
selector, err := SelectorsForObject(obj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list, err := getPods(ctx, c.client, namespace, selector.String())
|
||||
return list, err
|
||||
}
|
||||
|
||||
// isPodReady returns true if a pod is ready; false otherwise.
|
||||
func (c *ReadyChecker) isPodReady(pod *corev1.Pod) bool {
|
||||
for _, c := range pod.Status.Conditions {
|
||||
if c.Type == corev1.PodReady && c.Status == corev1.ConditionTrue {
|
||||
return true
|
||||
}
|
||||
}
|
||||
slog.Debug("Pod is not ready", "namespace", pod.GetNamespace(), "name", pod.GetName())
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *ReadyChecker) jobReady(job *batchv1.Job) (bool, error) {
|
||||
if job.Status.Failed > *job.Spec.BackoffLimit {
|
||||
slog.Debug("Job is failed", "namespace", job.GetNamespace(), "name", job.GetName())
|
||||
// If a job is failed, it can't recover, so throw an error
|
||||
return false, fmt.Errorf("job is failed: %s/%s", job.GetNamespace(), job.GetName())
|
||||
}
|
||||
if job.Spec.Completions != nil && job.Status.Succeeded < *job.Spec.Completions {
|
||||
slog.Debug("Job is not completed", "namespace", job.GetNamespace(), "name", job.GetName())
|
||||
return false, nil
|
||||
}
|
||||
slog.Debug("Job is completed", "namespace", job.GetNamespace(), "name", job.GetName())
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *ReadyChecker) serviceReady(s *corev1.Service) bool {
|
||||
// ExternalName Services are external to cluster so helm shouldn't be checking to see if they're 'ready' (i.e. have an IP Set)
|
||||
if s.Spec.Type == corev1.ServiceTypeExternalName {
|
||||
return true
|
||||
}
|
||||
|
||||
// Ensure that the service cluster IP is not empty
|
||||
if s.Spec.ClusterIP == "" {
|
||||
slog.Debug("Service does not have cluster IP address", "namespace", s.GetNamespace(), "name", s.GetName())
|
||||
return false
|
||||
}
|
||||
|
||||
// This checks if the service has a LoadBalancer and that balancer has an Ingress defined
|
||||
if s.Spec.Type == corev1.ServiceTypeLoadBalancer {
|
||||
// do not wait when at least 1 external IP is set
|
||||
if len(s.Spec.ExternalIPs) > 0 {
|
||||
slog.Debug("Service has external IP addresses", "namespace", s.GetNamespace(), "name", s.GetName(), "externalIPs", s.Spec.ExternalIPs)
|
||||
return true
|
||||
}
|
||||
|
||||
if s.Status.LoadBalancer.Ingress == nil {
|
||||
slog.Debug("Service does not have load balancer ingress IP address", "namespace", s.GetNamespace(), "name", s.GetName())
|
||||
return false
|
||||
}
|
||||
}
|
||||
slog.Debug("Service is ready", "namespace", s.GetNamespace(), "name", s.GetName(), "clusterIP", s.Spec.ClusterIP, "externalIPs", s.Spec.ExternalIPs)
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *ReadyChecker) volumeReady(v *corev1.PersistentVolumeClaim) bool {
|
||||
if v.Status.Phase != corev1.ClaimBound {
|
||||
slog.Debug("PersistentVolumeClaim is not bound", "namespace", v.GetNamespace(), "name", v.GetName())
|
||||
return false
|
||||
}
|
||||
slog.Debug("PersistentVolumeClaim is bound", "namespace", v.GetNamespace(), "name", v.GetName(), "phase", v.Status.Phase)
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *ReadyChecker) deploymentReady(rs *appsv1.ReplicaSet, dep *appsv1.Deployment) bool {
|
||||
// Verify the replicaset readiness
|
||||
if !c.replicaSetReady(rs) {
|
||||
return false
|
||||
}
|
||||
// Verify the generation observed by the deployment controller matches the spec generation
|
||||
if dep.Status.ObservedGeneration != dep.Generation {
|
||||
slog.Debug("Deployment is not ready, observedGeneration does not match spec generation", "namespace", dep.GetNamespace(), "name", dep.GetName(), "actualGeneration", dep.Status.ObservedGeneration, "expectedGeneration", dep.Generation)
|
||||
return false
|
||||
}
|
||||
|
||||
expectedReady := *dep.Spec.Replicas - deploymentutil.MaxUnavailable(*dep)
|
||||
if rs.Status.ReadyReplicas < expectedReady {
|
||||
slog.Debug("Deployment does not have enough pods ready", "namespace", dep.GetNamespace(), "name", dep.GetName(), "readyPods", rs.Status.ReadyReplicas, "totalPods", expectedReady)
|
||||
return false
|
||||
}
|
||||
slog.Debug("Deployment is ready", "namespace", dep.GetNamespace(), "name", dep.GetName(), "readyPods", rs.Status.ReadyReplicas, "totalPods", expectedReady)
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *ReadyChecker) daemonSetReady(ds *appsv1.DaemonSet) bool {
|
||||
// Verify the generation observed by the daemonSet controller matches the spec generation
|
||||
if ds.Status.ObservedGeneration != ds.Generation {
|
||||
slog.Debug("DaemonSet is not ready, observedGeneration does not match spec generation", "namespace", ds.GetNamespace(), "name", ds.GetName(), "observedGeneration", ds.Status.ObservedGeneration, "expectedGeneration", ds.Generation)
|
||||
return false
|
||||
}
|
||||
|
||||
// If the update strategy is not a rolling update, there will be nothing to wait for
|
||||
if ds.Spec.UpdateStrategy.Type != appsv1.RollingUpdateDaemonSetStrategyType {
|
||||
return true
|
||||
}
|
||||
|
||||
// Make sure all the updated pods have been scheduled
|
||||
if ds.Status.UpdatedNumberScheduled != ds.Status.DesiredNumberScheduled {
|
||||
slog.Debug("DaemonSet does not have enough Pods scheduled", "namespace", ds.GetNamespace(), "name", ds.GetName(), "scheduledPods", ds.Status.UpdatedNumberScheduled, "totalPods", ds.Status.DesiredNumberScheduled)
|
||||
return false
|
||||
}
|
||||
maxUnavailable, err := intstr.GetScaledValueFromIntOrPercent(ds.Spec.UpdateStrategy.RollingUpdate.MaxUnavailable, int(ds.Status.DesiredNumberScheduled), true)
|
||||
if err != nil {
|
||||
// If for some reason the value is invalid, set max unavailable to the
|
||||
// number of desired replicas. This is the same behavior as the
|
||||
// `MaxUnavailable` function in deploymentutil
|
||||
maxUnavailable = int(ds.Status.DesiredNumberScheduled)
|
||||
}
|
||||
|
||||
expectedReady := int(ds.Status.DesiredNumberScheduled) - maxUnavailable
|
||||
if int(ds.Status.NumberReady) < expectedReady {
|
||||
slog.Debug("DaemonSet does not have enough Pods ready", "namespace", ds.GetNamespace(), "name", ds.GetName(), "readyPods", ds.Status.NumberReady, "totalPods", expectedReady)
|
||||
return false
|
||||
}
|
||||
slog.Debug("DaemonSet is ready", "namespace", ds.GetNamespace(), "name", ds.GetName(), "readyPods", ds.Status.NumberReady, "totalPods", expectedReady)
|
||||
return true
|
||||
}
|
||||
|
||||
// Because the v1 extensions API is not available on all supported k8s versions
|
||||
// yet and because Go doesn't support generics, we need to have a duplicate
|
||||
// function to support the v1beta1 types
|
||||
func (c *ReadyChecker) crdBetaReady(crd apiextv1beta1.CustomResourceDefinition) bool {
|
||||
for _, cond := range crd.Status.Conditions {
|
||||
switch cond.Type {
|
||||
case apiextv1beta1.Established:
|
||||
if cond.Status == apiextv1beta1.ConditionTrue {
|
||||
return true
|
||||
}
|
||||
case apiextv1beta1.NamesAccepted:
|
||||
if cond.Status == apiextv1beta1.ConditionFalse {
|
||||
// This indicates a naming conflict, but it's probably not the
|
||||
// job of this function to fail because of that. Instead,
|
||||
// we treat it as a success, since the process should be able to
|
||||
// continue.
|
||||
return true
|
||||
}
|
||||
default:
|
||||
// intentionally left empty
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *ReadyChecker) crdReady(crd apiextv1.CustomResourceDefinition) bool {
|
||||
for _, cond := range crd.Status.Conditions {
|
||||
switch cond.Type {
|
||||
case apiextv1.Established:
|
||||
if cond.Status == apiextv1.ConditionTrue {
|
||||
return true
|
||||
}
|
||||
case apiextv1.NamesAccepted:
|
||||
if cond.Status == apiextv1.ConditionFalse {
|
||||
// This indicates a naming conflict, but it's probably not the
|
||||
// job of this function to fail because of that. Instead,
|
||||
// we treat it as a success, since the process should be able to
|
||||
// continue.
|
||||
return true
|
||||
}
|
||||
default:
|
||||
// intentionally left empty
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *ReadyChecker) statefulSetReady(sts *appsv1.StatefulSet) bool {
|
||||
// Verify the generation observed by the statefulSet controller matches the spec generation
|
||||
if sts.Status.ObservedGeneration != sts.Generation {
|
||||
slog.Debug("StatefulSet is not ready, observedGeneration doest not match spec generation", "namespace", sts.GetNamespace(), "name", sts.GetName(), "actualGeneration", sts.Status.ObservedGeneration, "expectedGeneration", sts.Generation)
|
||||
return false
|
||||
}
|
||||
|
||||
// If the update strategy is not a rolling update, there will be nothing to wait for
|
||||
if sts.Spec.UpdateStrategy.Type != appsv1.RollingUpdateStatefulSetStrategyType {
|
||||
slog.Debug("StatefulSet skipped ready check", "namespace", sts.GetNamespace(), "name", sts.GetName(), "updateStrategy", sts.Spec.UpdateStrategy.Type)
|
||||
return true
|
||||
}
|
||||
|
||||
// Dereference all the pointers because StatefulSets like them
|
||||
var partition int
|
||||
// 1 is the default for replicas if not set
|
||||
replicas := 1
|
||||
// For some reason, even if the update strategy is a rolling update, the
|
||||
// actual rollingUpdate field can be nil. If it is, we can safely assume
|
||||
// there is no partition value
|
||||
if sts.Spec.UpdateStrategy.RollingUpdate != nil && sts.Spec.UpdateStrategy.RollingUpdate.Partition != nil {
|
||||
partition = int(*sts.Spec.UpdateStrategy.RollingUpdate.Partition)
|
||||
}
|
||||
if sts.Spec.Replicas != nil {
|
||||
replicas = int(*sts.Spec.Replicas)
|
||||
}
|
||||
|
||||
// Because an update strategy can use partitioning, we need to calculate the
|
||||
// number of updated replicas we should have. For example, if the replicas
|
||||
// is set to 3 and the partition is 2, we'd expect only one pod to be
|
||||
// updated
|
||||
expectedReplicas := replicas - partition
|
||||
|
||||
// Make sure all the updated pods have been scheduled
|
||||
if int(sts.Status.UpdatedReplicas) < expectedReplicas {
|
||||
slog.Debug("StatefulSet does not have enough Pods scheduled", "namespace", sts.GetNamespace(), "name", sts.GetName(), "readyPods", sts.Status.UpdatedReplicas, "totalPods", expectedReplicas)
|
||||
return false
|
||||
}
|
||||
|
||||
if int(sts.Status.ReadyReplicas) != replicas {
|
||||
slog.Debug("StatefulSet does not have enough Pods ready", "namespace", sts.GetNamespace(), "name", sts.GetName(), "readyPods", sts.Status.ReadyReplicas, "totalPods", replicas)
|
||||
return false
|
||||
}
|
||||
// This check only makes sense when all partitions are being upgraded otherwise during a
|
||||
// partitioned rolling upgrade, this condition will never evaluate to true, leading to
|
||||
// error.
|
||||
if partition == 0 && sts.Status.CurrentRevision != sts.Status.UpdateRevision {
|
||||
slog.Debug("StatefulSet is not ready, currentRevision does not match updateRevision", "namespace", sts.GetNamespace(), "name", sts.GetName(), "currentRevision", sts.Status.CurrentRevision, "updateRevision", sts.Status.UpdateRevision)
|
||||
return false
|
||||
}
|
||||
slog.Debug("StatefulSet is ready", "namespace", sts.GetNamespace(), "name", sts.GetName(), "readyPods", sts.Status.ReadyReplicas, "totalPods", replicas)
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *ReadyChecker) replicationControllerReady(rc *corev1.ReplicationController) bool {
|
||||
// Verify the generation observed by the replicationController controller matches the spec generation
|
||||
if rc.Status.ObservedGeneration != rc.Generation {
|
||||
slog.Debug("ReplicationController is not ready, observedGeneration doest not match spec generation", "namespace", rc.GetNamespace(), "name", rc.GetName(), "actualGeneration", rc.Status.ObservedGeneration, "expectedGeneration", rc.Generation)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *ReadyChecker) replicaSetReady(rs *appsv1.ReplicaSet) bool {
|
||||
// Verify the generation observed by the replicaSet controller matches the spec generation
|
||||
if rs.Status.ObservedGeneration != rs.Generation {
|
||||
slog.Debug("ReplicaSet is not ready, observedGeneration doest not match spec generation", "namespace", rs.GetNamespace(), "name", rs.GetName(), "actualGeneration", rs.Status.ObservedGeneration, "expectedGeneration", rs.Generation)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func getPods(ctx context.Context, client kubernetes.Interface, namespace, selector string) ([]corev1.Pod, error) {
|
||||
list, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
|
||||
LabelSelector: selector,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list pods: %w", err)
|
||||
}
|
||||
return list.Items, nil
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package kube // import "helm.sh/helm/v4/pkg/kube"
|
||||
|
||||
import "k8s.io/cli-runtime/pkg/resource"
|
||||
|
||||
// ResourceList provides convenience methods for comparing collections of Infos.
|
||||
type ResourceList []*resource.Info
|
||||
|
||||
// Append adds an Info to the Result.
|
||||
func (r *ResourceList) Append(val *resource.Info) {
|
||||
*r = append(*r, val)
|
||||
}
|
||||
|
||||
// Visit implements resource.Visitor. The visitor stops if fn returns an error.
|
||||
func (r ResourceList) Visit(fn resource.VisitorFunc) error {
|
||||
for _, i := range r {
|
||||
if err := fn(i, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Filter returns a new Result with Infos that satisfy the predicate fn.
|
||||
func (r ResourceList) Filter(fn func(*resource.Info) bool) ResourceList {
|
||||
var result ResourceList
|
||||
for _, i := range r {
|
||||
if fn(i) {
|
||||
result.Append(i)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Get returns the Info from the result that matches the name and kind.
|
||||
func (r ResourceList) Get(info *resource.Info) *resource.Info {
|
||||
for _, i := range r {
|
||||
if isMatchingInfo(i, info) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Contains checks to see if an object exists.
|
||||
func (r ResourceList) Contains(info *resource.Info) bool {
|
||||
for _, i := range r {
|
||||
if isMatchingInfo(i, info) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Difference will return a new Result with objects not contained in rs.
|
||||
func (r ResourceList) Difference(rs ResourceList) ResourceList {
|
||||
return r.Filter(func(info *resource.Info) bool {
|
||||
return !rs.Contains(info)
|
||||
})
|
||||
}
|
||||
|
||||
// Intersect will return a new Result with objects contained in both Results.
|
||||
func (r ResourceList) Intersect(rs ResourceList) ResourceList {
|
||||
return r.Filter(rs.Contains)
|
||||
}
|
||||
|
||||
// isMatchingInfo returns true if infos match on Name, Namespace, Group and Kind.
|
||||
//
|
||||
// IMPORTANT: Version is intentionally excluded from the comparison. Resources
|
||||
// served by the same CRD at different API versions (e.g. v2beta1 vs v2beta2)
|
||||
// share the same underlying storage in the Kubernetes API server. Comparing
|
||||
// the full GroupVersionKind causes Difference() to treat a version change as
|
||||
// a resource removal + addition, which makes Helm delete the resource it just
|
||||
// created during upgrades. See https://github.com/helm/helm/issues/31768
|
||||
func isMatchingInfo(a, b *resource.Info) bool {
|
||||
return a.Name == b.Name && a.Namespace == b.Namespace && a.Mapping.GroupVersionKind.GroupKind() == b.Mapping.GroupVersionKind.GroupKind()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package kube // import "helm.sh/helm/v4/pkg/kube"
|
||||
|
||||
// ResourcePolicyAnno is the annotation name for a resource policy
|
||||
const ResourcePolicyAnno = "helm.sh/resource-policy"
|
||||
|
||||
// KeepPolicy is the resource policy type for keep
|
||||
//
|
||||
// This resource policy type allows resources to skip being deleted
|
||||
//
|
||||
// during an uninstallRelease action.
|
||||
const KeepPolicy = "keep"
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package kube
|
||||
|
||||
// Result contains the information of created, updated, and deleted resources
|
||||
// for various kube API calls along with helper methods for using those
|
||||
// resources
|
||||
type Result struct {
|
||||
Created ResourceList
|
||||
Updated ResourceList
|
||||
Deleted ResourceList
|
||||
}
|
||||
|
||||
// If needed, we can add methods to the Result type for things like diffing
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package kube
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RetryingRoundTripper struct {
|
||||
Wrapped http.RoundTripper
|
||||
}
|
||||
|
||||
func (rt *RetryingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return rt.roundTrip(req, 1, nil)
|
||||
}
|
||||
|
||||
func (rt *RetryingRoundTripper) roundTrip(req *http.Request, retry int, prevResp *http.Response) (*http.Response, error) {
|
||||
if retry < 0 {
|
||||
return prevResp, nil
|
||||
}
|
||||
resp, rtErr := rt.Wrapped.RoundTrip(req)
|
||||
if rtErr != nil {
|
||||
return resp, rtErr
|
||||
}
|
||||
if resp.StatusCode < 500 {
|
||||
return resp, rtErr
|
||||
}
|
||||
if resp.Header.Get("content-type") != "application/json" {
|
||||
return resp, rtErr
|
||||
}
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
var ke kubernetesError
|
||||
r := bytes.NewReader(b)
|
||||
err = json.NewDecoder(r).Decode(&ke)
|
||||
r.Seek(0, io.SeekStart)
|
||||
resp.Body = io.NopCloser(r)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
if ke.Code < 500 {
|
||||
return resp, nil
|
||||
}
|
||||
// Matches messages like "etcdserver: leader changed"
|
||||
if strings.HasSuffix(ke.Message, "etcdserver: leader changed") {
|
||||
return rt.roundTrip(req, retry-1, resp)
|
||||
}
|
||||
// Matches messages like "rpc error: code = Unknown desc = raft proposal dropped"
|
||||
if strings.HasSuffix(ke.Message, "raft proposal dropped") {
|
||||
return rt.roundTrip(req, retry-1, resp)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
type kubernetesError struct {
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
}
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package kube // import "helm.sh/helm/v4/pkg/kube"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/aggregator"
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/collector"
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/statusreaders"
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/status"
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/watcher"
|
||||
"github.com/fluxcd/cli-utils/pkg/object"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/client-go/dynamic"
|
||||
watchtools "k8s.io/client-go/tools/watch"
|
||||
|
||||
"helm.sh/helm/v4/internal/logging"
|
||||
helmStatusReaders "helm.sh/helm/v4/internal/statusreaders"
|
||||
)
|
||||
|
||||
type statusWaiter struct {
|
||||
client dynamic.Interface
|
||||
restMapper meta.RESTMapper
|
||||
ctx context.Context
|
||||
watchUntilReadyCtx context.Context
|
||||
waitCtx context.Context
|
||||
waitWithJobsCtx context.Context
|
||||
waitForDeleteCtx context.Context
|
||||
readers []engine.StatusReader
|
||||
logging.LogHolder
|
||||
}
|
||||
|
||||
// DefaultStatusWatcherTimeout is the timeout used by the status waiter when a
|
||||
// zero timeout is provided. This prevents callers from accidentally passing a
|
||||
// zero value (which would immediately cancel the context) and getting
|
||||
// "context deadline exceeded" errors. SDK callers can rely on this default
|
||||
// when they don't set a timeout.
|
||||
var DefaultStatusWatcherTimeout = 30 * time.Second
|
||||
|
||||
func alwaysReady(_ *unstructured.Unstructured) (*status.Result, error) {
|
||||
return &status.Result{
|
||||
Status: status.CurrentStatus,
|
||||
Message: "Resource is current",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *statusWaiter) WatchUntilReady(resourceList ResourceList, timeout time.Duration) error {
|
||||
if timeout == 0 {
|
||||
timeout = DefaultStatusWatcherTimeout
|
||||
}
|
||||
ctx, cancel := w.contextWithTimeout(w.watchUntilReadyCtx, timeout)
|
||||
defer cancel()
|
||||
w.Logger().Debug("waiting for resources", "count", len(resourceList), "timeout", timeout)
|
||||
sw := watcher.NewDefaultStatusWatcher(w.client, w.restMapper)
|
||||
jobSR := helmStatusReaders.NewCustomJobStatusReader(w.restMapper)
|
||||
podSR := helmStatusReaders.NewCustomPodStatusReader(w.restMapper)
|
||||
// We don't want to wait on any other resources as watchUntilReady is only for Helm hooks.
|
||||
// If custom readers are defined they can be used as Helm hooks support any resource.
|
||||
// We put them in front since the DelegatingStatusReader uses the first reader that matches.
|
||||
genericSR := statusreaders.NewGenericStatusReader(w.restMapper, alwaysReady)
|
||||
|
||||
sr := &statusreaders.DelegatingStatusReader{
|
||||
StatusReaders: append(w.readers, jobSR, podSR, genericSR),
|
||||
}
|
||||
sw.StatusReader = sr
|
||||
return w.wait(ctx, resourceList, sw)
|
||||
}
|
||||
|
||||
func (w *statusWaiter) Wait(resourceList ResourceList, timeout time.Duration) error {
|
||||
if timeout == 0 {
|
||||
timeout = DefaultStatusWatcherTimeout
|
||||
}
|
||||
ctx, cancel := w.contextWithTimeout(w.waitCtx, timeout)
|
||||
defer cancel()
|
||||
w.Logger().Debug("waiting for resources", "count", len(resourceList), "timeout", timeout)
|
||||
sw := watcher.NewDefaultStatusWatcher(w.client, w.restMapper)
|
||||
sw.StatusReader = statusreaders.NewStatusReader(w.restMapper, w.readers...)
|
||||
return w.wait(ctx, resourceList, sw)
|
||||
}
|
||||
|
||||
func (w *statusWaiter) WaitWithJobs(resourceList ResourceList, timeout time.Duration) error {
|
||||
if timeout == 0 {
|
||||
timeout = DefaultStatusWatcherTimeout
|
||||
}
|
||||
ctx, cancel := w.contextWithTimeout(w.waitWithJobsCtx, timeout)
|
||||
defer cancel()
|
||||
w.Logger().Debug("waiting for resources", "count", len(resourceList), "timeout", timeout)
|
||||
sw := watcher.NewDefaultStatusWatcher(w.client, w.restMapper)
|
||||
newCustomJobStatusReader := helmStatusReaders.NewCustomJobStatusReader(w.restMapper)
|
||||
readers := append([]engine.StatusReader(nil), w.readers...)
|
||||
readers = append(readers, newCustomJobStatusReader)
|
||||
customSR := statusreaders.NewStatusReader(w.restMapper, readers...)
|
||||
sw.StatusReader = customSR
|
||||
return w.wait(ctx, resourceList, sw)
|
||||
}
|
||||
|
||||
func (w *statusWaiter) WaitForDelete(resourceList ResourceList, timeout time.Duration) error {
|
||||
if timeout == 0 {
|
||||
timeout = DefaultStatusWatcherTimeout
|
||||
}
|
||||
ctx, cancel := w.contextWithTimeout(w.waitForDeleteCtx, timeout)
|
||||
defer cancel()
|
||||
w.Logger().Debug("waiting for resources to be deleted", "count", len(resourceList), "timeout", timeout)
|
||||
sw := watcher.NewDefaultStatusWatcher(w.client, w.restMapper)
|
||||
return w.waitForDelete(ctx, resourceList, sw)
|
||||
}
|
||||
|
||||
func (w *statusWaiter) waitForDelete(ctx context.Context, resourceList ResourceList, sw watcher.StatusWatcher) error {
|
||||
cancelCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
resources := []object.ObjMetadata{}
|
||||
for _, resource := range resourceList {
|
||||
obj, err := object.RuntimeToObjMeta(resource.Object)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resources = append(resources, obj)
|
||||
}
|
||||
eventCh := sw.Watch(cancelCtx, resources, watcher.Options{
|
||||
RESTScopeStrategy: watcher.RESTScopeNamespace,
|
||||
})
|
||||
statusCollector := collector.NewResourceStatusCollector(resources)
|
||||
done := statusCollector.ListenWithObserver(eventCh, statusObserver(cancel, status.NotFoundStatus, w.Logger()))
|
||||
<-done
|
||||
|
||||
if statusCollector.Error != nil {
|
||||
return statusCollector.Error
|
||||
}
|
||||
|
||||
errs := []error{}
|
||||
for _, id := range resources {
|
||||
rs := statusCollector.ResourceStatuses[id]
|
||||
if rs.Status == status.NotFoundStatus || rs.Status == status.UnknownStatus {
|
||||
continue
|
||||
}
|
||||
errs = append(errs, fmt.Errorf("resource %s/%s/%s still exists. status: %s, message: %s",
|
||||
rs.Identifier.GroupKind.Kind, rs.Identifier.Namespace, rs.Identifier.Name, rs.Status, rs.Message))
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *statusWaiter) wait(ctx context.Context, resourceList ResourceList, sw watcher.StatusWatcher) error {
|
||||
cancelCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
resources := []object.ObjMetadata{}
|
||||
for _, resource := range resourceList {
|
||||
switch value := AsVersioned(resource).(type) {
|
||||
case *appsv1.Deployment:
|
||||
if value.Spec.Paused {
|
||||
continue
|
||||
}
|
||||
}
|
||||
obj, err := object.RuntimeToObjMeta(resource.Object)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resources = append(resources, obj)
|
||||
}
|
||||
|
||||
eventCh := sw.Watch(cancelCtx, resources, watcher.Options{
|
||||
RESTScopeStrategy: watcher.RESTScopeNamespace,
|
||||
})
|
||||
statusCollector := collector.NewResourceStatusCollector(resources)
|
||||
done := statusCollector.ListenWithObserver(eventCh, statusObserver(cancel, status.CurrentStatus, w.Logger()))
|
||||
<-done
|
||||
|
||||
if statusCollector.Error != nil {
|
||||
return statusCollector.Error
|
||||
}
|
||||
|
||||
errs := []error{}
|
||||
for _, id := range resources {
|
||||
rs := statusCollector.ResourceStatuses[id]
|
||||
if rs.Status == status.CurrentStatus {
|
||||
continue
|
||||
}
|
||||
errs = append(errs, fmt.Errorf("resource %s/%s/%s not ready. status: %s, message: %s",
|
||||
rs.Identifier.GroupKind.Kind, rs.Identifier.Namespace, rs.Identifier.Name, rs.Status, rs.Message))
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *statusWaiter) contextWithTimeout(methodCtx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
if methodCtx == nil {
|
||||
methodCtx = w.ctx
|
||||
}
|
||||
return contextWithTimeout(methodCtx, timeout)
|
||||
}
|
||||
|
||||
func contextWithTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return watchtools.ContextWithOptionalTimeout(ctx, timeout)
|
||||
}
|
||||
|
||||
func statusObserver(cancel context.CancelFunc, desired status.Status, logger *slog.Logger) collector.ObserverFunc {
|
||||
return func(statusCollector *collector.ResourceStatusCollector, _ event.Event) {
|
||||
var rss []*event.ResourceStatus
|
||||
var nonDesiredResources []*event.ResourceStatus
|
||||
for _, rs := range statusCollector.ResourceStatuses {
|
||||
if rs == nil {
|
||||
continue
|
||||
}
|
||||
// If a resource is already deleted before waiting has started, it will show as unknown.
|
||||
// This check ensures we don't wait forever for a resource that is already deleted.
|
||||
if rs.Status == status.UnknownStatus && desired == status.NotFoundStatus {
|
||||
continue
|
||||
}
|
||||
// Failed is a terminal state. This check ensures we don't wait forever for a resource
|
||||
// that has already failed, as intervention is required to resolve the failure.
|
||||
if rs.Status == status.FailedStatus && desired == status.CurrentStatus {
|
||||
continue
|
||||
}
|
||||
rss = append(rss, rs)
|
||||
if rs.Status != desired {
|
||||
nonDesiredResources = append(nonDesiredResources, rs)
|
||||
}
|
||||
}
|
||||
|
||||
if aggregator.AggregateStatus(rss, desired) == desired {
|
||||
logger.Debug("all resources achieved desired status", "desiredStatus", desired, "resourceCount", len(rss))
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
|
||||
if len(nonDesiredResources) > 0 {
|
||||
// Log a single resource so the user knows what they're waiting for without an overwhelming amount of output
|
||||
sort.Slice(nonDesiredResources, func(i, j int) bool {
|
||||
return nonDesiredResources[i].Identifier.Name < nonDesiredResources[j].Identifier.Name
|
||||
})
|
||||
first := nonDesiredResources[0]
|
||||
logger.Debug("waiting for resource", "namespace", first.Identifier.Namespace, "name", first.Identifier.Name, "kind", first.Identifier.GroupKind.Kind, "expectedStatus", desired, "actualStatus", first.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type hookOnlyWaiter struct {
|
||||
sw *statusWaiter
|
||||
}
|
||||
|
||||
func (w *hookOnlyWaiter) WatchUntilReady(resourceList ResourceList, timeout time.Duration) error {
|
||||
return w.sw.WatchUntilReady(resourceList, timeout)
|
||||
}
|
||||
|
||||
func (w *hookOnlyWaiter) Wait(_ ResourceList, _ time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *hookOnlyWaiter) WaitWithJobs(_ ResourceList, _ time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *hookOnlyWaiter) WaitForDelete(_ ResourceList, _ time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package kube // import "helm.sh/helm/v4/pkg/kube"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
appsv1beta1 "k8s.io/api/apps/v1beta1"
|
||||
appsv1beta2 "k8s.io/api/apps/v1beta2"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
extensionsv1beta1 "k8s.io/api/extensions/v1beta1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/fields"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
"k8s.io/cli-runtime/pkg/resource"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
cachetools "k8s.io/client-go/tools/cache"
|
||||
watchtools "k8s.io/client-go/tools/watch"
|
||||
|
||||
"k8s.io/apimachinery/pkg/util/wait"
|
||||
)
|
||||
|
||||
// legacyWaiter is the legacy implementation of the Waiter interface. This logic was used by default in Helm 3
|
||||
// Helm 4 now uses the StatusWaiter implementation instead
|
||||
type legacyWaiter struct {
|
||||
c ReadyChecker
|
||||
kubeClient *kubernetes.Clientset
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (hw *legacyWaiter) Wait(resources ResourceList, timeout time.Duration) error {
|
||||
hw.c = NewReadyChecker(hw.kubeClient, PausedAsReady(true))
|
||||
return hw.waitForResources(resources, timeout)
|
||||
}
|
||||
|
||||
func (hw *legacyWaiter) WaitWithJobs(resources ResourceList, timeout time.Duration) error {
|
||||
hw.c = NewReadyChecker(hw.kubeClient, PausedAsReady(true), CheckJobs(true))
|
||||
return hw.waitForResources(resources, timeout)
|
||||
}
|
||||
|
||||
// waitForResources polls to get the current status of all pods, PVCs, Services and
|
||||
// Jobs(optional) until all are ready or a timeout is reached
|
||||
func (hw *legacyWaiter) waitForResources(created ResourceList, timeout time.Duration) error {
|
||||
slog.Debug("beginning wait for resources", "count", len(created), "timeout", timeout)
|
||||
|
||||
ctx, cancel := hw.contextWithTimeout(timeout)
|
||||
defer cancel()
|
||||
|
||||
numberOfErrors := make([]int, len(created))
|
||||
for i := range numberOfErrors {
|
||||
numberOfErrors[i] = 0
|
||||
}
|
||||
|
||||
return wait.PollUntilContextCancel(ctx, 2*time.Second, true, func(ctx context.Context) (bool, error) {
|
||||
waitRetries := 30
|
||||
for i, v := range created {
|
||||
ready, err := hw.c.IsReady(ctx, v)
|
||||
|
||||
if waitRetries > 0 && hw.isRetryableError(err, v) {
|
||||
numberOfErrors[i]++
|
||||
if numberOfErrors[i] > waitRetries {
|
||||
slog.Debug("max number of retries reached", "resource", v.Name, "retries", numberOfErrors[i])
|
||||
return false, err
|
||||
}
|
||||
slog.Debug("retrying resource readiness", "resource", v.Name, "currentRetries", numberOfErrors[i]-1, "maxRetries", waitRetries)
|
||||
return false, nil
|
||||
}
|
||||
numberOfErrors[i] = 0
|
||||
if !ready {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (hw *legacyWaiter) isRetryableError(err error, resource *resource.Info) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
slog.Debug(
|
||||
"error received when checking resource status",
|
||||
slog.String("resource", resource.Name),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
if ev, ok := err.(*apierrors.StatusError); ok {
|
||||
statusCode := ev.Status().Code
|
||||
retryable := hw.isRetryableHTTPStatusCode(statusCode)
|
||||
slog.Debug(
|
||||
"status code received",
|
||||
slog.String("resource", resource.Name),
|
||||
slog.Int("statusCode", int(statusCode)),
|
||||
slog.Bool("retryable", retryable),
|
||||
)
|
||||
return retryable
|
||||
}
|
||||
slog.Debug("retryable error assumed", "resource", resource.Name)
|
||||
return true
|
||||
}
|
||||
|
||||
func (hw *legacyWaiter) isRetryableHTTPStatusCode(httpStatusCode int32) bool {
|
||||
return httpStatusCode == 0 || httpStatusCode == http.StatusTooManyRequests || (httpStatusCode >= 500 && httpStatusCode != http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// WaitForDelete polls to check if all the resources are deleted or a timeout is reached
|
||||
func (hw *legacyWaiter) WaitForDelete(deleted ResourceList, timeout time.Duration) error {
|
||||
slog.Debug("beginning wait for resources to be deleted", "count", len(deleted), "timeout", timeout)
|
||||
|
||||
startTime := time.Now()
|
||||
ctx, cancel := hw.contextWithTimeout(timeout)
|
||||
defer cancel()
|
||||
|
||||
err := wait.PollUntilContextCancel(ctx, 2*time.Second, true, func(_ context.Context) (bool, error) {
|
||||
for _, v := range deleted {
|
||||
err := v.Get()
|
||||
if err == nil || !apierrors.IsNotFound(err) {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
|
||||
elapsed := time.Since(startTime).Round(time.Second)
|
||||
if err != nil {
|
||||
slog.Debug("wait for resources failed", slog.Duration("elapsed", elapsed), slog.Any("error", err))
|
||||
} else {
|
||||
slog.Debug("wait for resources succeeded", slog.Duration("elapsed", elapsed))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// SelectorsForObject returns the pod label selector for a given object
|
||||
//
|
||||
// Modified version of https://github.com/kubernetes/kubernetes/blob/v1.14.1/pkg/kubectl/polymorphichelpers/helpers.go#L84
|
||||
func SelectorsForObject(object runtime.Object) (selector labels.Selector, err error) {
|
||||
switch t := object.(type) {
|
||||
case *extensionsv1beta1.ReplicaSet:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *appsv1.ReplicaSet:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *appsv1beta2.ReplicaSet:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *corev1.ReplicationController:
|
||||
selector = labels.SelectorFromSet(t.Spec.Selector)
|
||||
case *appsv1.StatefulSet:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *appsv1beta1.StatefulSet:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *appsv1beta2.StatefulSet:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *extensionsv1beta1.DaemonSet:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *appsv1.DaemonSet:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *appsv1beta2.DaemonSet:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *extensionsv1beta1.Deployment:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *appsv1.Deployment:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *appsv1beta1.Deployment:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *appsv1beta2.Deployment:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *batchv1.Job:
|
||||
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
|
||||
case *corev1.Service:
|
||||
if len(t.Spec.Selector) == 0 {
|
||||
return nil, fmt.Errorf("invalid service '%s': Service is defined without a selector", t.Name)
|
||||
}
|
||||
selector = labels.SelectorFromSet(t.Spec.Selector)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("selector for %T not implemented", object)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return selector, fmt.Errorf("invalid label selector: %w", err)
|
||||
}
|
||||
|
||||
return selector, nil
|
||||
}
|
||||
|
||||
func (hw *legacyWaiter) watchTimeout(t time.Duration) func(*resource.Info) error {
|
||||
return func(info *resource.Info) error {
|
||||
return hw.watchUntilReady(t, info)
|
||||
}
|
||||
}
|
||||
|
||||
// WatchUntilReady watches the resources given and waits until it is ready.
|
||||
//
|
||||
// This method is mainly for hook implementations. It watches for a resource to
|
||||
// hit a particular milestone. The milestone depends on the Kind.
|
||||
//
|
||||
// For most kinds, it checks to see if the resource is marked as Added or Modified
|
||||
// by the Kubernetes event stream. For some kinds, it does more:
|
||||
//
|
||||
// - Jobs: A job is marked "Ready" when it has successfully completed. This is
|
||||
// ascertained by watching the Status fields in a job's output.
|
||||
// - Pods: A pod is marked "Ready" when it has successfully completed. This is
|
||||
// ascertained by watching the status.phase field in a pod's output.
|
||||
//
|
||||
// Handling for other kinds will be added as necessary.
|
||||
func (hw *legacyWaiter) WatchUntilReady(resources ResourceList, timeout time.Duration) error {
|
||||
// For jobs, there's also the option to do poll c.Jobs(namespace).Get():
|
||||
// https://github.com/adamreese/kubernetes/blob/master/test/e2e/job.go#L291-L300
|
||||
return perform(resources, hw.watchTimeout(timeout))
|
||||
}
|
||||
|
||||
func (hw *legacyWaiter) watchUntilReady(timeout time.Duration, info *resource.Info) error {
|
||||
kind := info.Mapping.GroupVersionKind.Kind
|
||||
switch kind {
|
||||
case "Job", "Pod":
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
slog.Debug("watching for resource changes", "kind", kind, "resource", info.Name, "timeout", timeout)
|
||||
|
||||
// Use a selector on the name of the resource. This should be unique for the
|
||||
// given version and kind
|
||||
selector, err := fields.ParseSelector(fmt.Sprintf("metadata.name=%s", info.Name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lw := cachetools.NewListWatchFromClient(info.Client, info.Mapping.Resource.Resource, info.Namespace, selector)
|
||||
|
||||
// What we watch for depends on the Kind.
|
||||
// - For a Job, we watch for completion.
|
||||
// - For all else, we watch until Ready.
|
||||
// In the future, we might want to add some special logic for types
|
||||
// like Ingress, Volume, etc.
|
||||
|
||||
ctx, cancel := hw.contextWithTimeout(timeout)
|
||||
defer cancel()
|
||||
_, err = watchtools.UntilWithSync(ctx, lw, &unstructured.Unstructured{}, nil, func(e watch.Event) (bool, error) {
|
||||
// Make sure the incoming object is versioned as we use unstructured
|
||||
// objects when we build manifests
|
||||
obj := convertWithMapper(e.Object, info.Mapping)
|
||||
switch e.Type {
|
||||
case watch.Added, watch.Modified:
|
||||
// For things like a secret or a config map, this is the best indicator
|
||||
// we get. We care mostly about jobs, where what we want to see is
|
||||
// the status go into a good state. For other types, like ReplicaSet
|
||||
// we don't really do anything to support these as hooks.
|
||||
slog.Debug("add/modify event received", "resource", info.Name, "eventType", e.Type)
|
||||
|
||||
switch kind {
|
||||
case "Job":
|
||||
return hw.waitForJob(obj, info.Name)
|
||||
case "Pod":
|
||||
return hw.waitForPodSuccess(obj, info.Name)
|
||||
}
|
||||
return true, nil
|
||||
case watch.Deleted:
|
||||
slog.Debug("deleted event received", "resource", info.Name)
|
||||
return true, nil
|
||||
case watch.Error:
|
||||
// Handle error and return with an error.
|
||||
slog.Error("error event received", "resource", info.Name)
|
||||
return true, fmt.Errorf("failed to deploy %s", info.Name)
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// waitForJob is a helper that waits for a job to complete.
|
||||
//
|
||||
// This operates on an event returned from a watcher.
|
||||
func (hw *legacyWaiter) waitForJob(obj runtime.Object, name string) (bool, error) {
|
||||
o, ok := obj.(*batchv1.Job)
|
||||
if !ok {
|
||||
return true, fmt.Errorf("expected %s to be a *batch.Job, got %T", name, obj)
|
||||
}
|
||||
|
||||
for _, c := range o.Status.Conditions {
|
||||
if c.Type == batchv1.JobComplete && c.Status == "True" {
|
||||
return true, nil
|
||||
} else if c.Type == batchv1.JobFailed && c.Status == "True" {
|
||||
slog.Error("job failed", "job", name, "reason", c.Reason)
|
||||
return true, fmt.Errorf("job %s failed: %s", name, c.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Debug("job status update", "job", name, "active", o.Status.Active, "failed", o.Status.Failed, "succeeded", o.Status.Succeeded)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// waitForPodSuccess is a helper that waits for a pod to complete.
|
||||
//
|
||||
// This operates on an event returned from a watcher.
|
||||
func (hw *legacyWaiter) waitForPodSuccess(obj runtime.Object, name string) (bool, error) {
|
||||
o, ok := obj.(*corev1.Pod)
|
||||
if !ok {
|
||||
return true, fmt.Errorf("expected %s to be a *v1.Pod, got %T", name, obj)
|
||||
}
|
||||
|
||||
switch o.Status.Phase {
|
||||
case corev1.PodSucceeded:
|
||||
slog.Debug("pod succeeded", "pod", o.Name)
|
||||
return true, nil
|
||||
case corev1.PodFailed:
|
||||
slog.Error("pod failed", "pod", o.Name)
|
||||
return true, fmt.Errorf("pod %s failed", o.Name)
|
||||
case corev1.PodPending:
|
||||
slog.Debug("pod pending", "pod", o.Name)
|
||||
case corev1.PodRunning:
|
||||
slog.Debug("pod running", "pod", o.Name)
|
||||
case corev1.PodUnknown:
|
||||
slog.Debug("pod unknown", "pod", o.Name)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (hw *legacyWaiter) contextWithTimeout(timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
return contextWithTimeout(hw.ctx, timeout)
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package registry // import "helm.sh/helm/v4/pkg/registry"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
chart "helm.sh/helm/v4/pkg/chart/v2"
|
||||
"helm.sh/helm/v4/pkg/chart/v2/loader"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
)
|
||||
|
||||
var immutableOciAnnotations = []string{
|
||||
ocispec.AnnotationVersion,
|
||||
ocispec.AnnotationTitle,
|
||||
}
|
||||
|
||||
// extractChartMeta is used to extract a chart metadata from a byte array
|
||||
func extractChartMeta(chartData []byte) (*chart.Metadata, error) {
|
||||
ch, err := loader.LoadArchive(bytes.NewReader(chartData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ch.Metadata, nil
|
||||
}
|
||||
|
||||
// generateOCIAnnotations will generate OCI annotations to include within the OCI manifest
|
||||
func generateOCIAnnotations(meta *chart.Metadata, creationTime string) map[string]string {
|
||||
|
||||
// Get annotations from Chart attributes
|
||||
ociAnnotations := generateChartOCIAnnotations(meta, creationTime)
|
||||
|
||||
// Copy Chart annotations
|
||||
annotations:
|
||||
for chartAnnotationKey, chartAnnotationValue := range meta.Annotations {
|
||||
|
||||
// Avoid overriding key properties
|
||||
for _, immutableOciKey := range immutableOciAnnotations {
|
||||
if immutableOciKey == chartAnnotationKey {
|
||||
continue annotations
|
||||
}
|
||||
}
|
||||
|
||||
// Add chart annotation
|
||||
ociAnnotations[chartAnnotationKey] = chartAnnotationValue
|
||||
}
|
||||
|
||||
return ociAnnotations
|
||||
}
|
||||
|
||||
// generateChartOCIAnnotations will generate OCI annotations from the provided chart
|
||||
func generateChartOCIAnnotations(meta *chart.Metadata, creationTime string) map[string]string {
|
||||
chartOCIAnnotations := map[string]string{}
|
||||
|
||||
chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationDescription, meta.Description)
|
||||
chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationTitle, meta.Name)
|
||||
chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationVersion, meta.Version)
|
||||
chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationURL, meta.Home)
|
||||
|
||||
if len(creationTime) == 0 {
|
||||
creationTime = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationCreated, creationTime)
|
||||
|
||||
if len(meta.Sources) > 0 {
|
||||
chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationSource, meta.Sources[0])
|
||||
}
|
||||
|
||||
if len(meta.Maintainers) > 0 {
|
||||
var maintainerSb strings.Builder
|
||||
|
||||
for maintainerIdx, maintainer := range meta.Maintainers {
|
||||
|
||||
if len(maintainer.Name) > 0 {
|
||||
maintainerSb.WriteString(maintainer.Name)
|
||||
}
|
||||
|
||||
if len(maintainer.Email) > 0 {
|
||||
maintainerSb.WriteString(" (")
|
||||
maintainerSb.WriteString(maintainer.Email)
|
||||
maintainerSb.WriteString(")")
|
||||
}
|
||||
|
||||
if maintainerIdx < len(meta.Maintainers)-1 {
|
||||
maintainerSb.WriteString(", ")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationAuthors, maintainerSb.String())
|
||||
|
||||
}
|
||||
|
||||
return chartOCIAnnotations
|
||||
}
|
||||
|
||||
// addToMap takes an existing map and adds an item if the value is not empty
|
||||
func addToMap(inputMap map[string]string, newKey string, newValue string) map[string]string {
|
||||
|
||||
// Add item to map if its
|
||||
if len(strings.TrimSpace(newValue)) > 0 {
|
||||
inputMap[newKey] = newValue
|
||||
}
|
||||
|
||||
return inputMap
|
||||
}
|
||||
+928
@@ -0,0 +1,928 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package registry // import "helm.sh/helm/v4/pkg/registry"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/opencontainers/image-spec/specs-go"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2"
|
||||
"oras.land/oras-go/v2/content/memory"
|
||||
"oras.land/oras-go/v2/registry"
|
||||
"oras.land/oras-go/v2/registry/remote"
|
||||
"oras.land/oras-go/v2/registry/remote/auth"
|
||||
"oras.land/oras-go/v2/registry/remote/credentials"
|
||||
"oras.land/oras-go/v2/registry/remote/retry"
|
||||
|
||||
"helm.sh/helm/v4/internal/version"
|
||||
chart "helm.sh/helm/v4/pkg/chart/v2"
|
||||
"helm.sh/helm/v4/pkg/helmpath"
|
||||
)
|
||||
|
||||
// See https://github.com/helm/helm/issues/10166
|
||||
const registryUnderscoreMessage = `
|
||||
OCI artifact references (e.g. tags) do not support the plus sign (+). To support
|
||||
storing semantic versions, Helm adopts the convention of changing plus (+) to
|
||||
an underscore (_) in chart version tags when pushing to a registry and back to
|
||||
a plus (+) when pulling from a registry.`
|
||||
|
||||
type (
|
||||
// RemoteClient shadows the ORAS remote.Client interface
|
||||
// (hiding the ORAS type from Helm client visibility)
|
||||
// https://pkg.go.dev/oras.land/oras-go/pkg/registry/remote#Client
|
||||
RemoteClient interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// Client works with OCI-compliant registries
|
||||
Client struct {
|
||||
debug bool
|
||||
enableCache bool
|
||||
// path to repository config file e.g. ~/.docker/config.json
|
||||
credentialsFile string
|
||||
username string
|
||||
password string
|
||||
out io.Writer
|
||||
authorizer *auth.Client
|
||||
registryAuthorizer RemoteClient
|
||||
credentialsStore credentials.Store
|
||||
httpClient *http.Client
|
||||
plainHTTP bool
|
||||
}
|
||||
|
||||
// ClientOption allows specifying various settings configurable by the user for overriding the defaults
|
||||
// used when creating a new default client
|
||||
// TODO(TerryHowe): ClientOption should return error in v5
|
||||
ClientOption func(*Client)
|
||||
)
|
||||
|
||||
// NewClient returns a new registry client with config
|
||||
func NewClient(options ...ClientOption) (*Client, error) {
|
||||
client := &Client{
|
||||
out: io.Discard,
|
||||
}
|
||||
for _, option := range options {
|
||||
option(client)
|
||||
}
|
||||
if client.credentialsFile == "" {
|
||||
client.credentialsFile = helmpath.ConfigPath(CredentialsFileBasename)
|
||||
}
|
||||
if client.httpClient == nil {
|
||||
client.httpClient = &http.Client{
|
||||
Transport: NewTransport(client.debug),
|
||||
}
|
||||
}
|
||||
|
||||
storeOptions := credentials.StoreOptions{
|
||||
AllowPlaintextPut: true,
|
||||
DetectDefaultNativeStore: true,
|
||||
}
|
||||
store, err := credentials.NewStore(client.credentialsFile, storeOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dockerStore, err := credentials.NewStoreFromDocker(storeOptions)
|
||||
if err != nil {
|
||||
// should only fail if user home directory can't be determined
|
||||
client.credentialsStore = store
|
||||
} else {
|
||||
// use Helm credentials with fallback to Docker
|
||||
client.credentialsStore = credentials.NewStoreWithFallbacks(store, dockerStore)
|
||||
}
|
||||
|
||||
if client.authorizer == nil {
|
||||
authorizer := auth.Client{
|
||||
Client: client.httpClient,
|
||||
}
|
||||
authorizer.SetUserAgent(version.GetUserAgent())
|
||||
|
||||
if client.username != "" && client.password != "" {
|
||||
authorizer.Credential = func(_ context.Context, _ string) (auth.Credential, error) {
|
||||
return auth.Credential{Username: client.username, Password: client.password}, nil
|
||||
}
|
||||
} else {
|
||||
authorizer.Credential = credentials.Credential(client.credentialsStore)
|
||||
}
|
||||
|
||||
if client.enableCache {
|
||||
authorizer.Cache = auth.NewCache()
|
||||
}
|
||||
client.authorizer = &authorizer
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// Generic returns a GenericClient for low-level OCI operations
|
||||
func (c *Client) Generic() *GenericClient {
|
||||
return NewGenericClient(c)
|
||||
}
|
||||
|
||||
// ClientOptDebug returns a function that sets the debug setting on client options set
|
||||
func ClientOptDebug(debug bool) ClientOption {
|
||||
return func(client *Client) {
|
||||
client.debug = debug
|
||||
}
|
||||
}
|
||||
|
||||
// ClientOptEnableCache returns a function that sets the enableCache setting on a client options set
|
||||
func ClientOptEnableCache(enableCache bool) ClientOption {
|
||||
return func(client *Client) {
|
||||
client.enableCache = enableCache
|
||||
}
|
||||
}
|
||||
|
||||
// ClientOptBasicAuth returns a function that sets the username and password setting on client options set
|
||||
func ClientOptBasicAuth(username, password string) ClientOption {
|
||||
return func(client *Client) {
|
||||
client.username = username
|
||||
client.password = password
|
||||
}
|
||||
}
|
||||
|
||||
// ClientOptWriter returns a function that sets the writer setting on client options set
|
||||
func ClientOptWriter(out io.Writer) ClientOption {
|
||||
return func(client *Client) {
|
||||
client.out = out
|
||||
}
|
||||
}
|
||||
|
||||
// ClientOptAuthorizer returns a function that sets the authorizer setting on a client options set. This
|
||||
// can be used to override the default authorization mechanism.
|
||||
//
|
||||
// Depending on the use-case you may need to set both ClientOptAuthorizer and ClientOptRegistryAuthorizer.
|
||||
func ClientOptAuthorizer(authorizer auth.Client) ClientOption {
|
||||
return func(client *Client) {
|
||||
client.authorizer = &authorizer
|
||||
}
|
||||
}
|
||||
|
||||
// ClientOptRegistryAuthorizer returns a function that sets the registry authorizer setting on a client options set. This
|
||||
// can be used to override the default authorization mechanism.
|
||||
//
|
||||
// Depending on the use-case you may need to set both ClientOptAuthorizer and ClientOptRegistryAuthorizer.
|
||||
func ClientOptRegistryAuthorizer(registryAuthorizer RemoteClient) ClientOption {
|
||||
return func(client *Client) {
|
||||
client.registryAuthorizer = registryAuthorizer
|
||||
}
|
||||
}
|
||||
|
||||
// ClientOptCredentialsFile returns a function that sets the credentialsFile setting on a client options set
|
||||
func ClientOptCredentialsFile(credentialsFile string) ClientOption {
|
||||
return func(client *Client) {
|
||||
client.credentialsFile = credentialsFile
|
||||
}
|
||||
}
|
||||
|
||||
// ClientOptHTTPClient returns a function that sets the httpClient setting on a client options set
|
||||
func ClientOptHTTPClient(httpClient *http.Client) ClientOption {
|
||||
return func(client *Client) {
|
||||
client.httpClient = httpClient
|
||||
}
|
||||
}
|
||||
|
||||
func ClientOptPlainHTTP() ClientOption {
|
||||
return func(c *Client) {
|
||||
c.plainHTTP = true
|
||||
}
|
||||
}
|
||||
|
||||
type (
|
||||
// LoginOption allows specifying various settings on login
|
||||
LoginOption func(*loginOperation)
|
||||
|
||||
loginOperation struct {
|
||||
host string
|
||||
client *Client
|
||||
}
|
||||
)
|
||||
|
||||
// warnIfHostHasPath checks if the host contains a repository path and logs a warning if it does.
|
||||
// Returns true if the host contains a path component (i.e., contains a '/').
|
||||
func warnIfHostHasPath(host string) bool {
|
||||
if strings.Contains(host, "/") {
|
||||
registryHost := strings.Split(host, "/")[0]
|
||||
slog.Warn("registry login currently only supports registry hostname, not a repository path", "host", host, "suggested", registryHost)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Login logs into a registry
|
||||
func (c *Client) Login(host string, options ...LoginOption) error {
|
||||
for _, option := range options {
|
||||
option(&loginOperation{host, c})
|
||||
}
|
||||
|
||||
warnIfHostHasPath(host)
|
||||
|
||||
reg, err := remote.NewRegistry(host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reg.PlainHTTP = c.plainHTTP
|
||||
cred := auth.Credential{Username: c.username, Password: c.password}
|
||||
c.authorizer.ForceAttemptOAuth2 = true
|
||||
reg.Client = c.authorizer
|
||||
|
||||
ctx := context.Background()
|
||||
if err := reg.Ping(ctx); err != nil {
|
||||
c.authorizer.ForceAttemptOAuth2 = false
|
||||
if err := reg.Ping(ctx); err != nil {
|
||||
return fmt.Errorf("authenticating to %q: %w", host, err)
|
||||
}
|
||||
}
|
||||
// Always restore to false after probing, to avoid forcing POST to token endpoints like GHCR.
|
||||
c.authorizer.ForceAttemptOAuth2 = false
|
||||
|
||||
key := credentials.ServerAddressFromRegistry(host)
|
||||
key = credentials.ServerAddressFromHostname(key)
|
||||
if err := c.credentialsStore.Put(ctx, key, cred); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(c.out, "Login Succeeded")
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoginOptBasicAuth returns a function that sets the username/password settings on login
|
||||
func LoginOptBasicAuth(username string, password string) LoginOption {
|
||||
return func(o *loginOperation) {
|
||||
o.client.username = username
|
||||
o.client.password = password
|
||||
o.client.authorizer.Credential = auth.StaticCredential(o.host, auth.Credential{Username: username, Password: password})
|
||||
}
|
||||
}
|
||||
|
||||
// LoginOptPlainText returns a function that allows plaintext (HTTP) login
|
||||
func LoginOptPlainText(isPlainText bool) LoginOption {
|
||||
return func(o *loginOperation) {
|
||||
o.client.plainHTTP = isPlainText
|
||||
}
|
||||
}
|
||||
|
||||
func ensureTLSConfig(client *auth.Client, setConfig *tls.Config) (*tls.Config, error) {
|
||||
var transport *http.Transport
|
||||
|
||||
switch t := client.Client.Transport.(type) {
|
||||
case *http.Transport:
|
||||
transport = t
|
||||
case *retry.Transport:
|
||||
switch t := t.Base.(type) {
|
||||
case *http.Transport:
|
||||
transport = t
|
||||
case *LoggingTransport:
|
||||
switch t := t.RoundTripper.(type) {
|
||||
case *http.Transport:
|
||||
transport = t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if transport == nil {
|
||||
// we don't know how to access the http.Transport, most likely the
|
||||
// auth.Client.Client was provided by API user
|
||||
return nil, fmt.Errorf("unable to access TLS client configuration, the provided HTTP Transport is not supported, given: %T", client.Client.Transport)
|
||||
}
|
||||
|
||||
switch {
|
||||
case setConfig != nil:
|
||||
transport.TLSClientConfig = setConfig
|
||||
case transport.TLSClientConfig == nil:
|
||||
transport.TLSClientConfig = &tls.Config{}
|
||||
}
|
||||
|
||||
return transport.TLSClientConfig, nil
|
||||
}
|
||||
|
||||
// LoginOptInsecure returns a function that sets the insecure setting on login
|
||||
func LoginOptInsecure(insecure bool) LoginOption {
|
||||
return func(o *loginOperation) {
|
||||
tlsConfig, err := ensureTLSConfig(o.client.authorizer, nil)
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
tlsConfig.InsecureSkipVerify = insecure
|
||||
}
|
||||
}
|
||||
|
||||
// LoginOptTLSClientConfig returns a function that sets the TLS settings on login.
|
||||
func LoginOptTLSClientConfig(certFile, keyFile, caFile string) LoginOption {
|
||||
return func(o *loginOperation) {
|
||||
if (certFile == "" || keyFile == "") && caFile == "" {
|
||||
return
|
||||
}
|
||||
tlsConfig, err := ensureTLSConfig(o.client.authorizer, nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if certFile != "" && keyFile != "" {
|
||||
authCert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
tlsConfig.Certificates = []tls.Certificate{authCert}
|
||||
}
|
||||
|
||||
if caFile != "" {
|
||||
certPool := x509.NewCertPool()
|
||||
ca, err := os.ReadFile(caFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if !certPool.AppendCertsFromPEM(ca) {
|
||||
panic(fmt.Errorf("unable to parse CA file: %q", caFile))
|
||||
}
|
||||
tlsConfig.RootCAs = certPool
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LoginOptTLSClientConfigFromConfig returns a function that sets the TLS settings on login
|
||||
// receiving the configuration in memory rather than from files.
|
||||
func LoginOptTLSClientConfigFromConfig(conf *tls.Config) LoginOption {
|
||||
return func(o *loginOperation) {
|
||||
_, err := ensureTLSConfig(o.client.authorizer, conf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type (
|
||||
// LogoutOption allows specifying various settings on logout
|
||||
LogoutOption func(*logoutOperation)
|
||||
|
||||
logoutOperation struct{}
|
||||
)
|
||||
|
||||
// Logout logs out of a registry
|
||||
func (c *Client) Logout(host string, opts ...LogoutOption) error {
|
||||
operation := &logoutOperation{}
|
||||
for _, opt := range opts {
|
||||
opt(operation)
|
||||
}
|
||||
|
||||
if err := credentials.Logout(context.Background(), c.credentialsStore, host); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Fprintf(c.out, "Removing login credentials for %s\n", host)
|
||||
return nil
|
||||
}
|
||||
|
||||
type (
|
||||
// PullOption allows specifying various settings on pull
|
||||
PullOption func(*pullOperation)
|
||||
|
||||
// PullResult is the result returned upon successful pull.
|
||||
PullResult struct {
|
||||
Manifest *DescriptorPullSummary `json:"manifest"`
|
||||
Config *DescriptorPullSummary `json:"config"`
|
||||
Chart *DescriptorPullSummaryWithMeta `json:"chart"`
|
||||
Prov *DescriptorPullSummary `json:"prov"`
|
||||
Ref string `json:"ref"`
|
||||
}
|
||||
|
||||
DescriptorPullSummary struct {
|
||||
Data []byte `json:"-"`
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
DescriptorPullSummaryWithMeta struct {
|
||||
DescriptorPullSummary
|
||||
Meta *chart.Metadata `json:"meta"`
|
||||
}
|
||||
|
||||
pullOperation struct {
|
||||
withChart bool
|
||||
withProv bool
|
||||
ignoreMissingProv bool
|
||||
}
|
||||
)
|
||||
|
||||
// processChartPull handles chart-specific processing of a generic pull result
|
||||
func (c *Client) processChartPull(genericResult *GenericPullResult, operation *pullOperation) (*PullResult, error) {
|
||||
var err error
|
||||
|
||||
// Chart-specific validation
|
||||
minNumDescriptors := 1 // 1 for the config
|
||||
if operation.withChart {
|
||||
minNumDescriptors++
|
||||
}
|
||||
if operation.withProv && !operation.ignoreMissingProv {
|
||||
minNumDescriptors++
|
||||
}
|
||||
|
||||
numDescriptors := len(genericResult.Descriptors)
|
||||
if numDescriptors < minNumDescriptors {
|
||||
return nil, fmt.Errorf("manifest does not contain minimum number of descriptors (%d), descriptors found: %d",
|
||||
minNumDescriptors, numDescriptors)
|
||||
}
|
||||
|
||||
// Find chart-specific descriptors
|
||||
var configDescriptor *ocispec.Descriptor
|
||||
var chartDescriptor *ocispec.Descriptor
|
||||
var provDescriptor *ocispec.Descriptor
|
||||
|
||||
for _, descriptor := range genericResult.Descriptors {
|
||||
d := descriptor
|
||||
switch d.MediaType {
|
||||
case ConfigMediaType:
|
||||
configDescriptor = &d
|
||||
case ChartLayerMediaType:
|
||||
chartDescriptor = &d
|
||||
case ProvLayerMediaType:
|
||||
provDescriptor = &d
|
||||
case LegacyChartLayerMediaType:
|
||||
chartDescriptor = &d
|
||||
_, _ = fmt.Fprintf(c.out, "Warning: chart media type %s is deprecated\n", LegacyChartLayerMediaType)
|
||||
}
|
||||
}
|
||||
|
||||
// Chart-specific validation
|
||||
if configDescriptor == nil {
|
||||
return nil, fmt.Errorf("could not load config with mediatype %s", ConfigMediaType)
|
||||
}
|
||||
if operation.withChart && chartDescriptor == nil {
|
||||
return nil, fmt.Errorf("manifest does not contain a layer with mediatype %s",
|
||||
ChartLayerMediaType)
|
||||
}
|
||||
|
||||
var provMissing bool
|
||||
if operation.withProv && provDescriptor == nil {
|
||||
if operation.ignoreMissingProv {
|
||||
provMissing = true
|
||||
} else {
|
||||
return nil, fmt.Errorf("manifest does not contain a layer with mediatype %s",
|
||||
ProvLayerMediaType)
|
||||
}
|
||||
}
|
||||
|
||||
// Build chart-specific result
|
||||
result := &PullResult{
|
||||
Manifest: &DescriptorPullSummary{
|
||||
Digest: genericResult.Manifest.Digest.String(),
|
||||
Size: genericResult.Manifest.Size,
|
||||
},
|
||||
Config: &DescriptorPullSummary{
|
||||
Digest: configDescriptor.Digest.String(),
|
||||
Size: configDescriptor.Size,
|
||||
},
|
||||
Chart: &DescriptorPullSummaryWithMeta{},
|
||||
Prov: &DescriptorPullSummary{},
|
||||
Ref: genericResult.Ref,
|
||||
}
|
||||
|
||||
// Fetch data using generic client
|
||||
genericClient := c.Generic()
|
||||
|
||||
result.Manifest.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, genericResult.Manifest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", genericResult.Manifest.Digest, err)
|
||||
}
|
||||
|
||||
result.Config.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *configDescriptor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", configDescriptor.Digest, err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(result.Config.Data, &result.Chart.Meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if operation.withChart {
|
||||
result.Chart.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *chartDescriptor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", chartDescriptor.Digest, err)
|
||||
}
|
||||
result.Chart.Digest = chartDescriptor.Digest.String()
|
||||
result.Chart.Size = chartDescriptor.Size
|
||||
}
|
||||
|
||||
if operation.withProv && !provMissing {
|
||||
result.Prov.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *provDescriptor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", provDescriptor.Digest, err)
|
||||
}
|
||||
result.Prov.Digest = provDescriptor.Digest.String()
|
||||
result.Prov.Size = provDescriptor.Size
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(c.out, "Pulled: %s\n", result.Ref)
|
||||
_, _ = fmt.Fprintf(c.out, "Digest: %s\n", result.Manifest.Digest)
|
||||
|
||||
if strings.Contains(result.Ref, "_") {
|
||||
_, _ = fmt.Fprintf(c.out, "%s contains an underscore.\n", result.Ref)
|
||||
_, _ = fmt.Fprint(c.out, registryUnderscoreMessage+"\n")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Pull downloads a chart from a registry
|
||||
func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) {
|
||||
operation := &pullOperation{
|
||||
withChart: true, // By default, always download the chart layer
|
||||
}
|
||||
for _, option := range options {
|
||||
option(operation)
|
||||
}
|
||||
if !operation.withChart && !operation.withProv {
|
||||
return nil, errors.New(
|
||||
"must specify at least one layer to pull (chart/prov)")
|
||||
}
|
||||
|
||||
// Build allowed media types for chart pull
|
||||
allowedMediaTypes := []string{
|
||||
ocispec.MediaTypeImageIndex,
|
||||
ocispec.MediaTypeImageManifest,
|
||||
ConfigMediaType,
|
||||
}
|
||||
if operation.withChart {
|
||||
allowedMediaTypes = append(allowedMediaTypes, ChartLayerMediaType, LegacyChartLayerMediaType)
|
||||
}
|
||||
if operation.withProv {
|
||||
allowedMediaTypes = append(allowedMediaTypes, ProvLayerMediaType)
|
||||
}
|
||||
|
||||
// Use generic client for the pull operation
|
||||
genericClient := c.Generic()
|
||||
genericResult, err := genericClient.PullGeneric(ref, GenericPullOptions{
|
||||
AllowedMediaTypes: allowedMediaTypes,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Process the result with chart-specific logic
|
||||
return c.processChartPull(genericResult, operation)
|
||||
}
|
||||
|
||||
// PullOptWithChart returns a function that sets the withChart setting on pull
|
||||
func PullOptWithChart(withChart bool) PullOption {
|
||||
return func(operation *pullOperation) {
|
||||
operation.withChart = withChart
|
||||
}
|
||||
}
|
||||
|
||||
// PullOptWithProv returns a function that sets the withProv setting on pull
|
||||
func PullOptWithProv(withProv bool) PullOption {
|
||||
return func(operation *pullOperation) {
|
||||
operation.withProv = withProv
|
||||
}
|
||||
}
|
||||
|
||||
// PullOptIgnoreMissingProv returns a function that sets the ignoreMissingProv setting on pull
|
||||
func PullOptIgnoreMissingProv(ignoreMissingProv bool) PullOption {
|
||||
return func(operation *pullOperation) {
|
||||
operation.ignoreMissingProv = ignoreMissingProv
|
||||
}
|
||||
}
|
||||
|
||||
type (
|
||||
// PushOption allows specifying various settings on push
|
||||
PushOption func(*pushOperation)
|
||||
|
||||
// PushResult is the result returned upon successful push.
|
||||
PushResult struct {
|
||||
Manifest *descriptorPushSummary `json:"manifest"`
|
||||
Config *descriptorPushSummary `json:"config"`
|
||||
Chart *descriptorPushSummaryWithMeta `json:"chart"`
|
||||
Prov *descriptorPushSummary `json:"prov"`
|
||||
Ref string `json:"ref"`
|
||||
}
|
||||
|
||||
descriptorPushSummary struct {
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
descriptorPushSummaryWithMeta struct {
|
||||
descriptorPushSummary
|
||||
Meta *chart.Metadata `json:"meta"`
|
||||
}
|
||||
|
||||
pushOperation struct {
|
||||
provData []byte
|
||||
strictMode bool
|
||||
creationTime string
|
||||
}
|
||||
)
|
||||
|
||||
// Push uploads a chart to a registry.
|
||||
func (c *Client) Push(data []byte, ref string, options ...PushOption) (*PushResult, error) {
|
||||
parsedRef, err := newReference(ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
operation := &pushOperation{
|
||||
strictMode: true, // By default, enable strict mode
|
||||
}
|
||||
for _, option := range options {
|
||||
option(operation)
|
||||
}
|
||||
meta, err := extractChartMeta(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if operation.strictMode {
|
||||
if !strings.HasSuffix(ref, fmt.Sprintf("/%s:%s", meta.Name, meta.Version)) {
|
||||
return nil, errors.New(
|
||||
"strict mode enabled, ref basename and tag must match the chart name and version")
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
memoryStore := memory.New()
|
||||
chartDescriptor, err := oras.PushBytes(ctx, memoryStore, ChartLayerMediaType, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
configData, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
configDescriptor, err := oras.PushBytes(ctx, memoryStore, ConfigMediaType, configData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
layers := []ocispec.Descriptor{chartDescriptor}
|
||||
var provDescriptor ocispec.Descriptor
|
||||
if operation.provData != nil {
|
||||
provDescriptor, err = oras.PushBytes(ctx, memoryStore, ProvLayerMediaType, operation.provData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
layers = append(layers, provDescriptor)
|
||||
}
|
||||
|
||||
// sort layers for determinism, similar to how ORAS v1 does it
|
||||
sort.Slice(layers, func(i, j int) bool {
|
||||
return layers[i].Digest < layers[j].Digest
|
||||
})
|
||||
|
||||
ociAnnotations := generateOCIAnnotations(meta, operation.creationTime)
|
||||
|
||||
manifestDescriptor, err := c.tagManifest(ctx, memoryStore, configDescriptor,
|
||||
layers, ociAnnotations, parsedRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
repository, err := remote.NewRepository(parsedRef.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repository.PlainHTTP = c.plainHTTP
|
||||
repository.Client = c.authorizer
|
||||
|
||||
manifestDescriptor, err = oras.ExtendedCopy(ctx, memoryStore, parsedRef.String(), repository, parsedRef.String(), oras.DefaultExtendedCopyOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
chartSummary := &descriptorPushSummaryWithMeta{
|
||||
Meta: meta,
|
||||
}
|
||||
chartSummary.Digest = chartDescriptor.Digest.String()
|
||||
chartSummary.Size = chartDescriptor.Size
|
||||
result := &PushResult{
|
||||
Manifest: &descriptorPushSummary{
|
||||
Digest: manifestDescriptor.Digest.String(),
|
||||
Size: manifestDescriptor.Size,
|
||||
},
|
||||
Config: &descriptorPushSummary{
|
||||
Digest: configDescriptor.Digest.String(),
|
||||
Size: configDescriptor.Size,
|
||||
},
|
||||
Chart: chartSummary,
|
||||
Prov: &descriptorPushSummary{}, // prevent nil references
|
||||
Ref: parsedRef.String(),
|
||||
}
|
||||
if operation.provData != nil {
|
||||
result.Prov = &descriptorPushSummary{
|
||||
Digest: provDescriptor.Digest.String(),
|
||||
Size: provDescriptor.Size,
|
||||
}
|
||||
}
|
||||
_, _ = fmt.Fprintf(c.out, "Pushed: %s\n", result.Ref)
|
||||
_, _ = fmt.Fprintf(c.out, "Digest: %s\n", result.Manifest.Digest)
|
||||
if strings.Contains(parsedRef.orasReference.Reference, "_") {
|
||||
_, _ = fmt.Fprintf(c.out, "%s contains an underscore.\n", result.Ref)
|
||||
_, _ = fmt.Fprint(c.out, registryUnderscoreMessage+"\n")
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
// PushOptProvData returns a function that sets the prov bytes setting on push
|
||||
func PushOptProvData(provData []byte) PushOption {
|
||||
return func(operation *pushOperation) {
|
||||
operation.provData = provData
|
||||
}
|
||||
}
|
||||
|
||||
// PushOptStrictMode returns a function that sets the strictMode setting on push
|
||||
func PushOptStrictMode(strictMode bool) PushOption {
|
||||
return func(operation *pushOperation) {
|
||||
operation.strictMode = strictMode
|
||||
}
|
||||
}
|
||||
|
||||
// PushOptCreationTime returns a function that sets the creation time
|
||||
func PushOptCreationTime(creationTime string) PushOption {
|
||||
return func(operation *pushOperation) {
|
||||
operation.creationTime = creationTime
|
||||
}
|
||||
}
|
||||
|
||||
// Tags provides a sorted list all semver compliant tags for a given repository
|
||||
func (c *Client) Tags(ref string) ([]string, error) {
|
||||
parsedReference, err := registry.ParseReference(ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
repository, err := remote.NewRepository(parsedReference.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repository.PlainHTTP = c.plainHTTP
|
||||
repository.Client = c.authorizer
|
||||
|
||||
var tagVersions []*semver.Version
|
||||
err = repository.Tags(ctx, "", func(tags []string) error {
|
||||
for _, tag := range tags {
|
||||
// Change underscore (_) back to plus (+) for Helm
|
||||
// See https://github.com/helm/helm/issues/10166
|
||||
tagVersion, err := semver.StrictNewVersion(strings.ReplaceAll(tag, "_", "+"))
|
||||
if err == nil {
|
||||
tagVersions = append(tagVersions, tagVersion)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Sort the collection
|
||||
sort.Sort(sort.Reverse(semver.Collection(tagVersions)))
|
||||
|
||||
tags := make([]string, len(tagVersions))
|
||||
|
||||
for iTv, tv := range tagVersions {
|
||||
tags[iTv] = tv.String()
|
||||
}
|
||||
|
||||
return tags, nil
|
||||
|
||||
}
|
||||
|
||||
// Resolve a reference to a descriptor.
|
||||
func (c *Client) Resolve(ref string) (desc ocispec.Descriptor, err error) {
|
||||
remoteRepository, err := remote.NewRepository(ref)
|
||||
if err != nil {
|
||||
return desc, err
|
||||
}
|
||||
remoteRepository.PlainHTTP = c.plainHTTP
|
||||
remoteRepository.Client = c.authorizer
|
||||
|
||||
parsedReference, err := newReference(ref)
|
||||
if err != nil {
|
||||
return desc, err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
parsedString := parsedReference.String()
|
||||
return remoteRepository.Resolve(ctx, parsedString)
|
||||
}
|
||||
|
||||
// ValidateReference for path and version
|
||||
func (c *Client) ValidateReference(ref, version string, u *url.URL) (string, *url.URL, error) {
|
||||
var tag string
|
||||
|
||||
registryReference, err := newReference(u.Host + u.Path)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if version == "" {
|
||||
// Use OCI URI tag as default
|
||||
version = registryReference.Tag
|
||||
} else {
|
||||
if registryReference.Tag != "" && registryReference.Tag != version {
|
||||
return "", nil, fmt.Errorf("chart reference and version mismatch: %s is not %s", version, registryReference.Tag)
|
||||
}
|
||||
}
|
||||
|
||||
if registryReference.Digest != "" {
|
||||
if version == "" {
|
||||
// Install by digest only
|
||||
return "", u, nil
|
||||
}
|
||||
u.Path = fmt.Sprintf("%s@%s", registryReference.Repository, registryReference.Digest)
|
||||
|
||||
// Validate the tag if it was specified
|
||||
path := registryReference.Registry + "/" + registryReference.Repository + ":" + version
|
||||
desc, err := c.Resolve(path)
|
||||
if err != nil {
|
||||
// The resource does not have to be tagged when digest is specified
|
||||
return "", u, nil
|
||||
}
|
||||
if desc.Digest.String() != registryReference.Digest {
|
||||
return "", nil, fmt.Errorf("chart reference digest mismatch: %s is not %s", desc.Digest.String(), registryReference.Digest)
|
||||
}
|
||||
return registryReference.Digest, u, nil
|
||||
}
|
||||
|
||||
// Evaluate whether an explicit version has been provided. Otherwise, determine version to use
|
||||
_, errSemVer := semver.NewVersion(version)
|
||||
if errSemVer == nil {
|
||||
tag = version
|
||||
} else {
|
||||
// Retrieve list of repository tags
|
||||
tags, err := c.Tags(strings.TrimPrefix(ref, fmt.Sprintf("%s://", OCIScheme)))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(tags) == 0 {
|
||||
return "", nil, fmt.Errorf("unable to locate any tags in provided repository: %s", ref)
|
||||
}
|
||||
|
||||
// Determine if version provided
|
||||
// If empty, try to get the highest available tag
|
||||
// If exact version, try to find it
|
||||
// If semver constraint string, try to find a match
|
||||
tag, err = GetTagMatchingVersionOrConstraint(tags, version)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
}
|
||||
|
||||
u.Path = fmt.Sprintf("%s:%s", registryReference.Repository, tag)
|
||||
// desc, err := c.Resolve(u.Path)
|
||||
|
||||
return "", u, err
|
||||
}
|
||||
|
||||
// tagManifest prepares and tags a manifest in memory storage
|
||||
func (c *Client) tagManifest(ctx context.Context, memoryStore *memory.Store,
|
||||
configDescriptor ocispec.Descriptor, layers []ocispec.Descriptor,
|
||||
ociAnnotations map[string]string, parsedRef reference) (ocispec.Descriptor, error) {
|
||||
|
||||
manifest := ocispec.Manifest{
|
||||
Versioned: specs.Versioned{SchemaVersion: 2},
|
||||
Config: configDescriptor,
|
||||
Layers: layers,
|
||||
Annotations: ociAnnotations,
|
||||
}
|
||||
|
||||
manifestData, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
return ocispec.Descriptor{}, err
|
||||
}
|
||||
|
||||
return oras.TagBytes(ctx, memoryStore, ocispec.MediaTypeImageManifest,
|
||||
manifestData, parsedRef.String())
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package registry // import "helm.sh/helm/v4/pkg/registry"
|
||||
|
||||
const (
|
||||
// OCIScheme is the URL scheme for OCI-based requests
|
||||
OCIScheme = "oci"
|
||||
|
||||
// CredentialsFileBasename is the filename for auth credentials file
|
||||
CredentialsFileBasename = "registry/config.json"
|
||||
|
||||
// ConfigMediaType is the reserved media type for the Helm chart manifest config
|
||||
ConfigMediaType = "application/vnd.cncf.helm.config.v1+json"
|
||||
|
||||
// ChartLayerMediaType is the reserved media type for Helm chart package content
|
||||
ChartLayerMediaType = "application/vnd.cncf.helm.chart.content.v1.tar+gzip"
|
||||
|
||||
// ProvLayerMediaType is the reserved media type for Helm chart provenance files
|
||||
ProvLayerMediaType = "application/vnd.cncf.helm.chart.provenance.v1.prov"
|
||||
|
||||
// LegacyChartLayerMediaType is the legacy reserved media type for Helm chart package content.
|
||||
LegacyChartLayerMediaType = "application/tar+gzip"
|
||||
)
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2"
|
||||
"oras.land/oras-go/v2/content"
|
||||
"oras.land/oras-go/v2/content/memory"
|
||||
"oras.land/oras-go/v2/registry/remote"
|
||||
"oras.land/oras-go/v2/registry/remote/auth"
|
||||
"oras.land/oras-go/v2/registry/remote/credentials"
|
||||
)
|
||||
|
||||
// GenericClient provides low-level OCI operations without artifact-specific assumptions
|
||||
type GenericClient struct {
|
||||
debug bool
|
||||
enableCache bool
|
||||
credentialsFile string
|
||||
username string
|
||||
password string
|
||||
out io.Writer
|
||||
authorizer *auth.Client
|
||||
registryAuthorizer RemoteClient
|
||||
credentialsStore credentials.Store
|
||||
httpClient *http.Client
|
||||
plainHTTP bool
|
||||
}
|
||||
|
||||
// GenericPullOptions configures a generic pull operation
|
||||
type GenericPullOptions struct {
|
||||
// MediaTypes to include in the pull (empty means all)
|
||||
AllowedMediaTypes []string
|
||||
// Skip descriptors with these media types
|
||||
SkipMediaTypes []string
|
||||
// Custom PreCopy function for filtering
|
||||
PreCopy func(context.Context, ocispec.Descriptor) error
|
||||
}
|
||||
|
||||
// GenericPullResult contains the result of a generic pull operation
|
||||
type GenericPullResult struct {
|
||||
Manifest ocispec.Descriptor
|
||||
Descriptors []ocispec.Descriptor
|
||||
MemoryStore *memory.Store
|
||||
Ref string
|
||||
}
|
||||
|
||||
// NewGenericClient creates a new generic OCI client from an existing Client
|
||||
func NewGenericClient(client *Client) *GenericClient {
|
||||
return &GenericClient{
|
||||
debug: client.debug,
|
||||
enableCache: client.enableCache,
|
||||
credentialsFile: client.credentialsFile,
|
||||
username: client.username,
|
||||
password: client.password,
|
||||
out: client.out,
|
||||
authorizer: client.authorizer,
|
||||
registryAuthorizer: client.registryAuthorizer,
|
||||
credentialsStore: client.credentialsStore,
|
||||
httpClient: client.httpClient,
|
||||
plainHTTP: client.plainHTTP,
|
||||
}
|
||||
}
|
||||
|
||||
// PullGeneric performs a generic OCI pull without artifact-specific assumptions
|
||||
func (c *GenericClient) PullGeneric(ref string, options GenericPullOptions) (*GenericPullResult, error) {
|
||||
parsedRef, err := newReference(ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
memoryStore := memory.New()
|
||||
var descriptors []ocispec.Descriptor
|
||||
|
||||
// Set up a repository with authentication and configuration
|
||||
repository, err := remote.NewRepository(parsedRef.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repository.PlainHTTP = c.plainHTTP
|
||||
repository.Client = c.authorizer
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Prepare allowed media types for filtering
|
||||
var allowedMediaTypes []string
|
||||
if len(options.AllowedMediaTypes) > 0 {
|
||||
allowedMediaTypes = make([]string, len(options.AllowedMediaTypes))
|
||||
copy(allowedMediaTypes, options.AllowedMediaTypes)
|
||||
sort.Strings(allowedMediaTypes)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
manifest, err := oras.Copy(ctx, repository, parsedRef.String(), memoryStore, "", oras.CopyOptions{
|
||||
CopyGraphOptions: oras.CopyGraphOptions{
|
||||
PreCopy: func(ctx context.Context, desc ocispec.Descriptor) error {
|
||||
// Apply a custom PreCopy function if provided
|
||||
if options.PreCopy != nil {
|
||||
if err := options.PreCopy(ctx, desc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
mediaType := desc.MediaType
|
||||
|
||||
// Skip media types if specified
|
||||
if slices.Contains(options.SkipMediaTypes, mediaType) {
|
||||
return oras.SkipNode
|
||||
}
|
||||
|
||||
// Filter by allowed media types if specified
|
||||
if len(allowedMediaTypes) > 0 {
|
||||
if i := sort.SearchStrings(allowedMediaTypes, mediaType); i >= len(allowedMediaTypes) || allowedMediaTypes[i] != mediaType {
|
||||
return oras.SkipNode
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
descriptors = append(descriptors, desc)
|
||||
mu.Unlock()
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &GenericPullResult{
|
||||
Manifest: manifest,
|
||||
Descriptors: descriptors,
|
||||
MemoryStore: memoryStore,
|
||||
Ref: parsedRef.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetDescriptorData retrieves the data for a specific descriptor
|
||||
func (c *GenericClient) GetDescriptorData(store *memory.Store, desc ocispec.Descriptor) ([]byte, error) {
|
||||
return content.FetchAll(context.Background(), store, desc)
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
)
|
||||
|
||||
// Plugin-specific constants
|
||||
const (
|
||||
// PluginArtifactType is the artifact type for Helm plugins
|
||||
PluginArtifactType = "application/vnd.helm.plugin.v1+json"
|
||||
)
|
||||
|
||||
// PluginPullOptions configures a plugin pull operation
|
||||
type PluginPullOptions struct {
|
||||
// PluginName specifies the expected plugin name for layer validation
|
||||
PluginName string
|
||||
}
|
||||
|
||||
// PluginPullResult contains the result of a plugin pull operation
|
||||
type PluginPullResult struct {
|
||||
Manifest ocispec.Descriptor
|
||||
PluginData []byte
|
||||
Prov struct {
|
||||
Data []byte
|
||||
}
|
||||
Ref string
|
||||
PluginName string
|
||||
}
|
||||
|
||||
// PullPlugin downloads a plugin from an OCI registry using artifact type
|
||||
func (c *Client) PullPlugin(ref string, pluginName string, options ...PluginPullOption) (*PluginPullResult, error) {
|
||||
operation := &pluginPullOperation{
|
||||
pluginName: pluginName,
|
||||
}
|
||||
for _, option := range options {
|
||||
option(operation)
|
||||
}
|
||||
|
||||
// Use generic client for the pull operation with artifact type filtering
|
||||
genericClient := c.Generic()
|
||||
genericResult, err := genericClient.PullGeneric(ref, GenericPullOptions{
|
||||
// Allow manifests and all layer types - we'll validate artifact type after download
|
||||
AllowedMediaTypes: []string{
|
||||
ocispec.MediaTypeImageManifest,
|
||||
"application/vnd.oci.image.layer.v1.tar",
|
||||
"application/vnd.oci.image.layer.v1.tar+gzip",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Process the result with plugin-specific logic
|
||||
return c.processPluginPull(genericResult, operation.pluginName)
|
||||
}
|
||||
|
||||
// processPluginPull handles plugin-specific processing of a generic pull result using artifact type
|
||||
func (c *Client) processPluginPull(genericResult *GenericPullResult, pluginName string) (*PluginPullResult, error) {
|
||||
// First validate that this is actually a plugin artifact
|
||||
manifestData, err := c.Generic().GetDescriptorData(genericResult.MemoryStore, genericResult.Manifest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve manifest: %w", err)
|
||||
}
|
||||
|
||||
// Parse the manifest to check artifact type
|
||||
var manifest ocispec.Manifest
|
||||
if err := json.Unmarshal(manifestData, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("unable to parse manifest: %w", err)
|
||||
}
|
||||
|
||||
// Validate artifact type (for OCI v1.1+ manifests)
|
||||
if manifest.ArtifactType != "" && manifest.ArtifactType != PluginArtifactType {
|
||||
return nil, fmt.Errorf("expected artifact type %s, got %s", PluginArtifactType, manifest.ArtifactType)
|
||||
}
|
||||
|
||||
// For backwards compatibility, also check config media type if no artifact type
|
||||
if manifest.ArtifactType == "" && manifest.Config.MediaType != PluginArtifactType {
|
||||
return nil, fmt.Errorf("expected config media type %s for legacy compatibility, got %s", PluginArtifactType, manifest.Config.MediaType)
|
||||
}
|
||||
|
||||
// Find the plugin tarball and optional provenance using NAME-VERSION.tgz format
|
||||
var pluginDescriptor *ocispec.Descriptor
|
||||
var provenanceDescriptor *ocispec.Descriptor
|
||||
var foundProvenanceName string
|
||||
|
||||
// Look for layers with the expected titles/annotations
|
||||
for _, layer := range manifest.Layers {
|
||||
d := layer
|
||||
// Check for title annotation
|
||||
if title, exists := d.Annotations[ocispec.AnnotationTitle]; exists {
|
||||
// Check if this looks like a plugin tarball: {pluginName}-{version}.tgz
|
||||
if pluginDescriptor == nil && strings.HasPrefix(title, pluginName+"-") && strings.HasSuffix(title, ".tgz") {
|
||||
pluginDescriptor = &d
|
||||
}
|
||||
// Check if this looks like a plugin provenance: {pluginName}-{version}.tgz.prov
|
||||
if provenanceDescriptor == nil && strings.HasPrefix(title, pluginName+"-") && strings.HasSuffix(title, ".tgz.prov") {
|
||||
provenanceDescriptor = &d
|
||||
foundProvenanceName = title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin tarball is required
|
||||
if pluginDescriptor == nil {
|
||||
return nil, fmt.Errorf("required layer matching pattern %s-VERSION.tgz not found in manifest", pluginName)
|
||||
}
|
||||
|
||||
// Build plugin-specific result
|
||||
result := &PluginPullResult{
|
||||
Manifest: genericResult.Manifest,
|
||||
Ref: genericResult.Ref,
|
||||
PluginName: pluginName,
|
||||
}
|
||||
|
||||
// Fetch plugin data using generic client
|
||||
genericClient := c.Generic()
|
||||
result.PluginData, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *pluginDescriptor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve plugin data with digest %s: %w", pluginDescriptor.Digest, err)
|
||||
}
|
||||
|
||||
// Fetch provenance data if available
|
||||
if provenanceDescriptor != nil {
|
||||
result.Prov.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *provenanceDescriptor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve provenance data with digest %s: %w", provenanceDescriptor.Digest, err)
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(c.out, "Pulled plugin: %s\n", result.Ref)
|
||||
_, _ = fmt.Fprintf(c.out, "Digest: %s\n", result.Manifest.Digest)
|
||||
if result.Prov.Data != nil {
|
||||
_, _ = fmt.Fprintf(c.out, "Provenance: %s\n", foundProvenanceName)
|
||||
}
|
||||
|
||||
if strings.Contains(result.Ref, "_") {
|
||||
_, _ = fmt.Fprintf(c.out, "%s contains an underscore.\n", result.Ref)
|
||||
_, _ = fmt.Fprint(c.out, registryUnderscoreMessage+"\n")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Plugin pull operation types and options
|
||||
type (
|
||||
pluginPullOperation struct {
|
||||
pluginName string
|
||||
withProv bool
|
||||
}
|
||||
|
||||
// PluginPullOption allows customizing plugin pull operations
|
||||
PluginPullOption func(*pluginPullOperation)
|
||||
)
|
||||
|
||||
// PluginPullOptWithPluginName sets the plugin name for validation
|
||||
func PluginPullOptWithPluginName(name string) PluginPullOption {
|
||||
return func(operation *pluginPullOperation) {
|
||||
operation.pluginName = name
|
||||
}
|
||||
}
|
||||
|
||||
// GetPluginName extracts the plugin name from an OCI reference using proper reference parsing
|
||||
func GetPluginName(source string) (string, error) {
|
||||
ref, err := newReference(source)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid OCI reference: %w", err)
|
||||
}
|
||||
|
||||
// Extract plugin name from the repository path
|
||||
// e.g., "ghcr.io/user/plugin-name:v1.0.0" -> Repository: "user/plugin-name"
|
||||
repository := ref.Repository
|
||||
if repository == "" {
|
||||
return "", fmt.Errorf("invalid OCI reference: missing repository")
|
||||
}
|
||||
|
||||
// Get the last part of the repository path as the plugin name
|
||||
parts := strings.Split(repository, "/")
|
||||
pluginName := parts[len(parts)-1]
|
||||
|
||||
if pluginName == "" {
|
||||
return "", fmt.Errorf("invalid OCI reference: cannot determine plugin name from repository %s", repository)
|
||||
}
|
||||
|
||||
return pluginName, nil
|
||||
}
|
||||
|
||||
// PullPluginOptWithProv configures the pull to fetch provenance data
|
||||
func PullPluginOptWithProv(withProv bool) PluginPullOption {
|
||||
return func(operation *pluginPullOperation) {
|
||||
operation.withProv = withProv
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"oras.land/oras-go/v2/registry"
|
||||
)
|
||||
|
||||
type reference struct {
|
||||
orasReference registry.Reference
|
||||
Registry string
|
||||
Repository string
|
||||
Tag string
|
||||
Digest string
|
||||
}
|
||||
|
||||
// newReference will parse and validate the reference, and clean tags when
|
||||
// applicable tags are only cleaned when plus (+) signs are present and are
|
||||
// converted to underscores (_) before pushing
|
||||
// See https://github.com/helm/helm/issues/10166
|
||||
func newReference(raw string) (result reference, err error) {
|
||||
// Remove the oci:// prefix if it is there
|
||||
raw = strings.TrimPrefix(raw, OCIScheme+"://")
|
||||
|
||||
// The sole possible reference modification is replacing plus (+) signs
|
||||
// present in tags with underscores (_). To do this properly, we first
|
||||
// need to identify a tag, and then pass it on to the reference parser
|
||||
// NOTE: Passing immediately to the reference parser will fail since (+)
|
||||
// signs are an invalid tag character, and simply replacing all plus (+)
|
||||
// occurrences could invalidate other portions of the URI
|
||||
lastIndex := strings.LastIndex(raw, "@")
|
||||
if lastIndex >= 0 {
|
||||
result.Digest = raw[(lastIndex + 1):]
|
||||
raw = raw[:lastIndex]
|
||||
}
|
||||
parts := strings.Split(raw, ":")
|
||||
if len(parts) > 1 && !strings.Contains(parts[len(parts)-1], "/") {
|
||||
tag := parts[len(parts)-1]
|
||||
|
||||
if tag != "" {
|
||||
// Replace any plus (+) signs with known underscore (_) conversion
|
||||
newTag := strings.ReplaceAll(tag, "+", "_")
|
||||
raw = strings.ReplaceAll(raw, tag, newTag)
|
||||
}
|
||||
}
|
||||
|
||||
result.orasReference, err = registry.ParseReference(raw)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Registry = result.orasReference.Registry
|
||||
result.Repository = result.orasReference.Repository
|
||||
result.Tag = result.orasReference.Reference
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *reference) String() string {
|
||||
if r.Tag == "" {
|
||||
return r.orasReference.String() + "@" + r.Digest
|
||||
}
|
||||
return r.orasReference.String()
|
||||
}
|
||||
|
||||
// IsOCI determines whether a URL is to be treated as an OCI URL
|
||||
func IsOCI(url string) bool {
|
||||
return strings.HasPrefix(url, fmt.Sprintf("%s://", OCIScheme))
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package registry // import "helm.sh/helm/v4/pkg/registry"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
)
|
||||
|
||||
func GetTagMatchingVersionOrConstraint(tags []string, versionString string) (string, error) {
|
||||
var constraint *semver.Constraints
|
||||
if versionString == "" {
|
||||
// If the string is empty, set a wildcard constraint
|
||||
constraint, _ = semver.NewConstraint("*")
|
||||
} else {
|
||||
// when customer inputs a specific version, check whether there's an exact match first
|
||||
for _, v := range tags {
|
||||
if versionString == v {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise set constraint to the string given
|
||||
var err error
|
||||
constraint, err = semver.NewConstraint(versionString)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise try to find the first available version matching the string,
|
||||
// in case it is a constraint
|
||||
for _, v := range tags {
|
||||
test, err := semver.NewVersion(v)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if constraint.Check(test) {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not locate a version matching provided version string %s", versionString)
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"oras.land/oras-go/v2/registry/remote/retry"
|
||||
)
|
||||
|
||||
var (
|
||||
// requestCount records the number of logged request-response pairs and will
|
||||
// be used as the unique id for the next pair.
|
||||
requestCount atomic.Uint64
|
||||
|
||||
// toScrub is a set of headers that should be scrubbed from the log.
|
||||
toScrub = []string{
|
||||
"Authorization",
|
||||
"Set-Cookie",
|
||||
}
|
||||
)
|
||||
|
||||
// payloadSizeLimit limits the maximum size of the response body to be printed.
|
||||
const payloadSizeLimit int64 = 16 * 1024 // 16 KiB
|
||||
|
||||
// LoggingTransport is an http.RoundTripper that keeps track of the in-flight
|
||||
// request and add hooks to report HTTP tracing events.
|
||||
type LoggingTransport struct {
|
||||
http.RoundTripper
|
||||
}
|
||||
|
||||
// NewTransport creates and returns a new instance of LoggingTransport
|
||||
func NewTransport(debug bool) *retry.Transport {
|
||||
type cloner[T any] interface {
|
||||
Clone() T
|
||||
}
|
||||
|
||||
// try to copy (clone) the http.DefaultTransport so any mutations we
|
||||
// perform on it (e.g. TLS config) are not reflected globally
|
||||
// follow https://github.com/golang/go/issues/39299 for a more elegant
|
||||
// solution in the future
|
||||
transport := http.DefaultTransport
|
||||
if t, ok := transport.(cloner[*http.Transport]); ok {
|
||||
transport = t.Clone()
|
||||
} else if t, ok := transport.(cloner[http.RoundTripper]); ok {
|
||||
// this branch will not be used with go 1.20, it was added
|
||||
// optimistically to try to clone if the http.DefaultTransport
|
||||
// implementation changes, still the Clone method in that case
|
||||
// might not return http.RoundTripper...
|
||||
transport = t.Clone()
|
||||
}
|
||||
if debug {
|
||||
transport = &LoggingTransport{RoundTripper: transport}
|
||||
}
|
||||
|
||||
return retry.NewTransport(transport)
|
||||
}
|
||||
|
||||
// RoundTrip calls base round trip while keeping track of the current request.
|
||||
func (t *LoggingTransport) RoundTrip(req *http.Request) (resp *http.Response, err error) {
|
||||
id := requestCount.Add(1) - 1
|
||||
|
||||
slog.Debug(req.Method, "id", id, "url", req.URL, "header", logHeader(req.Header))
|
||||
resp, err = t.RoundTripper.RoundTrip(req)
|
||||
if err != nil {
|
||||
slog.Debug("Response"[:len(req.Method)], "id", id, "error", err)
|
||||
} else if resp != nil {
|
||||
slog.Debug("Response"[:len(req.Method)], "id", id, "status", resp.Status, "header", logHeader(resp.Header), "body", logResponseBody(resp))
|
||||
} else {
|
||||
slog.Debug("Response"[:len(req.Method)], "id", id, "response", "nil")
|
||||
}
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// logHeader prints out the provided header keys and values, with auth header scrubbed.
|
||||
func logHeader(header http.Header) string {
|
||||
if len(header) > 0 {
|
||||
var headers []string
|
||||
for k, v := range header {
|
||||
for _, h := range toScrub {
|
||||
if strings.EqualFold(k, h) {
|
||||
v = []string{"*****"}
|
||||
}
|
||||
}
|
||||
headers = append(headers, fmt.Sprintf(" %q: %q", k, strings.Join(v, ", ")))
|
||||
}
|
||||
return strings.Join(headers, "\n")
|
||||
}
|
||||
return " Empty header"
|
||||
}
|
||||
|
||||
// logResponseBody prints out the response body if it is printable and within size limit.
|
||||
func logResponseBody(resp *http.Response) string {
|
||||
if resp.Body == nil || resp.Body == http.NoBody {
|
||||
return " No response body to print"
|
||||
}
|
||||
|
||||
// non-applicable body is not printed and remains untouched for subsequent processing
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
return " Response body without a content type is not printed"
|
||||
}
|
||||
if !isPrintableContentType(contentType) {
|
||||
return fmt.Sprintf(" Response body of content type %q is not printed", contentType)
|
||||
}
|
||||
|
||||
buf := bytes.NewBuffer(nil)
|
||||
body := resp.Body
|
||||
// restore the body by concatenating the read body with the remaining body
|
||||
resp.Body = struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}{
|
||||
Reader: io.MultiReader(buf, body),
|
||||
Closer: body,
|
||||
}
|
||||
// read the body up to limit+1 to check if the body exceeds the limit
|
||||
if _, err := io.CopyN(buf, body, payloadSizeLimit+1); err != nil && err != io.EOF {
|
||||
return fmt.Sprintf(" Error reading response body: %v", err)
|
||||
}
|
||||
|
||||
readBody := buf.String()
|
||||
if len(readBody) == 0 {
|
||||
return " Response body is empty"
|
||||
}
|
||||
if containsCredentials(readBody) {
|
||||
return " Response body redacted due to potential credentials"
|
||||
}
|
||||
if len(readBody) > int(payloadSizeLimit) {
|
||||
return readBody[:payloadSizeLimit] + "\n...(truncated)"
|
||||
}
|
||||
return readBody
|
||||
}
|
||||
|
||||
// isPrintableContentType returns true if the contentType is printable.
|
||||
func isPrintableContentType(contentType string) bool {
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch mediaType {
|
||||
case "application/json", // JSON types
|
||||
"text/plain", "text/html": // text types
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(mediaType, "+json")
|
||||
}
|
||||
|
||||
// containsCredentials returns true if the body contains potential credentials.
|
||||
func containsCredentials(body string) bool {
|
||||
return strings.Contains(body, `"token"`) || strings.Contains(body, `"access_token"`)
|
||||
}
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package repo // import "helm.sh/helm/v4/pkg/repo/v1"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"helm.sh/helm/v4/internal/fileutil"
|
||||
"helm.sh/helm/v4/pkg/getter"
|
||||
"helm.sh/helm/v4/pkg/helmpath"
|
||||
)
|
||||
|
||||
// Entry represents a collection of parameters for chart repository
|
||||
type Entry struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
CertFile string `json:"certFile"`
|
||||
KeyFile string `json:"keyFile"`
|
||||
CAFile string `json:"caFile"`
|
||||
InsecureSkipTLSVerify bool `json:"insecure_skip_tls_verify"`
|
||||
PassCredentialsAll bool `json:"pass_credentials_all"`
|
||||
}
|
||||
|
||||
// ChartRepository represents a chart repository
|
||||
type ChartRepository struct {
|
||||
Config *Entry
|
||||
IndexFile *IndexFile
|
||||
Client getter.Getter
|
||||
CachePath string
|
||||
}
|
||||
|
||||
// NewChartRepository constructs ChartRepository
|
||||
func NewChartRepository(cfg *Entry, getters getter.Providers) (*ChartRepository, error) {
|
||||
u, err := url.Parse(cfg.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid chart URL format: %s", cfg.URL)
|
||||
}
|
||||
|
||||
client, err := getters.ByScheme(u.Scheme)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not find protocol handler for: %s", u.Scheme)
|
||||
}
|
||||
|
||||
return &ChartRepository{
|
||||
Config: cfg,
|
||||
IndexFile: NewIndexFile(),
|
||||
Client: client,
|
||||
CachePath: helmpath.CachePath("repository"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DownloadIndexFile fetches the index from a repository.
|
||||
func (r *ChartRepository) DownloadIndexFile() (string, error) {
|
||||
indexURL, err := ResolveReferenceURL(r.Config.URL, "index.yaml")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := r.Client.Get(indexURL,
|
||||
getter.WithURL(r.Config.URL),
|
||||
getter.WithInsecureSkipVerifyTLS(r.Config.InsecureSkipTLSVerify),
|
||||
getter.WithTLSClientConfig(r.Config.CertFile, r.Config.KeyFile, r.Config.CAFile),
|
||||
getter.WithBasicAuth(r.Config.Username, r.Config.Password),
|
||||
getter.WithPassCredentialsAll(r.Config.PassCredentialsAll),
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
index, err := io.ReadAll(resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
indexFile, err := loadIndex(index, r.Config.URL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Create the chart list file in the cache directory
|
||||
var charts strings.Builder
|
||||
for name := range indexFile.Entries {
|
||||
fmt.Fprintln(&charts, name)
|
||||
}
|
||||
chartsFile := filepath.Join(r.CachePath, helmpath.CacheChartsFile(r.Config.Name))
|
||||
os.MkdirAll(filepath.Dir(chartsFile), 0755)
|
||||
|
||||
fileutil.AtomicWriteFile(chartsFile, bytes.NewReader([]byte(charts.String())), 0644)
|
||||
|
||||
// Create the index file in the cache directory
|
||||
fname := filepath.Join(r.CachePath, helmpath.CacheIndexFile(r.Config.Name))
|
||||
os.MkdirAll(filepath.Dir(fname), 0755)
|
||||
return fname, fileutil.AtomicWriteFile(fname, bytes.NewReader(index), 0644)
|
||||
}
|
||||
|
||||
type findChartInRepoURLOptions struct {
|
||||
Username string
|
||||
Password string
|
||||
PassCredentialsAll bool
|
||||
InsecureSkipTLSVerify bool
|
||||
CertFile string
|
||||
KeyFile string
|
||||
CAFile string
|
||||
ChartVersion string
|
||||
}
|
||||
|
||||
type FindChartInRepoURLOption func(*findChartInRepoURLOptions)
|
||||
|
||||
// WithChartVersion specifies the chart version to find
|
||||
func WithChartVersion(chartVersion string) FindChartInRepoURLOption {
|
||||
return func(options *findChartInRepoURLOptions) {
|
||||
options.ChartVersion = chartVersion
|
||||
}
|
||||
}
|
||||
|
||||
// WithUsernamePassword specifies the username/password credntials for the repository
|
||||
func WithUsernamePassword(username, password string) FindChartInRepoURLOption {
|
||||
return func(options *findChartInRepoURLOptions) {
|
||||
options.Username = username
|
||||
options.Password = password
|
||||
}
|
||||
}
|
||||
|
||||
// WithPassCredentialsAll flags whether credentials should be passed on to other domains
|
||||
func WithPassCredentialsAll(passCredentialsAll bool) FindChartInRepoURLOption {
|
||||
return func(options *findChartInRepoURLOptions) {
|
||||
options.PassCredentialsAll = passCredentialsAll
|
||||
}
|
||||
}
|
||||
|
||||
// WithClientTLS species the cert, key, and CA files for client mTLS
|
||||
func WithClientTLS(certFile, keyFile, caFile string) FindChartInRepoURLOption {
|
||||
return func(options *findChartInRepoURLOptions) {
|
||||
options.CertFile = certFile
|
||||
options.KeyFile = keyFile
|
||||
options.CAFile = caFile
|
||||
}
|
||||
}
|
||||
|
||||
// WithInsecureSkipTLSVerify skips TLS verification for repository communication
|
||||
func WithInsecureSkipTLSVerify(insecureSkipTLSVerify bool) FindChartInRepoURLOption {
|
||||
return func(options *findChartInRepoURLOptions) {
|
||||
options.InsecureSkipTLSVerify = insecureSkipTLSVerify
|
||||
}
|
||||
}
|
||||
|
||||
// FindChartInRepoURL finds chart in chart repository pointed by repoURL
|
||||
// without adding repo to repositories
|
||||
func FindChartInRepoURL(repoURL string, chartName string, getters getter.Providers, options ...FindChartInRepoURLOption) (string, error) {
|
||||
|
||||
opts := findChartInRepoURLOptions{}
|
||||
for _, option := range options {
|
||||
option(&opts)
|
||||
}
|
||||
|
||||
// Download and write the index file to a temporary location
|
||||
buf := make([]byte, 20)
|
||||
rand.Read(buf)
|
||||
name := strings.ReplaceAll(base64.StdEncoding.EncodeToString(buf), "/", "-")
|
||||
|
||||
c := Entry{
|
||||
URL: repoURL,
|
||||
Username: opts.Username,
|
||||
Password: opts.Password,
|
||||
PassCredentialsAll: opts.PassCredentialsAll,
|
||||
CertFile: opts.CertFile,
|
||||
KeyFile: opts.KeyFile,
|
||||
CAFile: opts.CAFile,
|
||||
Name: name,
|
||||
InsecureSkipTLSVerify: opts.InsecureSkipTLSVerify,
|
||||
}
|
||||
r, err := NewChartRepository(&c, getters)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
idx, err := r.DownloadIndexFile()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("looks like %q is not a valid chart repository or cannot be reached: %w", repoURL, err)
|
||||
}
|
||||
defer func() {
|
||||
os.RemoveAll(filepath.Join(r.CachePath, helmpath.CacheChartsFile(r.Config.Name)))
|
||||
os.RemoveAll(filepath.Join(r.CachePath, helmpath.CacheIndexFile(r.Config.Name)))
|
||||
}()
|
||||
|
||||
// Read the index file for the repository to get chart information and return chart URL
|
||||
repoIndex, err := LoadIndexFile(idx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
errMsg := fmt.Sprintf("chart %q", chartName)
|
||||
if opts.ChartVersion != "" {
|
||||
errMsg = fmt.Sprintf("%s version %q", errMsg, opts.ChartVersion)
|
||||
}
|
||||
cv, err := repoIndex.Get(chartName, opts.ChartVersion)
|
||||
if err != nil {
|
||||
return "", ChartNotFoundError{
|
||||
Chart: errMsg,
|
||||
RepoURL: repoURL,
|
||||
}
|
||||
}
|
||||
|
||||
if len(cv.URLs) == 0 {
|
||||
return "", fmt.Errorf("%s has no downloadable URLs", errMsg)
|
||||
}
|
||||
|
||||
chartURL := cv.URLs[0]
|
||||
|
||||
absoluteChartURL, err := ResolveReferenceURL(repoURL, chartURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to make chart URL absolute: %w", err)
|
||||
}
|
||||
|
||||
return absoluteChartURL, nil
|
||||
}
|
||||
|
||||
// ResolveReferenceURL resolves refURL relative to baseURL.
|
||||
// If refURL is absolute, it simply returns refURL.
|
||||
func ResolveReferenceURL(baseURL, refURL string) (string, error) {
|
||||
parsedRefURL, err := url.Parse(refURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse %s as URL: %w", refURL, err)
|
||||
}
|
||||
|
||||
if parsedRefURL.IsAbs() {
|
||||
return refURL, nil
|
||||
}
|
||||
|
||||
parsedBaseURL, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse %s as URL: %w", baseURL, err)
|
||||
}
|
||||
|
||||
// We need a trailing slash for ResolveReference to work, but make sure there isn't already one
|
||||
parsedBaseURL.RawPath = strings.TrimSuffix(parsedBaseURL.RawPath, "/") + "/"
|
||||
parsedBaseURL.Path = strings.TrimSuffix(parsedBaseURL.Path, "/") + "/"
|
||||
|
||||
resolvedURL := parsedBaseURL.ResolveReference(parsedRefURL)
|
||||
resolvedURL.RawQuery = parsedBaseURL.RawQuery
|
||||
return resolvedURL.String(), nil
|
||||
}
|
||||
|
||||
func (e *Entry) String() string {
|
||||
buf, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
slog.Error("failed to marshal entry", slog.Any("error", err))
|
||||
panic(err)
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/*
|
||||
Package repo implements the Helm Chart Repository.
|
||||
|
||||
A chart repository is an HTTP server that provides information on charts. A local
|
||||
repository cache is an on-disk representation of a chart repository.
|
||||
|
||||
There are two important file formats for chart repositories.
|
||||
|
||||
The first is the 'index.yaml' format, which is expressed like this:
|
||||
|
||||
apiVersion: v1
|
||||
entries:
|
||||
frobnitz:
|
||||
- created: 2016-09-29T12:14:34.830161306-06:00
|
||||
description: This is a frobnitz.
|
||||
digest: 587bd19a9bd9d2bc4a6d25ab91c8c8e7042c47b4ac246e37bf8e1e74386190f4
|
||||
home: http://example.com
|
||||
keywords:
|
||||
- frobnitz
|
||||
- sprocket
|
||||
- dodad
|
||||
maintainers:
|
||||
- email: helm@example.com
|
||||
name: The Helm Team
|
||||
- email: nobody@example.com
|
||||
name: Someone Else
|
||||
name: frobnitz
|
||||
urls:
|
||||
- http://example-charts.com/testdata/repository/frobnitz-1.2.3.tgz
|
||||
version: 1.2.3
|
||||
sprocket:
|
||||
- created: 2016-09-29T12:14:34.830507606-06:00
|
||||
description: This is a sprocket"
|
||||
digest: 8505ff813c39502cc849a38e1e4a8ac24b8e6e1dcea88f4c34ad9b7439685ae6
|
||||
home: http://example.com
|
||||
keywords:
|
||||
- frobnitz
|
||||
- sprocket
|
||||
- dodad
|
||||
maintainers:
|
||||
- email: helm@example.com
|
||||
name: The Helm Team
|
||||
- email: nobody@example.com
|
||||
name: Someone Else
|
||||
name: sprocket
|
||||
urls:
|
||||
- http://example-charts.com/testdata/repository/sprocket-1.2.0.tgz
|
||||
version: 1.2.0
|
||||
generated: 2016-09-29T12:14:34.829721375-06:00
|
||||
|
||||
An index.yaml file contains the necessary descriptive information about what
|
||||
charts are available in a repository, and how to get them.
|
||||
|
||||
The second file format is the repositories.yaml file format. This file is for
|
||||
facilitating local cached copies of one or more chart repositories.
|
||||
|
||||
The format of a repository.yaml file is:
|
||||
|
||||
apiVersion: v1
|
||||
generated: TIMESTAMP
|
||||
repositories:
|
||||
- name: stable
|
||||
url: http://example.com/charts
|
||||
cache: stable-index.yaml
|
||||
- name: incubator
|
||||
url: http://example.com/incubator
|
||||
cache: incubator-index.yaml
|
||||
|
||||
This file maps three bits of information about a repository:
|
||||
|
||||
- The name the user uses to refer to it
|
||||
- The fully qualified URL to the repository (index.yaml will be appended)
|
||||
- The name of the local cachefile
|
||||
|
||||
The format for both files was changed after Helm v2.0.0-Alpha.4. Helm is not
|
||||
backwards compatible with those earlier versions.
|
||||
*/
|
||||
package repo
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type ChartNotFoundError struct {
|
||||
RepoURL string
|
||||
Chart string
|
||||
}
|
||||
|
||||
func (e ChartNotFoundError) Error() string {
|
||||
return fmt.Sprintf("%s not found in %s repository", e.Chart, e.RepoURL)
|
||||
}
|
||||
|
||||
func (e ChartNotFoundError) Is(err error) bool {
|
||||
_, ok := err.(ChartNotFoundError)
|
||||
return ok
|
||||
}
|
||||
+419
@@ -0,0 +1,419 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"sigs.k8s.io/yaml"
|
||||
|
||||
"helm.sh/helm/v4/internal/fileutil"
|
||||
"helm.sh/helm/v4/internal/urlutil"
|
||||
chart "helm.sh/helm/v4/pkg/chart/v2"
|
||||
"helm.sh/helm/v4/pkg/chart/v2/loader"
|
||||
"helm.sh/helm/v4/pkg/provenance"
|
||||
)
|
||||
|
||||
// APIVersionV1 is the v1 API version for index and repository files.
|
||||
const APIVersionV1 = "v1"
|
||||
|
||||
var (
|
||||
// ErrNoAPIVersion indicates that an API version was not specified.
|
||||
ErrNoAPIVersion = errors.New("no API version specified")
|
||||
// ErrNoChartVersion indicates that a chart with the given version is not found.
|
||||
ErrNoChartVersion = errors.New("no chart version found")
|
||||
// ErrNoChartName indicates that a chart with the given name is not found.
|
||||
ErrNoChartName = errors.New("no chart name found")
|
||||
// ErrEmptyIndexYaml indicates that the content of index.yaml is empty.
|
||||
ErrEmptyIndexYaml = errors.New("empty index.yaml file")
|
||||
)
|
||||
|
||||
// ChartVersions is a list of versioned chart references.
|
||||
// Implements a sorter on Version.
|
||||
type ChartVersions []*ChartVersion
|
||||
|
||||
// Len returns the length.
|
||||
func (c ChartVersions) Len() int { return len(c) }
|
||||
|
||||
// Swap swaps the position of two items in the versions slice.
|
||||
func (c ChartVersions) Swap(i, j int) { c[i], c[j] = c[j], c[i] }
|
||||
|
||||
// Less returns true if the version of entry a is less than the version of entry b.
|
||||
func (c ChartVersions) Less(a, b int) bool {
|
||||
// Failed parse pushes to the back.
|
||||
i, err := semver.NewVersion(c[a].Version)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
j, err := semver.NewVersion(c[b].Version)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return i.LessThan(j)
|
||||
}
|
||||
|
||||
// IndexFile represents the index file in a chart repository
|
||||
type IndexFile struct {
|
||||
// This is used ONLY for validation against chartmuseum's index files and is discarded after validation.
|
||||
ServerInfo map[string]interface{} `json:"serverInfo,omitempty"`
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Generated time.Time `json:"generated"`
|
||||
Entries map[string]ChartVersions `json:"entries"`
|
||||
PublicKeys []string `json:"publicKeys,omitempty"`
|
||||
|
||||
// Annotations are additional mappings uninterpreted by Helm. They are made available for
|
||||
// other applications to add information to the index file.
|
||||
Annotations map[string]string `json:"annotations,omitempty"`
|
||||
}
|
||||
|
||||
// NewIndexFile initializes an index.
|
||||
func NewIndexFile() *IndexFile {
|
||||
return &IndexFile{
|
||||
APIVersion: APIVersionV1,
|
||||
Generated: time.Now(),
|
||||
Entries: map[string]ChartVersions{},
|
||||
PublicKeys: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
// LoadIndexFile takes a file at the given path and returns an IndexFile object
|
||||
func LoadIndexFile(path string) (*IndexFile, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
i, err := loadIndex(b, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading %s: %w", path, err)
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
// MustAdd adds a file to the index
|
||||
// This can leave the index in an unsorted state
|
||||
func (i IndexFile) MustAdd(md *chart.Metadata, filename, baseURL, digest string) error {
|
||||
if i.Entries == nil {
|
||||
return errors.New("entries not initialized")
|
||||
}
|
||||
|
||||
if md.APIVersion == "" {
|
||||
md.APIVersion = chart.APIVersionV1
|
||||
}
|
||||
if err := md.Validate(); err != nil {
|
||||
return fmt.Errorf("validate failed for %s: %w", filename, err)
|
||||
}
|
||||
|
||||
u := filename
|
||||
if baseURL != "" {
|
||||
_, file := filepath.Split(filename)
|
||||
var err error
|
||||
u, err = urlutil.URLJoin(baseURL, file)
|
||||
if err != nil {
|
||||
u = path.Join(baseURL, file)
|
||||
}
|
||||
}
|
||||
cr := &ChartVersion{
|
||||
URLs: []string{u},
|
||||
Metadata: md,
|
||||
Digest: digest,
|
||||
Created: time.Now(),
|
||||
}
|
||||
ee := i.Entries[md.Name]
|
||||
i.Entries[md.Name] = append(ee, cr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add adds a file to the index and logs an error.
|
||||
//
|
||||
// Deprecated: Use index.MustAdd instead.
|
||||
func (i IndexFile) Add(md *chart.Metadata, filename, baseURL, digest string) {
|
||||
if err := i.MustAdd(md, filename, baseURL, digest); err != nil {
|
||||
slog.Error("skipping loading invalid entry for chart %q %q from %s: %s", md.Name, md.Version, filename, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Has returns true if the index has an entry for a chart with the given name and exact version.
|
||||
func (i IndexFile) Has(name, version string) bool {
|
||||
_, err := i.Get(name, version)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// SortEntries sorts the entries by version in descending order.
|
||||
//
|
||||
// In canonical form, the individual version records should be sorted so that
|
||||
// the most recent release for every version is in the 0th slot in the
|
||||
// Entries.ChartVersions array. That way, tooling can predict the newest
|
||||
// version without needing to parse SemVers.
|
||||
func (i IndexFile) SortEntries() {
|
||||
for _, versions := range i.Entries {
|
||||
sort.Sort(sort.Reverse(versions))
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the ChartVersion for the given name.
|
||||
//
|
||||
// If version is empty, this will return the chart with the latest stable version,
|
||||
// prerelease versions will be skipped.
|
||||
func (i IndexFile) Get(name, version string) (*ChartVersion, error) {
|
||||
vs, ok := i.Entries[name]
|
||||
if !ok {
|
||||
return nil, ErrNoChartName
|
||||
}
|
||||
if len(vs) == 0 {
|
||||
return nil, ErrNoChartVersion
|
||||
}
|
||||
|
||||
var constraint *semver.Constraints
|
||||
if version == "" {
|
||||
constraint, _ = semver.NewConstraint("*")
|
||||
} else {
|
||||
var err error
|
||||
constraint, err = semver.NewConstraint(version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// when customer inputs specific version, check whether there's an exact match first
|
||||
if len(version) != 0 {
|
||||
for _, ver := range vs {
|
||||
if version == ver.Version {
|
||||
return ver, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, ver := range vs {
|
||||
test, err := semver.NewVersion(ver.Version)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if constraint.Check(test) {
|
||||
if len(version) != 0 {
|
||||
slog.Warn("unable to find exact version requested; falling back to closest available version", "chart", name, "requested", version, "selected", ver.Version)
|
||||
}
|
||||
return ver, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no chart version found for %s-%s", name, version)
|
||||
}
|
||||
|
||||
// WriteFile writes an index file to the given destination path.
|
||||
//
|
||||
// The mode on the file is set to 'mode'.
|
||||
func (i IndexFile) WriteFile(dest string, mode os.FileMode) error {
|
||||
b, err := yaml.Marshal(i)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.AtomicWriteFile(dest, bytes.NewReader(b), mode)
|
||||
}
|
||||
|
||||
// WriteJSONFile writes an index file in JSON format to the given destination
|
||||
// path.
|
||||
//
|
||||
// The mode on the file is set to 'mode'.
|
||||
func (i IndexFile) WriteJSONFile(dest string, mode os.FileMode) error {
|
||||
b, err := json.MarshalIndent(i, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.AtomicWriteFile(dest, bytes.NewReader(b), mode)
|
||||
}
|
||||
|
||||
// Merge merges the given index file into this index.
|
||||
//
|
||||
// This merges by name and version.
|
||||
//
|
||||
// If one of the entries in the given index does _not_ already exist, it is added.
|
||||
// In all other cases, the existing record is preserved.
|
||||
//
|
||||
// This can leave the index in an unsorted state
|
||||
func (i *IndexFile) Merge(f *IndexFile) {
|
||||
for _, cvs := range f.Entries {
|
||||
for _, cv := range cvs {
|
||||
if !i.Has(cv.Name, cv.Version) {
|
||||
e := i.Entries[cv.Name]
|
||||
i.Entries[cv.Name] = append(e, cv)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ChartVersion represents a chart entry in the IndexFile
|
||||
type ChartVersion struct {
|
||||
*chart.Metadata
|
||||
URLs []string `json:"urls"`
|
||||
Created time.Time `json:"created,omitempty"`
|
||||
Removed bool `json:"removed,omitempty"`
|
||||
Digest string `json:"digest,omitempty"`
|
||||
|
||||
// ChecksumDeprecated is deprecated in Helm 3, and therefore ignored. Helm 3 replaced
|
||||
// this with Digest. However, with a strict YAML parser enabled, a field must be
|
||||
// present on the struct for backwards compatibility.
|
||||
ChecksumDeprecated string `json:"checksum,omitempty"`
|
||||
|
||||
// EngineDeprecated is deprecated in Helm 3, and therefore ignored. However, with a strict
|
||||
// YAML parser enabled, this field must be present.
|
||||
EngineDeprecated string `json:"engine,omitempty"`
|
||||
|
||||
// TillerVersionDeprecated is deprecated in Helm 3, and therefore ignored. However, with a strict
|
||||
// YAML parser enabled, this field must be present.
|
||||
TillerVersionDeprecated string `json:"tillerVersion,omitempty"`
|
||||
|
||||
// URLDeprecated is deprecated in Helm 3, superseded by URLs. It is ignored. However,
|
||||
// with a strict YAML parser enabled, this must be present on the struct.
|
||||
URLDeprecated string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// IndexDirectory reads a (flat) directory and generates an index.
|
||||
//
|
||||
// It indexes only charts that have been packaged (*.tgz).
|
||||
//
|
||||
// The index returned will be in an unsorted state
|
||||
func IndexDirectory(dir, baseURL string) (*IndexFile, error) {
|
||||
archives, err := filepath.Glob(filepath.Join(dir, "*.tgz"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
moreArchives, err := filepath.Glob(filepath.Join(dir, "**/*.tgz"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
archives = append(archives, moreArchives...)
|
||||
|
||||
index := NewIndexFile()
|
||||
for _, arch := range archives {
|
||||
fname, err := filepath.Rel(dir, arch)
|
||||
if err != nil {
|
||||
return index, err
|
||||
}
|
||||
|
||||
var parentDir string
|
||||
parentDir, fname = filepath.Split(fname)
|
||||
// filepath.Split appends an extra slash to the end of parentDir. We want to strip that out.
|
||||
parentDir = strings.TrimSuffix(parentDir, string(os.PathSeparator))
|
||||
parentURL, err := urlutil.URLJoin(baseURL, parentDir)
|
||||
if err != nil {
|
||||
parentURL = path.Join(baseURL, parentDir)
|
||||
}
|
||||
|
||||
c, err := loader.Load(arch)
|
||||
if err != nil {
|
||||
// Assume this is not a chart.
|
||||
continue
|
||||
}
|
||||
hash, err := provenance.DigestFile(arch)
|
||||
if err != nil {
|
||||
return index, err
|
||||
}
|
||||
if err := index.MustAdd(c.Metadata, fname, parentURL, hash); err != nil {
|
||||
return index, fmt.Errorf("failed adding to %s to index: %w", fname, err)
|
||||
}
|
||||
}
|
||||
return index, nil
|
||||
}
|
||||
|
||||
// loadIndex loads an index file and does minimal validity checking.
|
||||
//
|
||||
// The source parameter is only used for logging.
|
||||
// This will fail if API Version is not set (ErrNoAPIVersion) or if the unmarshal fails.
|
||||
func loadIndex(data []byte, source string) (*IndexFile, error) {
|
||||
i := &IndexFile{}
|
||||
|
||||
if len(data) == 0 {
|
||||
return i, ErrEmptyIndexYaml
|
||||
}
|
||||
|
||||
if err := jsonOrYamlUnmarshal(data, i); err != nil {
|
||||
return i, err
|
||||
}
|
||||
|
||||
for name, cvs := range i.Entries {
|
||||
for idx := len(cvs) - 1; idx >= 0; idx-- {
|
||||
if cvs[idx] == nil {
|
||||
slog.Warn(fmt.Sprintf("skipping loading invalid entry for chart %q from %s: empty entry", name, source))
|
||||
cvs = append(cvs[:idx], cvs[idx+1:]...)
|
||||
continue
|
||||
}
|
||||
// When metadata section missing, initialize with no data
|
||||
if cvs[idx].Metadata == nil {
|
||||
cvs[idx].Metadata = &chart.Metadata{}
|
||||
}
|
||||
if cvs[idx].APIVersion == "" {
|
||||
cvs[idx].APIVersion = chart.APIVersionV1
|
||||
}
|
||||
if err := cvs[idx].Validate(); ignoreSkippableChartValidationError(err) != nil {
|
||||
slog.Warn(fmt.Sprintf("skipping loading invalid entry for chart %q %q from %s: %s", name, cvs[idx].Version, source, err))
|
||||
cvs = append(cvs[:idx], cvs[idx+1:]...)
|
||||
}
|
||||
}
|
||||
// adjust slice to only contain a set of valid versions
|
||||
i.Entries[name] = cvs
|
||||
}
|
||||
i.SortEntries()
|
||||
if i.APIVersion == "" {
|
||||
return i, ErrNoAPIVersion
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
// jsonOrYamlUnmarshal unmarshals the given byte slice containing JSON or YAML
|
||||
// into the provided interface.
|
||||
//
|
||||
// It automatically detects whether the data is in JSON or YAML format by
|
||||
// checking its validity as JSON. If the data is valid JSON, it will use the
|
||||
// `encoding/json` package to unmarshal it. Otherwise, it will use the
|
||||
// `sigs.k8s.io/yaml` package to unmarshal the YAML data.
|
||||
func jsonOrYamlUnmarshal(b []byte, i interface{}) error {
|
||||
if json.Valid(b) {
|
||||
return json.Unmarshal(b, i)
|
||||
}
|
||||
return yaml.UnmarshalStrict(b, i)
|
||||
}
|
||||
|
||||
// ignoreSkippableChartValidationError inspect the given error and returns nil if
|
||||
// the error isn't important for index loading
|
||||
//
|
||||
// In particular, charts may introduce validations that don't impact repository indexes
|
||||
// And repository indexes may be generated by older/non-compliant software, which doesn't
|
||||
// conform to all validations.
|
||||
func ignoreSkippableChartValidationError(err error) error {
|
||||
verr, ok := err.(chart.ValidationError)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
// https://github.com/helm/helm/issues/12748 (JFrog repository strips alias field)
|
||||
if strings.HasPrefix(verr.Error(), "validation: more than one dependency with name or alias") {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package repo // import "helm.sh/helm/v4/pkg/repo/v1"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"sigs.k8s.io/yaml"
|
||||
)
|
||||
|
||||
// File represents the repositories.yaml file
|
||||
type File struct {
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Generated time.Time `json:"generated"`
|
||||
Repositories []*Entry `json:"repositories"`
|
||||
}
|
||||
|
||||
// NewFile generates an empty repositories file.
|
||||
//
|
||||
// Generated and APIVersion are automatically set.
|
||||
func NewFile() *File {
|
||||
return &File{
|
||||
APIVersion: APIVersionV1,
|
||||
Generated: time.Now(),
|
||||
Repositories: []*Entry{},
|
||||
}
|
||||
}
|
||||
|
||||
// LoadFile takes a file at the given path and returns a File object
|
||||
func LoadFile(path string) (*File, error) {
|
||||
r := new(File)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return r, fmt.Errorf("couldn't load repositories file (%s): %w", path, err)
|
||||
}
|
||||
|
||||
err = yaml.Unmarshal(b, r)
|
||||
return r, err
|
||||
}
|
||||
|
||||
// Add adds one or more repo entries to a repo file.
|
||||
func (r *File) Add(re ...*Entry) {
|
||||
r.Repositories = append(r.Repositories, re...)
|
||||
}
|
||||
|
||||
// Update attempts to replace one or more repo entries in a repo file. If an
|
||||
// entry with the same name doesn't exist in the repo file it will add it.
|
||||
func (r *File) Update(re ...*Entry) {
|
||||
for _, target := range re {
|
||||
r.update(target)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *File) update(e *Entry) {
|
||||
for j, repo := range r.Repositories {
|
||||
if repo.Name == e.Name {
|
||||
r.Repositories[j] = e
|
||||
return
|
||||
}
|
||||
}
|
||||
r.Add(e)
|
||||
}
|
||||
|
||||
// Has returns true if the given name is already a repository name.
|
||||
func (r *File) Has(name string) bool {
|
||||
entry := r.Get(name)
|
||||
return entry != nil
|
||||
}
|
||||
|
||||
// Get returns an entry with the given name if it exists, otherwise returns nil
|
||||
func (r *File) Get(name string) *Entry {
|
||||
for _, entry := range r.Repositories {
|
||||
if entry.Name == name {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove removes the entry from the list of repositories.
|
||||
func (r *File) Remove(name string) bool {
|
||||
cp := []*Entry{}
|
||||
found := false
|
||||
for _, rf := range r.Repositories {
|
||||
if rf == nil {
|
||||
continue
|
||||
}
|
||||
if rf.Name == name {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
cp = append(cp, rf)
|
||||
}
|
||||
r.Repositories = cp
|
||||
return found
|
||||
}
|
||||
|
||||
// WriteFile writes a repositories file to the given path.
|
||||
func (r *File) WriteFile(path string, perm os.FileMode) error {
|
||||
data, err := yaml.Marshal(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, perm)
|
||||
}
|
||||
Reference in New Issue
Block a user