working commit

This commit is contained in:
2026-03-13 19:02:42 +02:00
parent bebbf79c7a
commit 5c1da77f4c
1329 changed files with 314708 additions and 39 deletions
+50
View File
@@ -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
View File
@@ -0,0 +1,125 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package 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{}
+53
View File
@@ -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
}
+24
View File
@@ -0,0 +1,24 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+67
View File
@@ -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
}
+81
View File
@@ -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
}()
+86
View File
@@ -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
+45
View File
@@ -0,0 +1,45 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
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
}
+18
View File
@@ -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
+66
View File
@@ -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
}
+28
View File
@@ -0,0 +1,28 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package schema
type InputMessageTestV1 struct {
Name string
}
type OutputMessageTestV1 struct {
Greeting string
}
type ConfigTestV1 struct{}
func (c *ConfigTestV1) Validate() error {
return nil
}
+156
View File
@@ -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
})
}
+178
View File
@@ -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
}
+39
View File
@@ -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
}
}
+377
View File
@@ -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)
}
@@ -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
View File
@@ -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
}
+73
View File
@@ -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
}