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
|
||||
}
|
||||
Reference in New Issue
Block a user