mirror of https://github.com/helm/helm
Signed-off-by: Evans Mungai <mbuevans@gmail.com>pull/31116/head
commit
5dabfdfb3f
@ -0,0 +1,87 @@
|
||||
/*
|
||||
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"
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
// Config interface defines the methods that all plugin type configurations must implement
|
||||
type Config interface {
|
||||
Validate() error
|
||||
}
|
||||
|
||||
// ConfigCLI represents the configuration for CLI plugins
|
||||
type ConfigCLI 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"`
|
||||
}
|
||||
|
||||
// ConfigGetter represents the configuration for download plugins
|
||||
type ConfigGetter struct {
|
||||
// Protocols are the list of URL schemes supported by this downloader
|
||||
Protocols []string `yaml:"protocols"`
|
||||
}
|
||||
|
||||
// ConfigPostrenderer represents the configuration for postrenderer plugins
|
||||
// there are no runtime-independent configurations for postrenderer/v1 plugin type
|
||||
type ConfigPostrenderer struct{}
|
||||
|
||||
func (c *ConfigCLI) Validate() error {
|
||||
// Config validation for CLI plugins
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ConfigGetter) 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
|
||||
}
|
||||
|
||||
func (c *ConfigPostrenderer) Validate() error {
|
||||
// Config validation for postrenderer plugins
|
||||
return nil
|
||||
}
|
||||
|
||||
func remarshalConfig[T Config](configData map[string]any) (Config, error) {
|
||||
data, err := yaml.Marshal(configData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var config T
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
// Descriptor describes a plugin to find
|
||||
type Descriptor struct {
|
||||
// Name is the name of the plugin
|
||||
Name string
|
||||
// Type is the type of the plugin (cli, getter, postrenderer)
|
||||
Type string
|
||||
}
|
@ -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
|
@ -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 {
|
||||
Err error // Underlying error
|
||||
Code int // Exeit code from plugin code execution
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
func (e *InvokeExecError) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
@ -0,0 +1,191 @@
|
||||
/*
|
||||
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 installer // import "helm.sh/helm/v4/internal/plugin/installer"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"helm.sh/helm/v4/internal/plugin"
|
||||
"helm.sh/helm/v4/internal/plugin/cache"
|
||||
"helm.sh/helm/v4/internal/third_party/dep/fs"
|
||||
"helm.sh/helm/v4/pkg/cli"
|
||||
"helm.sh/helm/v4/pkg/getter"
|
||||
"helm.sh/helm/v4/pkg/helmpath"
|
||||
)
|
||||
|
||||
// HTTPInstaller installs plugins from an archive served by a web server.
|
||||
type HTTPInstaller struct {
|
||||
CacheDir string
|
||||
PluginName string
|
||||
base
|
||||
extractor Extractor
|
||||
getter getter.Getter
|
||||
// Cached data to avoid duplicate downloads
|
||||
pluginData []byte
|
||||
provData []byte
|
||||
}
|
||||
|
||||
// NewHTTPInstaller creates a new HttpInstaller.
|
||||
func NewHTTPInstaller(source string) (*HTTPInstaller, error) {
|
||||
key, err := cache.Key(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
extractor, err := NewExtractor(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
get, err := getter.All(new(cli.EnvSettings)).ByScheme("http")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
i := &HTTPInstaller{
|
||||
CacheDir: helmpath.CachePath("plugins", key),
|
||||
PluginName: stripPluginName(filepath.Base(source)),
|
||||
base: newBase(source),
|
||||
extractor: extractor,
|
||||
getter: get,
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
// Install downloads and extracts the tarball into the cache directory
|
||||
// and installs into the plugin directory.
|
||||
//
|
||||
// Implements Installer.
|
||||
func (i *HTTPInstaller) Install() error {
|
||||
// Ensure plugin data is cached
|
||||
if i.pluginData == nil {
|
||||
pluginData, err := i.getter.Get(i.Source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.pluginData = pluginData.Bytes()
|
||||
}
|
||||
|
||||
// Save the original tarball to plugins directory for verification
|
||||
// Extract metadata to get the actual plugin name and version
|
||||
metadata, err := plugin.ExtractTgzPluginMetadata(bytes.NewReader(i.pluginData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract plugin metadata from tarball: %w", err)
|
||||
}
|
||||
filename := fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version)
|
||||
tarballPath := helmpath.DataPath("plugins", filename)
|
||||
if err := os.MkdirAll(filepath.Dir(tarballPath), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create plugins directory: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(tarballPath, i.pluginData, 0644); err != nil {
|
||||
return fmt.Errorf("failed to save tarball: %w", err)
|
||||
}
|
||||
|
||||
// Ensure prov data is cached if available
|
||||
if i.provData == nil {
|
||||
// Try to download .prov file if it exists
|
||||
provURL := i.Source + ".prov"
|
||||
if provData, err := i.getter.Get(provURL); err == nil {
|
||||
i.provData = provData.Bytes()
|
||||
}
|
||||
}
|
||||
|
||||
// Save prov file if we have the data
|
||||
if i.provData != nil {
|
||||
provPath := tarballPath + ".prov"
|
||||
if err := os.WriteFile(provPath, i.provData, 0644); err != nil {
|
||||
slog.Debug("failed to save provenance file", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := i.extractor.Extract(bytes.NewBuffer(i.pluginData), i.CacheDir); err != nil {
|
||||
return fmt.Errorf("extracting files from archive: %w", err)
|
||||
}
|
||||
|
||||
// Detect where the plugin.yaml actually is
|
||||
pluginRoot, err := detectPluginRoot(i.CacheDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate plugin structure if needed
|
||||
if err := validatePluginName(pluginRoot, i.PluginName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
src, err := filepath.Abs(pluginRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Debug("copying", "source", src, "path", i.Path())
|
||||
return fs.CopyDir(src, i.Path())
|
||||
}
|
||||
|
||||
// Update updates a local repository
|
||||
// Not implemented for now since tarball most likely will be packaged by version
|
||||
func (i *HTTPInstaller) Update() error {
|
||||
return fmt.Errorf("method Update() not implemented for HttpInstaller")
|
||||
}
|
||||
|
||||
// Path is overridden because we want to join on the plugin name not the file name
|
||||
func (i HTTPInstaller) Path() string {
|
||||
if i.Source == "" {
|
||||
return ""
|
||||
}
|
||||
return helmpath.DataPath("plugins", i.PluginName)
|
||||
}
|
||||
|
||||
// SupportsVerification returns true if the HTTP installer can verify plugins
|
||||
func (i *HTTPInstaller) SupportsVerification() bool {
|
||||
// Only support verification for tarball URLs
|
||||
return strings.HasSuffix(i.Source, ".tgz") || strings.HasSuffix(i.Source, ".tar.gz")
|
||||
}
|
||||
|
||||
// GetVerificationData returns cached plugin and provenance data for verification
|
||||
func (i *HTTPInstaller) GetVerificationData() (archiveData, provData []byte, filename string, err error) {
|
||||
if !i.SupportsVerification() {
|
||||
return nil, nil, "", fmt.Errorf("verification not supported for this source")
|
||||
}
|
||||
|
||||
// Download plugin data once and cache it
|
||||
if i.pluginData == nil {
|
||||
data, err := i.getter.Get(i.Source)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to download plugin: %w", err)
|
||||
}
|
||||
i.pluginData = data.Bytes()
|
||||
}
|
||||
|
||||
// Download prov data once and cache it if available
|
||||
if i.provData == nil {
|
||||
provData, err := i.getter.Get(i.Source + ".prov")
|
||||
if err != nil {
|
||||
// If provenance file doesn't exist, set provData to nil
|
||||
// The verification logic will handle this gracefully
|
||||
i.provData = nil
|
||||
} else {
|
||||
i.provData = provData.Bytes()
|
||||
}
|
||||
}
|
||||
|
||||
return i.pluginData, i.provData, filepath.Base(i.Source), nil
|
||||
}
|
@ -0,0 +1,219 @@
|
||||
/*
|
||||
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 installer // import "helm.sh/helm/v4/internal/plugin/installer"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"helm.sh/helm/v4/internal/plugin"
|
||||
"helm.sh/helm/v4/internal/third_party/dep/fs"
|
||||
"helm.sh/helm/v4/pkg/helmpath"
|
||||
)
|
||||
|
||||
// ErrPluginNotAFolder indicates that the plugin path is not a folder.
|
||||
var ErrPluginNotAFolder = errors.New("expected plugin to be a folder")
|
||||
|
||||
// LocalInstaller installs plugins from the filesystem.
|
||||
type LocalInstaller struct {
|
||||
base
|
||||
isArchive bool
|
||||
extractor Extractor
|
||||
pluginData []byte // Cached plugin data
|
||||
provData []byte // Cached provenance data
|
||||
}
|
||||
|
||||
// NewLocalInstaller creates a new LocalInstaller.
|
||||
func NewLocalInstaller(source string) (*LocalInstaller, error) {
|
||||
src, err := filepath.Abs(source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get absolute path to plugin: %w", err)
|
||||
}
|
||||
i := &LocalInstaller{
|
||||
base: newBase(src),
|
||||
}
|
||||
|
||||
// Check if source is an archive
|
||||
if isLocalArchive(src) {
|
||||
i.isArchive = true
|
||||
extractor, err := NewExtractor(src)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unsupported archive format: %w", err)
|
||||
}
|
||||
i.extractor = extractor
|
||||
}
|
||||
|
||||
return i, nil
|
||||
}
|
||||
|
||||
// isLocalArchive checks if the file is a supported archive format
|
||||
func isLocalArchive(path string) bool {
|
||||
for suffix := range Extractors {
|
||||
if strings.HasSuffix(path, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Install creates a symlink to the plugin directory.
|
||||
//
|
||||
// Implements Installer.
|
||||
func (i *LocalInstaller) Install() error {
|
||||
if i.isArchive {
|
||||
return i.installFromArchive()
|
||||
}
|
||||
return i.installFromDirectory()
|
||||
}
|
||||
|
||||
// installFromDirectory creates a symlink to the plugin directory
|
||||
func (i *LocalInstaller) installFromDirectory() error {
|
||||
stat, err := os.Stat(i.Source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !stat.IsDir() {
|
||||
return ErrPluginNotAFolder
|
||||
}
|
||||
|
||||
if !isPlugin(i.Source) {
|
||||
return ErrMissingMetadata
|
||||
}
|
||||
slog.Debug("symlinking", "source", i.Source, "path", i.Path())
|
||||
return os.Symlink(i.Source, i.Path())
|
||||
}
|
||||
|
||||
// installFromArchive extracts and installs a plugin from a tarball
|
||||
func (i *LocalInstaller) installFromArchive() error {
|
||||
// Read the archive file
|
||||
data, err := os.ReadFile(i.Source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read archive: %w", err)
|
||||
}
|
||||
|
||||
// Copy the original tarball to plugins directory for verification
|
||||
// Extract metadata to get the actual plugin name and version
|
||||
metadata, err := plugin.ExtractTgzPluginMetadata(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract plugin metadata from tarball: %w", err)
|
||||
}
|
||||
filename := fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version)
|
||||
tarballPath := helmpath.DataPath("plugins", filename)
|
||||
if err := os.MkdirAll(filepath.Dir(tarballPath), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create plugins directory: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(tarballPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to save tarball: %w", err)
|
||||
}
|
||||
|
||||
// Check for and copy .prov file if it exists
|
||||
provSource := i.Source + ".prov"
|
||||
if provData, err := os.ReadFile(provSource); err == nil {
|
||||
provPath := tarballPath + ".prov"
|
||||
if err := os.WriteFile(provPath, provData, 0644); err != nil {
|
||||
slog.Debug("failed to save provenance file", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a temporary directory for extraction
|
||||
tempDir, err := os.MkdirTemp("", "helm-plugin-extract-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Extract the archive
|
||||
buffer := bytes.NewBuffer(data)
|
||||
if err := i.extractor.Extract(buffer, tempDir); err != nil {
|
||||
return fmt.Errorf("failed to extract archive: %w", err)
|
||||
}
|
||||
|
||||
// Plugin directory should be named after the plugin at the archive root
|
||||
pluginName := stripPluginName(filepath.Base(i.Source))
|
||||
pluginDir := filepath.Join(tempDir, pluginName)
|
||||
if _, err = os.Stat(filepath.Join(pluginDir, "plugin.yaml")); err != nil {
|
||||
return fmt.Errorf("plugin.yaml not found in expected directory %s: %w", pluginDir, err)
|
||||
}
|
||||
|
||||
// Copy to the final destination
|
||||
slog.Debug("copying", "source", pluginDir, "path", i.Path())
|
||||
return fs.CopyDir(pluginDir, i.Path())
|
||||
}
|
||||
|
||||
// Update updates a local repository
|
||||
func (i *LocalInstaller) Update() error {
|
||||
slog.Debug("local repository is auto-updated")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Path is overridden to handle archive plugin names properly
|
||||
func (i *LocalInstaller) Path() string {
|
||||
if i.Source == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
pluginName := filepath.Base(i.Source)
|
||||
if i.isArchive {
|
||||
// Strip archive extension to get plugin name
|
||||
pluginName = stripPluginName(pluginName)
|
||||
}
|
||||
|
||||
return helmpath.DataPath("plugins", pluginName)
|
||||
}
|
||||
|
||||
// SupportsVerification returns true if the local installer can verify plugins
|
||||
func (i *LocalInstaller) SupportsVerification() bool {
|
||||
// Only support verification for local tarball files
|
||||
return i.isArchive
|
||||
}
|
||||
|
||||
// GetVerificationData loads plugin and provenance data from local files for verification
|
||||
func (i *LocalInstaller) GetVerificationData() (archiveData, provData []byte, filename string, err error) {
|
||||
if !i.SupportsVerification() {
|
||||
return nil, nil, "", fmt.Errorf("verification not supported for directories")
|
||||
}
|
||||
|
||||
// Read and cache the plugin archive file
|
||||
if i.pluginData == nil {
|
||||
i.pluginData, err = os.ReadFile(i.Source)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to read plugin file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Read and cache the provenance file if it exists
|
||||
if i.provData == nil {
|
||||
provFile := i.Source + ".prov"
|
||||
i.provData, err = os.ReadFile(provFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// If provenance file doesn't exist, set provData to nil
|
||||
// The verification logic will handle this gracefully
|
||||
i.provData = nil
|
||||
} else {
|
||||
// If file exists but can't be read (permissions, etc), return error
|
||||
return nil, nil, "", fmt.Errorf("failed to access provenance file %s: %w", provFile, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return i.pluginData, i.provData, filepath.Base(i.Source), nil
|
||||
}
|
@ -0,0 +1,148 @@
|
||||
/*
|
||||
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 installer // import "helm.sh/helm/v4/internal/plugin/installer"
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"helm.sh/helm/v4/internal/test/ensure"
|
||||
"helm.sh/helm/v4/pkg/helmpath"
|
||||
)
|
||||
|
||||
var _ Installer = new(LocalInstaller)
|
||||
|
||||
func TestLocalInstaller(t *testing.T) {
|
||||
ensure.HelmHome(t)
|
||||
// Make a temp dir
|
||||
tdir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(tdir, "plugin.yaml"), []byte{}, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
source := "../testdata/plugdir/good/echo-v1"
|
||||
i, err := NewForSource(source, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
|
||||
if err := Install(i); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if i.Path() != helmpath.DataPath("plugins", "echo-v1") {
|
||||
t.Fatalf("expected path '$XDG_CONFIG_HOME/helm/plugins/helm-env', got %q", i.Path())
|
||||
}
|
||||
defer os.RemoveAll(filepath.Dir(helmpath.DataPath())) // helmpath.DataPath is like /tmp/helm013130971/helm
|
||||
}
|
||||
|
||||
func TestLocalInstallerNotAFolder(t *testing.T) {
|
||||
source := "../testdata/plugdir/good/echo-v1/plugin.yaml"
|
||||
i, err := NewForSource(source, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
|
||||
err = Install(i)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if err != ErrPluginNotAFolder {
|
||||
t.Fatalf("expected error to equal: %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalInstallerTarball(t *testing.T) {
|
||||
ensure.HelmHome(t)
|
||||
|
||||
// Create a test tarball
|
||||
tempDir := t.TempDir()
|
||||
tarballPath := filepath.Join(tempDir, "test-plugin-1.0.0.tar.gz")
|
||||
|
||||
// Create tarball content
|
||||
var buf bytes.Buffer
|
||||
gw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gw)
|
||||
|
||||
files := []struct {
|
||||
Name string
|
||||
Body string
|
||||
Mode int64
|
||||
}{
|
||||
{"test-plugin/plugin.yaml", "name: test-plugin\nversion: 1.0.0\nusage: test\ndescription: test\ncommand: echo", 0644},
|
||||
{"test-plugin/bin/test-plugin", "#!/bin/bash\necho test", 0755},
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
hdr := &tar.Header{
|
||||
Name: file.Name,
|
||||
Mode: file.Mode,
|
||||
Size: int64(len(file.Body)),
|
||||
}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write([]byte(file.Body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Write tarball to file
|
||||
if err := os.WriteFile(tarballPath, buf.Bytes(), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Test installation
|
||||
i, err := NewForSource(tarballPath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
|
||||
// Verify it's detected as LocalInstaller
|
||||
localInstaller, ok := i.(*LocalInstaller)
|
||||
if !ok {
|
||||
t.Fatal("expected LocalInstaller")
|
||||
}
|
||||
|
||||
if !localInstaller.isArchive {
|
||||
t.Fatal("expected isArchive to be true")
|
||||
}
|
||||
|
||||
if err := Install(i); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectedPath := helmpath.DataPath("plugins", "test-plugin")
|
||||
if i.Path() != expectedPath {
|
||||
t.Fatalf("expected path %q, got %q", expectedPath, i.Path())
|
||||
}
|
||||
|
||||
// Verify plugin was installed
|
||||
if _, err := os.Stat(i.Path()); err != nil {
|
||||
t.Fatalf("plugin not found at %s: %v", i.Path(), err)
|
||||
}
|
||||
}
|
@ -0,0 +1,301 @@
|
||||
/*
|
||||
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 installer
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"helm.sh/helm/v4/internal/plugin"
|
||||
"helm.sh/helm/v4/internal/plugin/cache"
|
||||
"helm.sh/helm/v4/internal/third_party/dep/fs"
|
||||
"helm.sh/helm/v4/pkg/cli"
|
||||
"helm.sh/helm/v4/pkg/getter"
|
||||
"helm.sh/helm/v4/pkg/helmpath"
|
||||
"helm.sh/helm/v4/pkg/registry"
|
||||
)
|
||||
|
||||
// Ensure OCIInstaller implements Verifier
|
||||
var _ Verifier = (*OCIInstaller)(nil)
|
||||
|
||||
// OCIInstaller installs plugins from OCI registries
|
||||
type OCIInstaller struct {
|
||||
CacheDir string
|
||||
PluginName string
|
||||
base
|
||||
settings *cli.EnvSettings
|
||||
getter getter.Getter
|
||||
// Cached data to avoid duplicate downloads
|
||||
pluginData []byte
|
||||
provData []byte
|
||||
}
|
||||
|
||||
// NewOCIInstaller creates a new OCIInstaller with optional getter options
|
||||
func NewOCIInstaller(source string, options ...getter.Option) (*OCIInstaller, error) {
|
||||
// Extract plugin name from OCI reference using robust registry parsing
|
||||
pluginName, err := registry.GetPluginName(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key, err := cache.Key(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
settings := cli.New()
|
||||
|
||||
// Always add plugin artifact type and any provided options
|
||||
pluginOptions := append([]getter.Option{getter.WithArtifactType("plugin")}, options...)
|
||||
getterProvider, err := getter.NewOCIGetter(pluginOptions...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
i := &OCIInstaller{
|
||||
CacheDir: helmpath.CachePath("plugins", key),
|
||||
PluginName: pluginName,
|
||||
base: newBase(source),
|
||||
settings: settings,
|
||||
getter: getterProvider,
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
// Install downloads and installs a plugin from OCI registry
|
||||
// Implements Installer.
|
||||
func (i *OCIInstaller) Install() error {
|
||||
slog.Debug("pulling OCI plugin", "source", i.Source)
|
||||
|
||||
// Ensure plugin data is cached
|
||||
if i.pluginData == nil {
|
||||
pluginData, err := i.getter.Get(i.Source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to pull plugin from %s: %w", i.Source, err)
|
||||
}
|
||||
i.pluginData = pluginData.Bytes()
|
||||
}
|
||||
|
||||
// Extract metadata to get the actual plugin name and version
|
||||
metadata, err := plugin.ExtractTgzPluginMetadata(bytes.NewReader(i.pluginData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract plugin metadata from tarball: %w", err)
|
||||
}
|
||||
filename := fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version)
|
||||
|
||||
tarballPath := helmpath.DataPath("plugins", filename)
|
||||
if err := os.MkdirAll(filepath.Dir(tarballPath), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create plugins directory: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(tarballPath, i.pluginData, 0644); err != nil {
|
||||
return fmt.Errorf("failed to save tarball: %w", err)
|
||||
}
|
||||
|
||||
// Ensure prov data is cached if available
|
||||
if i.provData == nil {
|
||||
// Try to download .prov file if it exists
|
||||
provSource := i.Source + ".prov"
|
||||
if provData, err := i.getter.Get(provSource); err == nil {
|
||||
i.provData = provData.Bytes()
|
||||
}
|
||||
}
|
||||
|
||||
// Save prov file if we have the data
|
||||
if i.provData != nil {
|
||||
provPath := tarballPath + ".prov"
|
||||
if err := os.WriteFile(provPath, i.provData, 0644); err != nil {
|
||||
slog.Debug("failed to save provenance file", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a gzip compressed file
|
||||
if len(i.pluginData) < 2 || i.pluginData[0] != 0x1f || i.pluginData[1] != 0x8b {
|
||||
return fmt.Errorf("plugin data is not a gzip compressed archive")
|
||||
}
|
||||
|
||||
// Create cache directory
|
||||
if err := os.MkdirAll(i.CacheDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
|
||||
// Extract as gzipped tar
|
||||
if err := extractTarGz(bytes.NewReader(i.pluginData), i.CacheDir); err != nil {
|
||||
return fmt.Errorf("failed to extract plugin: %w", err)
|
||||
}
|
||||
|
||||
// Verify plugin.yaml exists - check root and subdirectories
|
||||
pluginDir := i.CacheDir
|
||||
if !isPlugin(pluginDir) {
|
||||
// Check if plugin.yaml is in a subdirectory
|
||||
entries, err := os.ReadDir(i.CacheDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
foundPluginDir := ""
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
subDir := filepath.Join(i.CacheDir, entry.Name())
|
||||
if isPlugin(subDir) {
|
||||
foundPluginDir = subDir
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if foundPluginDir == "" {
|
||||
return ErrMissingMetadata
|
||||
}
|
||||
|
||||
// Use the subdirectory as the plugin directory
|
||||
pluginDir = foundPluginDir
|
||||
}
|
||||
|
||||
// Copy from cache to final destination
|
||||
src, err := filepath.Abs(pluginDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Debug("copying", "source", src, "path", i.Path())
|
||||
return fs.CopyDir(src, i.Path())
|
||||
}
|
||||
|
||||
// Update updates a plugin by reinstalling it
|
||||
func (i *OCIInstaller) Update() error {
|
||||
// For OCI, update means removing the old version and installing the new one
|
||||
if err := os.RemoveAll(i.Path()); err != nil {
|
||||
return err
|
||||
}
|
||||
return i.Install()
|
||||
}
|
||||
|
||||
// Path is where the plugin will be installed
|
||||
func (i OCIInstaller) Path() string {
|
||||
if i.Source == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(i.settings.PluginsDirectory, i.PluginName)
|
||||
}
|
||||
|
||||
// extractTarGz extracts a gzipped tar archive to a directory
|
||||
func extractTarGz(r io.Reader, targetDir string) error {
|
||||
gzr, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gzr.Close()
|
||||
|
||||
return extractTar(gzr, targetDir)
|
||||
}
|
||||
|
||||
// extractTar extracts a tar archive to a directory
|
||||
func extractTar(r io.Reader, targetDir string) error {
|
||||
tarReader := tar.NewReader(r)
|
||||
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path, err := cleanJoin(targetDir, header.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(path, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outFile, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer outFile.Close()
|
||||
if _, err := io.Copy(outFile, tarReader); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeXGlobalHeader, tar.TypeXHeader:
|
||||
// Skip these
|
||||
continue
|
||||
default:
|
||||
return fmt.Errorf("unknown type: %b in %s", header.Typeflag, header.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SupportsVerification returns true since OCI plugins can be verified
|
||||
func (i *OCIInstaller) SupportsVerification() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// GetVerificationData downloads and caches plugin and provenance data from OCI registry for verification
|
||||
func (i *OCIInstaller) GetVerificationData() (archiveData, provData []byte, filename string, err error) {
|
||||
slog.Debug("getting verification data for OCI plugin", "source", i.Source)
|
||||
|
||||
// Download plugin data once and cache it
|
||||
if i.pluginData == nil {
|
||||
pluginDataBuffer, err := i.getter.Get(i.Source)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to pull plugin from %s: %w", i.Source, err)
|
||||
}
|
||||
i.pluginData = pluginDataBuffer.Bytes()
|
||||
}
|
||||
|
||||
// Download prov data once and cache it if available
|
||||
if i.provData == nil {
|
||||
provSource := i.Source + ".prov"
|
||||
// Calling getter.Get again is reasonable because: 1. The OCI registry client already optimizes the underlying network calls
|
||||
// 2. Both calls use the same underlying manifest and memory store 3. The second .prov call is very fast since the data is already pulled
|
||||
provDataBuffer, err := i.getter.Get(provSource)
|
||||
if err != nil {
|
||||
// If provenance file doesn't exist, set provData to nil
|
||||
// The verification logic will handle this gracefully
|
||||
i.provData = nil
|
||||
} else {
|
||||
i.provData = provDataBuffer.Bytes()
|
||||
}
|
||||
}
|
||||
|
||||
// Extract metadata to get the filename
|
||||
metadata, err := plugin.ExtractTgzPluginMetadata(bytes.NewReader(i.pluginData))
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to extract plugin metadata from tarball: %w", err)
|
||||
}
|
||||
filename = fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version)
|
||||
|
||||
slog.Debug("got verification data for OCI plugin", "filename", filename)
|
||||
return i.pluginData, i.provData, filename, nil
|
||||
}
|
@ -0,0 +1,806 @@
|
||||
/*
|
||||
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 installer // import "helm.sh/helm/v4/internal/plugin/installer"
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/opencontainers/go-digest"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
|
||||
"helm.sh/helm/v4/internal/test/ensure"
|
||||
"helm.sh/helm/v4/pkg/cli"
|
||||
"helm.sh/helm/v4/pkg/getter"
|
||||
"helm.sh/helm/v4/pkg/helmpath"
|
||||
)
|
||||
|
||||
var _ Installer = new(OCIInstaller)
|
||||
|
||||
// createTestPluginTarGz creates a test plugin tar.gz with plugin.yaml
|
||||
func createTestPluginTarGz(t *testing.T, pluginName string) []byte {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
gzWriter := gzip.NewWriter(&buf)
|
||||
tarWriter := tar.NewWriter(gzWriter)
|
||||
|
||||
// Add plugin.yaml
|
||||
pluginYAML := fmt.Sprintf(`name: %s
|
||||
version: "1.0.0"
|
||||
description: "Test plugin for OCI installer"
|
||||
command: "$HELM_PLUGIN_DIR/bin/%s"
|
||||
`, pluginName, pluginName)
|
||||
header := &tar.Header{
|
||||
Name: "plugin.yaml",
|
||||
Mode: 0644,
|
||||
Size: int64(len(pluginYAML)),
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
if err := tarWriter.WriteHeader(header); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tarWriter.Write([]byte(pluginYAML)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Add bin directory
|
||||
dirHeader := &tar.Header{
|
||||
Name: "bin/",
|
||||
Mode: 0755,
|
||||
Typeflag: tar.TypeDir,
|
||||
}
|
||||
if err := tarWriter.WriteHeader(dirHeader); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Add executable
|
||||
execContent := fmt.Sprintf("#!/bin/sh\necho '%s test plugin'", pluginName)
|
||||
execHeader := &tar.Header{
|
||||
Name: fmt.Sprintf("bin/%s", pluginName),
|
||||
Mode: 0755,
|
||||
Size: int64(len(execContent)),
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
if err := tarWriter.WriteHeader(execHeader); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tarWriter.Write([]byte(execContent)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tarWriter.Close()
|
||||
gzWriter.Close()
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// mockOCIRegistryWithArtifactType creates a mock OCI registry server using the new artifact type approach
|
||||
func mockOCIRegistryWithArtifactType(t *testing.T, pluginName string) (*httptest.Server, string) {
|
||||
t.Helper()
|
||||
|
||||
pluginData := createTestPluginTarGz(t, pluginName)
|
||||
layerDigest := fmt.Sprintf("sha256:%x", sha256Sum(pluginData))
|
||||
|
||||
// Create empty config data (as per OCI v1.1+ spec)
|
||||
configData := []byte("{}")
|
||||
configDigest := fmt.Sprintf("sha256:%x", sha256Sum(configData))
|
||||
|
||||
// Create manifest with artifact type
|
||||
manifest := ocispec.Manifest{
|
||||
MediaType: ocispec.MediaTypeImageManifest,
|
||||
ArtifactType: "application/vnd.helm.plugin.v1+json", // Using artifact type
|
||||
Config: ocispec.Descriptor{
|
||||
MediaType: "application/vnd.oci.empty.v1+json", // Empty config
|
||||
Digest: digest.Digest(configDigest),
|
||||
Size: int64(len(configData)),
|
||||
},
|
||||
Layers: []ocispec.Descriptor{
|
||||
{
|
||||
MediaType: "application/vnd.oci.image.layer.v1.tar",
|
||||
Digest: digest.Digest(layerDigest),
|
||||
Size: int64(len(pluginData)),
|
||||
Annotations: map[string]string{
|
||||
ocispec.AnnotationTitle: pluginName + "-1.0.0.tgz", // Layer named with version
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
manifestData, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifestDigest := fmt.Sprintf("sha256:%x", sha256Sum(manifestData))
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v2/") && !strings.Contains(r.URL.Path, "/manifests/") && !strings.Contains(r.URL.Path, "/blobs/"):
|
||||
// API version check
|
||||
w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("{}"))
|
||||
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/manifests/") && strings.Contains(r.URL.Path, pluginName):
|
||||
// Return manifest
|
||||
w.Header().Set("Content-Type", ocispec.MediaTypeImageManifest)
|
||||
w.Header().Set("Docker-Content-Digest", manifestDigest)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(manifestData)
|
||||
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/blobs/"+layerDigest):
|
||||
// Return layer data
|
||||
w.Header().Set("Content-Type", "application/vnd.oci.image.layer.v1.tar")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(pluginData)
|
||||
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/blobs/"+configDigest):
|
||||
// Return config data
|
||||
w.Header().Set("Content-Type", "application/vnd.oci.empty.v1+json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(configData)
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
|
||||
// Parse server URL to get host:port format for OCI reference
|
||||
serverURL, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registryHost := serverURL.Host
|
||||
|
||||
return server, registryHost
|
||||
}
|
||||
|
||||
// sha256Sum calculates SHA256 sum of data
|
||||
func sha256Sum(data []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write(data)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func TestNewOCIInstaller(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
expectName string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid OCI reference with tag",
|
||||
source: "oci://ghcr.io/user/plugin-name:v1.0.0",
|
||||
expectName: "plugin-name",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid OCI reference with digest",
|
||||
source: "oci://ghcr.io/user/plugin-name@sha256:1234567890abcdef",
|
||||
expectName: "plugin-name",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid OCI reference without tag",
|
||||
source: "oci://ghcr.io/user/plugin-name",
|
||||
expectName: "plugin-name",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid OCI reference with multiple path segments",
|
||||
source: "oci://registry.example.com/org/team/plugin-name:latest",
|
||||
expectName: "plugin-name",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid OCI reference - no path",
|
||||
source: "oci://registry.example.com",
|
||||
expectName: "",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "valid OCI reference - single path segment",
|
||||
source: "oci://registry.example.com/plugin",
|
||||
expectName: "plugin",
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
installer, err := NewOCIInstaller(tt.source)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check all fields thoroughly
|
||||
if installer.PluginName != tt.expectName {
|
||||
t.Errorf("expected plugin name %s, got %s", tt.expectName, installer.PluginName)
|
||||
}
|
||||
|
||||
if installer.Source != tt.source {
|
||||
t.Errorf("expected source %s, got %s", tt.source, installer.Source)
|
||||
}
|
||||
|
||||
if installer.CacheDir == "" {
|
||||
t.Error("expected non-empty cache directory")
|
||||
}
|
||||
|
||||
if !strings.Contains(installer.CacheDir, "plugins") {
|
||||
t.Errorf("expected cache directory to contain 'plugins', got %s", installer.CacheDir)
|
||||
}
|
||||
|
||||
if installer.settings == nil {
|
||||
t.Error("expected settings to be initialized")
|
||||
}
|
||||
|
||||
// Check that Path() method works
|
||||
expectedPath := helmpath.DataPath("plugins", tt.expectName)
|
||||
if installer.Path() != expectedPath {
|
||||
t.Errorf("expected path %s, got %s", expectedPath, installer.Path())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOCIInstaller_Path(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
pluginName string
|
||||
expectPath string
|
||||
}{
|
||||
{
|
||||
name: "valid plugin name",
|
||||
source: "oci://ghcr.io/user/plugin-name:v1.0.0",
|
||||
pluginName: "plugin-name",
|
||||
expectPath: helmpath.DataPath("plugins", "plugin-name"),
|
||||
},
|
||||
{
|
||||
name: "empty source",
|
||||
source: "",
|
||||
pluginName: "",
|
||||
expectPath: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
installer := &OCIInstaller{
|
||||
PluginName: tt.pluginName,
|
||||
base: newBase(tt.source),
|
||||
settings: cli.New(),
|
||||
}
|
||||
|
||||
path := installer.Path()
|
||||
if path != tt.expectPath {
|
||||
t.Errorf("expected path %s, got %s", tt.expectPath, path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOCIInstaller_Install(t *testing.T) {
|
||||
// Set up isolated test environment
|
||||
ensure.HelmHome(t)
|
||||
|
||||
pluginName := "test-plugin-basic"
|
||||
server, registryHost := mockOCIRegistryWithArtifactType(t, pluginName)
|
||||
defer server.Close()
|
||||
|
||||
// Test OCI reference
|
||||
source := fmt.Sprintf("oci://%s/%s:latest", registryHost, pluginName)
|
||||
|
||||
// Test with plain HTTP (since test server uses HTTP)
|
||||
installer, err := NewOCIInstaller(source, getter.WithPlainHTTP(true))
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
// The OCI installer uses helmpath.DataPath, which is isolated by ensure.HelmHome(t)
|
||||
actualPath := installer.Path()
|
||||
t.Logf("Installer will use path: %s", actualPath)
|
||||
|
||||
// Install the plugin
|
||||
if err := Install(installer); err != nil {
|
||||
t.Fatalf("Expected installation to succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Verify plugin was installed to the correct location
|
||||
if !isPlugin(actualPath) {
|
||||
t.Errorf("Expected plugin directory %s to contain plugin.yaml", actualPath)
|
||||
}
|
||||
|
||||
// Debug: list what was actually created
|
||||
if entries, err := os.ReadDir(actualPath); err != nil {
|
||||
t.Fatalf("Could not read plugin directory %s: %v", actualPath, err)
|
||||
} else {
|
||||
t.Logf("Plugin directory %s contains:", actualPath)
|
||||
for _, entry := range entries {
|
||||
t.Logf(" - %s", entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the plugin.yaml file exists and is valid
|
||||
pluginFile := filepath.Join(actualPath, "plugin.yaml")
|
||||
if _, err := os.Stat(pluginFile); err != nil {
|
||||
t.Errorf("Expected plugin.yaml to exist, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOCIInstaller_Install_WithGetterOptions(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
pluginName string
|
||||
options []getter.Option
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "plain HTTP",
|
||||
pluginName: "example-cli-plain-http",
|
||||
options: []getter.Option{getter.WithPlainHTTP(true)},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "insecure skip TLS verify",
|
||||
pluginName: "example-cli-insecure",
|
||||
options: []getter.Option{getter.WithPlainHTTP(true), getter.WithInsecureSkipVerifyTLS(true)},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "with timeout",
|
||||
pluginName: "example-cli-timeout",
|
||||
options: []getter.Option{getter.WithPlainHTTP(true), getter.WithTimeout(30 * time.Second)},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Set up isolated test environment for each subtest
|
||||
ensure.HelmHome(t)
|
||||
|
||||
server, registryHost := mockOCIRegistryWithArtifactType(t, tc.pluginName)
|
||||
defer server.Close()
|
||||
|
||||
source := fmt.Sprintf("oci://%s/%s:latest", registryHost, tc.pluginName)
|
||||
|
||||
installer, err := NewOCIInstaller(source, tc.options...)
|
||||
if err != nil {
|
||||
if !tc.wantErr {
|
||||
t.Fatalf("Expected no error creating installer, got %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// The installer now uses our isolated test directory
|
||||
actualPath := installer.Path()
|
||||
|
||||
// Install the plugin
|
||||
err = Install(installer)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Expected installation to fail, but it succeeded")
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Expected installation to succeed, got error: %v", err)
|
||||
} else {
|
||||
// Verify plugin was installed to the actual path
|
||||
if !isPlugin(actualPath) {
|
||||
t.Errorf("Expected plugin directory %s to contain plugin.yaml", actualPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOCIInstaller_Install_AlreadyExists(t *testing.T) {
|
||||
// Set up isolated test environment
|
||||
ensure.HelmHome(t)
|
||||
|
||||
pluginName := "test-plugin-exists"
|
||||
server, registryHost := mockOCIRegistryWithArtifactType(t, pluginName)
|
||||
defer server.Close()
|
||||
|
||||
source := fmt.Sprintf("oci://%s/%s:latest", registryHost, pluginName)
|
||||
installer, err := NewOCIInstaller(source, getter.WithPlainHTTP(true))
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
// First install should succeed
|
||||
if err := Install(installer); err != nil {
|
||||
t.Fatalf("Expected first installation to succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Verify plugin was installed
|
||||
if !isPlugin(installer.Path()) {
|
||||
t.Errorf("Expected plugin directory %s to contain plugin.yaml", installer.Path())
|
||||
}
|
||||
|
||||
// Second install should fail with "plugin already exists"
|
||||
err = Install(installer)
|
||||
if err == nil {
|
||||
t.Error("Expected error when installing plugin that already exists")
|
||||
} else if !strings.Contains(err.Error(), "plugin already exists") {
|
||||
t.Errorf("Expected 'plugin already exists' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOCIInstaller_Update(t *testing.T) {
|
||||
// Set up isolated test environment
|
||||
ensure.HelmHome(t)
|
||||
|
||||
pluginName := "test-plugin-update"
|
||||
server, registryHost := mockOCIRegistryWithArtifactType(t, pluginName)
|
||||
defer server.Close()
|
||||
|
||||
source := fmt.Sprintf("oci://%s/%s:latest", registryHost, pluginName)
|
||||
installer, err := NewOCIInstaller(source, getter.WithPlainHTTP(true))
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
// Test update when plugin does not exist - should fail
|
||||
err = Update(installer)
|
||||
if err == nil {
|
||||
t.Error("Expected error when updating plugin that does not exist")
|
||||
} else if !strings.Contains(err.Error(), "plugin does not exist") {
|
||||
t.Errorf("Expected 'plugin does not exist' error, got: %v", err)
|
||||
}
|
||||
|
||||
// Install plugin first
|
||||
if err := Install(installer); err != nil {
|
||||
t.Fatalf("Expected installation to succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Verify plugin was installed
|
||||
if !isPlugin(installer.Path()) {
|
||||
t.Errorf("Expected plugin directory %s to contain plugin.yaml", installer.Path())
|
||||
}
|
||||
|
||||
// Test update when plugin exists - should succeed
|
||||
// For OCI, Update() removes old version and reinstalls
|
||||
if err := Update(installer); err != nil {
|
||||
t.Errorf("Expected update to succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Verify plugin is still installed after update
|
||||
if !isPlugin(installer.Path()) {
|
||||
t.Errorf("Expected plugin directory %s to contain plugin.yaml after update", installer.Path())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOCIInstaller_Install_ComponentExtraction(t *testing.T) {
|
||||
// Test that we can extract a plugin archive properly
|
||||
// This tests the extraction logic that Install() uses
|
||||
tempDir := t.TempDir()
|
||||
pluginName := "test-plugin-extract"
|
||||
|
||||
pluginData := createTestPluginTarGz(t, pluginName)
|
||||
|
||||
// Test extraction
|
||||
err := extractTarGz(bytes.NewReader(pluginData), tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to extract plugin: %v", err)
|
||||
}
|
||||
|
||||
// Verify plugin.yaml exists
|
||||
pluginYAMLPath := filepath.Join(tempDir, "plugin.yaml")
|
||||
if _, err := os.Stat(pluginYAMLPath); os.IsNotExist(err) {
|
||||
t.Errorf("plugin.yaml not found after extraction")
|
||||
}
|
||||
|
||||
// Verify bin directory exists
|
||||
binPath := filepath.Join(tempDir, "bin")
|
||||
if _, err := os.Stat(binPath); os.IsNotExist(err) {
|
||||
t.Errorf("bin directory not found after extraction")
|
||||
}
|
||||
|
||||
// Verify executable exists and has correct permissions
|
||||
execPath := filepath.Join(tempDir, "bin", pluginName)
|
||||
if info, err := os.Stat(execPath); err != nil {
|
||||
t.Errorf("executable not found: %v", err)
|
||||
} else if info.Mode()&0111 == 0 {
|
||||
t.Errorf("file is not executable")
|
||||
}
|
||||
|
||||
// Verify this would be recognized as a plugin
|
||||
if !isPlugin(tempDir) {
|
||||
t.Errorf("extracted directory is not a valid plugin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTarGz(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create a test tar.gz file
|
||||
var buf bytes.Buffer
|
||||
gzWriter := gzip.NewWriter(&buf)
|
||||
tarWriter := tar.NewWriter(gzWriter)
|
||||
|
||||
// Add a test file to the archive
|
||||
testContent := "test content"
|
||||
header := &tar.Header{
|
||||
Name: "test-file.txt",
|
||||
Mode: 0644,
|
||||
Size: int64(len(testContent)),
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
|
||||
if err := tarWriter.WriteHeader(header); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := tarWriter.Write([]byte(testContent)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Add a test directory
|
||||
dirHeader := &tar.Header{
|
||||
Name: "test-dir/",
|
||||
Mode: 0755,
|
||||
Typeflag: tar.TypeDir,
|
||||
}
|
||||
|
||||
if err := tarWriter.WriteHeader(dirHeader); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tarWriter.Close()
|
||||
gzWriter.Close()
|
||||
|
||||
// Test extraction
|
||||
err := extractTarGz(bytes.NewReader(buf.Bytes()), tempDir)
|
||||
if err != nil {
|
||||
t.Errorf("extractTarGz failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify extracted file
|
||||
extractedFile := filepath.Join(tempDir, "test-file.txt")
|
||||
content, err := os.ReadFile(extractedFile)
|
||||
if err != nil {
|
||||
t.Errorf("failed to read extracted file: %v", err)
|
||||
}
|
||||
|
||||
if string(content) != testContent {
|
||||
t.Errorf("expected content %s, got %s", testContent, string(content))
|
||||
}
|
||||
|
||||
// Verify extracted directory
|
||||
extractedDir := filepath.Join(tempDir, "test-dir")
|
||||
if _, err := os.Stat(extractedDir); os.IsNotExist(err) {
|
||||
t.Errorf("extracted directory does not exist: %s", extractedDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTarGz_InvalidGzip(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Test with invalid gzip data
|
||||
invalidGzipData := []byte("not gzip data")
|
||||
err := extractTarGz(bytes.NewReader(invalidGzipData), tempDir)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid gzip data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTar_UnknownFileType(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create a test tar file
|
||||
var buf bytes.Buffer
|
||||
tarWriter := tar.NewWriter(&buf)
|
||||
|
||||
// Add a test file
|
||||
testContent := "test content"
|
||||
header := &tar.Header{
|
||||
Name: "test-file.txt",
|
||||
Mode: 0644,
|
||||
Size: int64(len(testContent)),
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
|
||||
if err := tarWriter.WriteHeader(header); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := tarWriter.Write([]byte(testContent)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Test unknown file type
|
||||
unknownHeader := &tar.Header{
|
||||
Name: "unknown-type",
|
||||
Mode: 0644,
|
||||
Typeflag: tar.TypeSymlink, // Use a type that's not handled
|
||||
}
|
||||
|
||||
if err := tarWriter.WriteHeader(unknownHeader); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tarWriter.Close()
|
||||
|
||||
// Test extraction - should fail due to unknown type
|
||||
err := extractTar(bytes.NewReader(buf.Bytes()), tempDir)
|
||||
if err == nil {
|
||||
t.Error("expected error for unknown tar file type")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "unknown type") {
|
||||
t.Errorf("expected 'unknown type' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTar_SuccessfulExtraction(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Since we can't easily create extended headers with Go's tar package,
|
||||
// we'll test the logic that skips them by creating a simple tar with regular files
|
||||
// and then testing that the extraction works correctly.
|
||||
|
||||
// Create a test tar file
|
||||
var buf bytes.Buffer
|
||||
tarWriter := tar.NewWriter(&buf)
|
||||
|
||||
// Add a regular file
|
||||
testContent := "test content"
|
||||
header := &tar.Header{
|
||||
Name: "test-file.txt",
|
||||
Mode: 0644,
|
||||
Size: int64(len(testContent)),
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
|
||||
if err := tarWriter.WriteHeader(header); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := tarWriter.Write([]byte(testContent)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tarWriter.Close()
|
||||
|
||||
// Test extraction
|
||||
err := extractTar(bytes.NewReader(buf.Bytes()), tempDir)
|
||||
if err != nil {
|
||||
t.Errorf("extractTar failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the regular file was extracted
|
||||
extractedFile := filepath.Join(tempDir, "test-file.txt")
|
||||
content, err := os.ReadFile(extractedFile)
|
||||
if err != nil {
|
||||
t.Errorf("failed to read extracted file: %v", err)
|
||||
}
|
||||
|
||||
if string(content) != testContent {
|
||||
t.Errorf("expected content %s, got %s", testContent, string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOCIInstaller_Install_PlainHTTPOption(t *testing.T) {
|
||||
// Test that PlainHTTP option is properly passed to getter
|
||||
source := "oci://example.com/test-plugin:v1.0.0"
|
||||
|
||||
// Test with PlainHTTP=false (default)
|
||||
installer1, err := NewOCIInstaller(source)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create installer: %v", err)
|
||||
}
|
||||
if installer1.getter == nil {
|
||||
t.Error("getter should be initialized")
|
||||
}
|
||||
|
||||
// Test with PlainHTTP=true
|
||||
installer2, err := NewOCIInstaller(source, getter.WithPlainHTTP(true))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create installer with PlainHTTP=true: %v", err)
|
||||
}
|
||||
if installer2.getter == nil {
|
||||
t.Error("getter should be initialized with PlainHTTP=true")
|
||||
}
|
||||
|
||||
// Both installers should have the same basic properties
|
||||
if installer1.PluginName != installer2.PluginName {
|
||||
t.Error("plugin names should match")
|
||||
}
|
||||
if installer1.Source != installer2.Source {
|
||||
t.Error("sources should match")
|
||||
}
|
||||
|
||||
// Test with multiple options
|
||||
installer3, err := NewOCIInstaller(source,
|
||||
getter.WithPlainHTTP(true),
|
||||
getter.WithBasicAuth("user", "pass"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create installer with multiple options: %v", err)
|
||||
}
|
||||
if installer3.getter == nil {
|
||||
t.Error("getter should be initialized with multiple options")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOCIInstaller_Install_ValidationErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
layerData []byte
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "non-gzip layer",
|
||||
layerData: []byte("not gzip data"),
|
||||
expectError: true,
|
||||
errorMsg: "is not a gzip compressed archive",
|
||||
},
|
||||
{
|
||||
name: "empty layer",
|
||||
layerData: []byte{},
|
||||
expectError: true,
|
||||
errorMsg: "is not a gzip compressed archive",
|
||||
},
|
||||
{
|
||||
name: "single byte layer",
|
||||
layerData: []byte{0x1f},
|
||||
expectError: true,
|
||||
errorMsg: "is not a gzip compressed archive",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test the gzip validation logic that's used in the Install method
|
||||
if len(tt.layerData) < 2 || tt.layerData[0] != 0x1f || tt.layerData[1] != 0x8b {
|
||||
// This matches the validation in the Install method
|
||||
if !tt.expectError {
|
||||
t.Error("expected valid gzip data")
|
||||
}
|
||||
if !strings.Contains(tt.errorMsg, "is not a gzip compressed archive") {
|
||||
t.Errorf("expected error message to contain 'is not a gzip compressed archive'")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,80 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package installer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"helm.sh/helm/v4/internal/plugin"
|
||||
)
|
||||
|
||||
// detectPluginRoot searches for plugin.yaml in the extracted directory
|
||||
// and returns the path to the directory containing it.
|
||||
// This handles cases where the tarball contains the plugin in a subdirectory.
|
||||
func detectPluginRoot(extractDir string) (string, error) {
|
||||
// First check if plugin.yaml is at the root
|
||||
if _, err := os.Stat(filepath.Join(extractDir, plugin.PluginFileName)); err == nil {
|
||||
return extractDir, nil
|
||||
}
|
||||
|
||||
// Otherwise, look for plugin.yaml in subdirectories (only one level deep)
|
||||
entries, err := os.ReadDir(extractDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
subdir := filepath.Join(extractDir, entry.Name())
|
||||
if _, err := os.Stat(filepath.Join(subdir, plugin.PluginFileName)); err == nil {
|
||||
return subdir, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("plugin.yaml not found in %s or its immediate subdirectories", extractDir)
|
||||
}
|
||||
|
||||
// validatePluginName checks if the plugin directory name matches the plugin name
|
||||
// from plugin.yaml when the plugin is in a subdirectory.
|
||||
func validatePluginName(pluginRoot string, expectedName string) error {
|
||||
// Only validate if plugin is in a subdirectory
|
||||
dirName := filepath.Base(pluginRoot)
|
||||
if dirName == expectedName {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load plugin.yaml to get the actual name
|
||||
p, err := plugin.LoadDir(pluginRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load plugin from %s: %w", pluginRoot, err)
|
||||
}
|
||||
|
||||
m := p.Metadata()
|
||||
actualName := m.Name
|
||||
|
||||
// For now, just log a warning if names don't match
|
||||
// In the future, we might want to enforce this more strictly
|
||||
if actualName != dirName && actualName != strings.TrimSuffix(expectedName, filepath.Ext(expectedName)) {
|
||||
// This is just informational - not an error
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -0,0 +1,165 @@
|
||||
/*
|
||||
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 installer
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetectPluginRoot(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(dir string) error
|
||||
expectRoot string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "plugin.yaml at root",
|
||||
setup: func(dir string) error {
|
||||
return os.WriteFile(filepath.Join(dir, "plugin.yaml"), []byte("name: test"), 0644)
|
||||
},
|
||||
expectRoot: ".",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "plugin.yaml in subdirectory",
|
||||
setup: func(dir string) error {
|
||||
subdir := filepath.Join(dir, "my-plugin")
|
||||
if err := os.MkdirAll(subdir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(subdir, "plugin.yaml"), []byte("name: test"), 0644)
|
||||
},
|
||||
expectRoot: "my-plugin",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "no plugin.yaml",
|
||||
setup: func(dir string) error {
|
||||
return os.WriteFile(filepath.Join(dir, "README.md"), []byte("test"), 0644)
|
||||
},
|
||||
expectRoot: "",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "plugin.yaml in nested subdirectory (should not find)",
|
||||
setup: func(dir string) error {
|
||||
subdir := filepath.Join(dir, "outer", "inner")
|
||||
if err := os.MkdirAll(subdir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(subdir, "plugin.yaml"), []byte("name: test"), 0644)
|
||||
},
|
||||
expectRoot: "",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := tt.setup(dir); err != nil {
|
||||
t.Fatalf("Setup failed: %v", err)
|
||||
}
|
||||
|
||||
root, err := detectPluginRoot(dir)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
expectedPath := dir
|
||||
if tt.expectRoot != "." {
|
||||
expectedPath = filepath.Join(dir, tt.expectRoot)
|
||||
}
|
||||
if root != expectedPath {
|
||||
t.Errorf("Expected root %s but got %s", expectedPath, root)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePluginName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(dir string) error
|
||||
pluginRoot string
|
||||
expectedName string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "matching directory and plugin name",
|
||||
setup: func(dir string) error {
|
||||
subdir := filepath.Join(dir, "my-plugin")
|
||||
if err := os.MkdirAll(subdir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
yaml := `name: my-plugin
|
||||
version: 1.0.0
|
||||
usage: test
|
||||
description: test`
|
||||
return os.WriteFile(filepath.Join(subdir, "plugin.yaml"), []byte(yaml), 0644)
|
||||
},
|
||||
pluginRoot: "my-plugin",
|
||||
expectedName: "my-plugin",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "different directory and plugin name",
|
||||
setup: func(dir string) error {
|
||||
subdir := filepath.Join(dir, "wrong-name")
|
||||
if err := os.MkdirAll(subdir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
yaml := `name: my-plugin
|
||||
version: 1.0.0
|
||||
usage: test
|
||||
description: test`
|
||||
return os.WriteFile(filepath.Join(subdir, "plugin.yaml"), []byte(yaml), 0644)
|
||||
},
|
||||
pluginRoot: "wrong-name",
|
||||
expectedName: "wrong-name",
|
||||
expectError: false, // Currently we don't error on mismatch
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := tt.setup(dir); err != nil {
|
||||
t.Fatalf("Setup failed: %v", err)
|
||||
}
|
||||
|
||||
pluginRoot := filepath.Join(dir, tt.pluginRoot)
|
||||
err := validatePluginName(pluginRoot, tt.expectedName)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,421 @@
|
||||
/*
|
||||
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 installer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"helm.sh/helm/v4/internal/plugin"
|
||||
"helm.sh/helm/v4/internal/test/ensure"
|
||||
)
|
||||
|
||||
func TestInstallWithOptions_VerifyMissingProvenance(t *testing.T) {
|
||||
ensure.HelmHome(t)
|
||||
|
||||
// Create a temporary plugin tarball without .prov file
|
||||
pluginDir := createTestPluginDir(t)
|
||||
pluginTgz := createTarballFromPluginDir(t, pluginDir)
|
||||
defer os.Remove(pluginTgz)
|
||||
|
||||
// Create local installer
|
||||
installer, err := NewLocalInstaller(pluginTgz)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create installer: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(installer.Path())
|
||||
|
||||
// Capture stderr to check warning message
|
||||
oldStderr := os.Stderr
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stderr = w
|
||||
|
||||
// Install with verification enabled (should warn but succeed)
|
||||
result, err := InstallWithOptions(installer, Options{Verify: true, Keyring: "dummy"})
|
||||
|
||||
// Restore stderr and read captured output
|
||||
w.Close()
|
||||
os.Stderr = oldStderr
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
output := buf.String()
|
||||
|
||||
// Should succeed with nil result (no verification performed)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected installation to succeed despite missing .prov file, got error: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil verification result when .prov file is missing, got: %+v", result)
|
||||
}
|
||||
|
||||
// Should contain warning message
|
||||
expectedWarning := "WARNING: No provenance file found for plugin"
|
||||
if !strings.Contains(output, expectedWarning) {
|
||||
t.Errorf("Expected warning message '%s' in output, got: %s", expectedWarning, output)
|
||||
}
|
||||
|
||||
// Plugin should be installed
|
||||
if _, err := os.Stat(installer.Path()); os.IsNotExist(err) {
|
||||
t.Errorf("Plugin should be installed at %s", installer.Path())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWithOptions_VerifyWithValidProvenance(t *testing.T) {
|
||||
ensure.HelmHome(t)
|
||||
|
||||
// Create a temporary plugin tarball with valid .prov file
|
||||
pluginDir := createTestPluginDir(t)
|
||||
pluginTgz := createTarballFromPluginDir(t, pluginDir)
|
||||
|
||||
provFile := pluginTgz + ".prov"
|
||||
createProvFile(t, provFile, pluginTgz, "")
|
||||
defer os.Remove(provFile)
|
||||
|
||||
// Create keyring with test key (empty for testing)
|
||||
keyring := createTestKeyring(t)
|
||||
defer os.Remove(keyring)
|
||||
|
||||
// Create local installer
|
||||
installer, err := NewLocalInstaller(pluginTgz)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create installer: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(installer.Path())
|
||||
|
||||
// Install with verification enabled
|
||||
// This will fail signature verification but pass hash validation
|
||||
result, err := InstallWithOptions(installer, Options{Verify: true, Keyring: keyring})
|
||||
|
||||
// Should fail due to invalid signature (empty keyring) but we test that it gets past the hash check
|
||||
if err == nil {
|
||||
t.Fatalf("Expected installation to fail with empty keyring")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "plugin verification failed") {
|
||||
t.Errorf("Expected plugin verification failed error, got: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil verification result when verification fails, got: %+v", result)
|
||||
}
|
||||
|
||||
// Plugin should not be installed due to verification failure
|
||||
if _, err := os.Stat(installer.Path()); !os.IsNotExist(err) {
|
||||
t.Errorf("Plugin should not be installed when verification fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWithOptions_VerifyWithInvalidProvenance(t *testing.T) {
|
||||
ensure.HelmHome(t)
|
||||
|
||||
// Create a temporary plugin tarball with invalid .prov file
|
||||
pluginDir := createTestPluginDir(t)
|
||||
pluginTgz := createTarballFromPluginDir(t, pluginDir)
|
||||
defer os.Remove(pluginTgz)
|
||||
|
||||
provFile := pluginTgz + ".prov"
|
||||
createProvFileInvalidFormat(t, provFile)
|
||||
defer os.Remove(provFile)
|
||||
|
||||
// Create keyring with test key
|
||||
keyring := createTestKeyring(t)
|
||||
defer os.Remove(keyring)
|
||||
|
||||
// Create local installer
|
||||
installer, err := NewLocalInstaller(pluginTgz)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create installer: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(installer.Path())
|
||||
|
||||
// Install with verification enabled (should fail)
|
||||
result, err := InstallWithOptions(installer, Options{Verify: true, Keyring: keyring})
|
||||
|
||||
// Should fail with verification error
|
||||
if err == nil {
|
||||
t.Fatalf("Expected installation with invalid .prov file to fail")
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil verification result when verification fails, got: %+v", result)
|
||||
}
|
||||
|
||||
// Should contain verification failure message
|
||||
expectedError := "plugin verification failed"
|
||||
if !strings.Contains(err.Error(), expectedError) {
|
||||
t.Errorf("Expected error message '%s', got: %s", expectedError, err.Error())
|
||||
}
|
||||
|
||||
// Plugin should not be installed
|
||||
if _, err := os.Stat(installer.Path()); !os.IsNotExist(err) {
|
||||
t.Errorf("Plugin should not be installed when verification fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWithOptions_NoVerifyRequested(t *testing.T) {
|
||||
ensure.HelmHome(t)
|
||||
|
||||
// Create a temporary plugin tarball without .prov file
|
||||
pluginDir := createTestPluginDir(t)
|
||||
pluginTgz := createTarballFromPluginDir(t, pluginDir)
|
||||
defer os.Remove(pluginTgz)
|
||||
|
||||
// Create local installer
|
||||
installer, err := NewLocalInstaller(pluginTgz)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create installer: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(installer.Path())
|
||||
|
||||
// Install without verification (should succeed without any verification)
|
||||
result, err := InstallWithOptions(installer, Options{Verify: false})
|
||||
|
||||
// Should succeed with no verification
|
||||
if err != nil {
|
||||
t.Fatalf("Expected installation without verification to succeed, got error: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil verification result when verification is disabled, got: %+v", result)
|
||||
}
|
||||
|
||||
// Plugin should be installed
|
||||
if _, err := os.Stat(installer.Path()); os.IsNotExist(err) {
|
||||
t.Errorf("Plugin should be installed at %s", installer.Path())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWithOptions_VerifyDirectoryNotSupported(t *testing.T) {
|
||||
ensure.HelmHome(t)
|
||||
|
||||
// Create a directory-based plugin (not an archive)
|
||||
pluginDir := createTestPluginDir(t)
|
||||
|
||||
// Create local installer for directory
|
||||
installer, err := NewLocalInstaller(pluginDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create installer: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(installer.Path())
|
||||
|
||||
// Install with verification should fail (directories don't support verification)
|
||||
result, err := InstallWithOptions(installer, Options{Verify: true, Keyring: "dummy"})
|
||||
|
||||
// Should fail with verification not supported error
|
||||
if err == nil {
|
||||
t.Fatalf("Expected installation to fail with verification not supported error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--verify is only supported for plugin tarballs") {
|
||||
t.Errorf("Expected verification not supported error, got: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil verification result when verification fails, got: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWithOptions_VerifyMismatchedProvenance(t *testing.T) {
|
||||
ensure.HelmHome(t)
|
||||
|
||||
// Create plugin tarball
|
||||
pluginDir := createTestPluginDir(t)
|
||||
pluginTgz := createTarballFromPluginDir(t, pluginDir)
|
||||
defer os.Remove(pluginTgz)
|
||||
|
||||
provFile := pluginTgz + ".prov"
|
||||
// Create provenance file with wrong hash (for a different file)
|
||||
createProvFile(t, provFile, pluginTgz, "sha256:wronghash")
|
||||
defer os.Remove(provFile)
|
||||
|
||||
// Create keyring with test key
|
||||
keyring := createTestKeyring(t)
|
||||
defer os.Remove(keyring)
|
||||
|
||||
// Create local installer
|
||||
installer, err := NewLocalInstaller(pluginTgz)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create installer: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(installer.Path())
|
||||
|
||||
// Install with verification should fail due to hash mismatch
|
||||
result, err := InstallWithOptions(installer, Options{Verify: true, Keyring: keyring})
|
||||
|
||||
// Should fail with verification error
|
||||
if err == nil {
|
||||
t.Fatalf("Expected installation to fail with hash mismatch")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "plugin verification failed") {
|
||||
t.Errorf("Expected plugin verification failed error, got: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil verification result when verification fails, got: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWithOptions_VerifyProvenanceAccessError(t *testing.T) {
|
||||
ensure.HelmHome(t)
|
||||
|
||||
// Create plugin tarball
|
||||
pluginDir := createTestPluginDir(t)
|
||||
pluginTgz := createTarballFromPluginDir(t, pluginDir)
|
||||
defer os.Remove(pluginTgz)
|
||||
|
||||
// Create a .prov file but make it inaccessible (simulate permission error)
|
||||
provFile := pluginTgz + ".prov"
|
||||
if err := os.WriteFile(provFile, []byte("test"), 0000); err != nil {
|
||||
t.Fatalf("Failed to create inaccessible provenance file: %v", err)
|
||||
}
|
||||
defer os.Remove(provFile)
|
||||
|
||||
// Create keyring
|
||||
keyring := createTestKeyring(t)
|
||||
defer os.Remove(keyring)
|
||||
|
||||
// Create local installer
|
||||
installer, err := NewLocalInstaller(pluginTgz)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create installer: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(installer.Path())
|
||||
|
||||
// Install with verification should fail due to access error
|
||||
result, err := InstallWithOptions(installer, Options{Verify: true, Keyring: keyring})
|
||||
|
||||
// Should fail with access error (either at stat level or during verification)
|
||||
if err == nil {
|
||||
t.Fatalf("Expected installation to fail with provenance file access error")
|
||||
}
|
||||
// The error could be either "failed to access provenance file" or "plugin verification failed"
|
||||
// depending on when the permission error occurs
|
||||
if !strings.Contains(err.Error(), "failed to access provenance file") &&
|
||||
!strings.Contains(err.Error(), "plugin verification failed") {
|
||||
t.Errorf("Expected provenance file access or verification error, got: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil verification result when verification fails, got: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for test setup
|
||||
|
||||
func createTestPluginDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
// Create temporary directory with plugin structure
|
||||
tmpDir := t.TempDir()
|
||||
pluginDir := filepath.Join(tmpDir, "test-plugin")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create plugin directory: %v", err)
|
||||
}
|
||||
|
||||
// Create plugin.yaml using the standardized v1 format
|
||||
pluginYaml := `apiVersion: v1
|
||||
name: test-plugin
|
||||
type: cli/v1
|
||||
runtime: subprocess
|
||||
version: 1.0.0
|
||||
runtimeConfig:
|
||||
platformCommand:
|
||||
- command: echo`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(pluginYaml), 0644); err != nil {
|
||||
t.Fatalf("Failed to create plugin.yaml: %v", err)
|
||||
}
|
||||
|
||||
return pluginDir
|
||||
}
|
||||
|
||||
func createTarballFromPluginDir(t *testing.T, pluginDir string) string {
|
||||
t.Helper()
|
||||
|
||||
// Create tarball using the plugin package helper
|
||||
tmpDir := filepath.Dir(pluginDir)
|
||||
tgzPath := filepath.Join(tmpDir, "test-plugin-1.0.0.tgz")
|
||||
tarFile, err := os.Create(tgzPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create tarball file: %v", err)
|
||||
}
|
||||
defer tarFile.Close()
|
||||
|
||||
if err := plugin.CreatePluginTarball(pluginDir, "test-plugin", tarFile); err != nil {
|
||||
t.Fatalf("Failed to create tarball: %v", err)
|
||||
}
|
||||
|
||||
return tgzPath
|
||||
}
|
||||
|
||||
func createProvFile(t *testing.T, provFile, pluginTgz, hash string) {
|
||||
t.Helper()
|
||||
|
||||
var hashStr string
|
||||
if hash == "" {
|
||||
// Calculate actual hash of the tarball for realistic testing
|
||||
data, err := os.ReadFile(pluginTgz)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read tarball for hashing: %v", err)
|
||||
}
|
||||
hashSum := sha256.Sum256(data)
|
||||
hashStr = fmt.Sprintf("sha256:%x", hashSum)
|
||||
} else {
|
||||
// Use provided hash (could be wrong for testing)
|
||||
hashStr = hash
|
||||
}
|
||||
|
||||
// Create properly formatted provenance file with specified hash
|
||||
provContent := fmt.Sprintf(`-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA256
|
||||
|
||||
name: test-plugin
|
||||
version: 1.0.0
|
||||
description: Test plugin for verification
|
||||
files:
|
||||
test-plugin-1.0.0.tgz: %s
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
Version: GnuPG v1
|
||||
|
||||
iQEcBAEBCAAGBQJktest...
|
||||
-----END PGP SIGNATURE-----
|
||||
`, hashStr)
|
||||
if err := os.WriteFile(provFile, []byte(provContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create provenance file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createProvFileInvalidFormat(t *testing.T, provFile string) {
|
||||
t.Helper()
|
||||
|
||||
// Create an invalid provenance file (not PGP signed format)
|
||||
invalidProv := "This is not a valid PGP signed message"
|
||||
if err := os.WriteFile(provFile, []byte(invalidProv), 0644); err != nil {
|
||||
t.Fatalf("Failed to create invalid provenance file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createTestKeyring(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
// Create a temporary keyring file
|
||||
tmpDir := t.TempDir()
|
||||
keyringPath := filepath.Join(tmpDir, "pubring.gpg")
|
||||
|
||||
// Create empty keyring for testing
|
||||
if err := os.WriteFile(keyringPath, []byte{}, 0644); err != nil {
|
||||
t.Fatalf("Failed to create test keyring: %v", err)
|
||||
}
|
||||
|
||||
return keyringPath
|
||||
}
|
@ -0,0 +1,266 @@
|
||||
/*
|
||||
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))
|
||||
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))
|
||||
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
|
||||
}
|
@ -0,0 +1,267 @@
|
||||
/*
|
||||
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"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPeekAPIVersion(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
data []byte
|
||||
expected string
|
||||
}{
|
||||
"v1": {
|
||||
data: []byte(`---
|
||||
apiVersion: v1
|
||||
name: "test-plugin"
|
||||
`),
|
||||
expected: "v1",
|
||||
},
|
||||
"legacy": { // No apiVersion field
|
||||
data: []byte(`---
|
||||
name: "test-plugin"
|
||||
`),
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
version, err := peekAPIVersion(bytes.NewReader(tc.data))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, version)
|
||||
})
|
||||
}
|
||||
|
||||
// invalid yaml
|
||||
{
|
||||
data := []byte(`bad yaml`)
|
||||
_, err := peekAPIVersion(bytes.NewReader(data))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDir(t *testing.T) {
|
||||
|
||||
makeMetadata := func(apiVersion string) Metadata {
|
||||
usage := "hello [params]..."
|
||||
if apiVersion == "legacy" {
|
||||
usage = "" // Legacy plugins don't have Usage field for command syntax
|
||||
}
|
||||
return Metadata{
|
||||
APIVersion: apiVersion,
|
||||
Name: fmt.Sprintf("hello-%s", apiVersion),
|
||||
Version: "0.1.0",
|
||||
Type: "cli/v1",
|
||||
Runtime: "subprocess",
|
||||
Config: &ConfigCLI{
|
||||
Usage: usage,
|
||||
ShortHelp: "echo hello message",
|
||||
LongHelp: "description",
|
||||
IgnoreFlags: true,
|
||||
},
|
||||
RuntimeConfig: &RuntimeConfigSubprocess{
|
||||
PlatformCommands: []PlatformCommand{
|
||||
{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "${HELM_PLUGIN_DIR}/hello.sh"}},
|
||||
{OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "${HELM_PLUGIN_DIR}/hello.ps1"}},
|
||||
},
|
||||
PlatformHooks: map[string][]PlatformCommand{
|
||||
Install: {
|
||||
{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"installing...\""}},
|
||||
{OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"installing...\""}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
testCases := map[string]struct {
|
||||
dirname string
|
||||
apiVersion string
|
||||
expect Metadata
|
||||
}{
|
||||
"legacy": {
|
||||
dirname: "testdata/plugdir/good/hello-legacy",
|
||||
apiVersion: "legacy",
|
||||
expect: makeMetadata("legacy"),
|
||||
},
|
||||
"v1": {
|
||||
dirname: "testdata/plugdir/good/hello-v1",
|
||||
apiVersion: "v1",
|
||||
expect: makeMetadata("v1"),
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
plug, err := LoadDir(tc.dirname)
|
||||
require.NoError(t, err, "error loading plugin from %s", tc.dirname)
|
||||
|
||||
assert.Equal(t, tc.dirname, plug.Dir())
|
||||
assert.EqualValues(t, tc.expect, plug.Metadata())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDirDuplicateEntries(t *testing.T) {
|
||||
testCases := map[string]string{
|
||||
"legacy": "testdata/plugdir/bad/duplicate-entries-legacy",
|
||||
"v1": "testdata/plugdir/bad/duplicate-entries-v1",
|
||||
}
|
||||
for name, dirname := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := LoadDir(dirname)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDirGetter(t *testing.T) {
|
||||
dirname := "testdata/plugdir/good/getter"
|
||||
|
||||
expect := Metadata{
|
||||
Name: "getter",
|
||||
Version: "1.2.3",
|
||||
Type: "getter/v1",
|
||||
APIVersion: "v1",
|
||||
Runtime: "subprocess",
|
||||
Config: &ConfigGetter{
|
||||
Protocols: []string{"myprotocol", "myprotocols"},
|
||||
},
|
||||
RuntimeConfig: &RuntimeConfigSubprocess{
|
||||
ProtocolCommands: []SubprocessProtocolCommand{
|
||||
{
|
||||
Protocols: []string{"myprotocol", "myprotocols"},
|
||||
Command: "echo getter",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
plug, err := LoadDir(dirname)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, dirname, plug.Dir())
|
||||
assert.Equal(t, expect, plug.Metadata())
|
||||
}
|
||||
|
||||
func TestPostRenderer(t *testing.T) {
|
||||
dirname := "testdata/plugdir/good/postrenderer-v1"
|
||||
|
||||
expect := Metadata{
|
||||
Name: "postrenderer-v1",
|
||||
Version: "1.2.3",
|
||||
Type: "postrenderer/v1",
|
||||
APIVersion: "v1",
|
||||
Runtime: "subprocess",
|
||||
Config: &ConfigPostrenderer{},
|
||||
RuntimeConfig: &RuntimeConfigSubprocess{
|
||||
PlatformCommands: []PlatformCommand{
|
||||
{
|
||||
Command: "${HELM_PLUGIN_DIR}/sed-test.sh",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
plug, err := LoadDir(dirname)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, dirname, plug.Dir())
|
||||
assert.Equal(t, expect, plug.Metadata())
|
||||
}
|
||||
|
||||
func TestDetectDuplicates(t *testing.T) {
|
||||
plugs := []Plugin{
|
||||
mockSubprocessCLIPlugin(t, "foo"),
|
||||
mockSubprocessCLIPlugin(t, "bar"),
|
||||
}
|
||||
if err := detectDuplicates(plugs); err != nil {
|
||||
t.Error("no duplicates in the first set")
|
||||
}
|
||||
plugs = append(plugs, mockSubprocessCLIPlugin(t, "foo"))
|
||||
if err := detectDuplicates(plugs); err == nil {
|
||||
t.Error("duplicates in the second set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAll(t *testing.T) {
|
||||
// Verify that empty dir loads:
|
||||
{
|
||||
plugs, err := LoadAll("testdata")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, plugs, 0)
|
||||
}
|
||||
|
||||
basedir := "testdata/plugdir/good"
|
||||
plugs, err := LoadAll(basedir)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, plugs, "expected plugins to be loaded from %s", basedir)
|
||||
|
||||
plugsMap := map[string]Plugin{}
|
||||
for _, p := range plugs {
|
||||
plugsMap[p.Metadata().Name] = p
|
||||
}
|
||||
|
||||
assert.Len(t, plugsMap, 7)
|
||||
assert.Contains(t, plugsMap, "downloader")
|
||||
assert.Contains(t, plugsMap, "echo-legacy")
|
||||
assert.Contains(t, plugsMap, "echo-v1")
|
||||
assert.Contains(t, plugsMap, "getter")
|
||||
assert.Contains(t, plugsMap, "hello-legacy")
|
||||
assert.Contains(t, plugsMap, "hello-v1")
|
||||
assert.Contains(t, plugsMap, "postrenderer-v1")
|
||||
}
|
||||
|
||||
func TestFindPlugins(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
plugdirs string
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: "plugdirs is empty",
|
||||
plugdirs: "",
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "plugdirs isn't dir",
|
||||
plugdirs: "./plugin_test.go",
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "plugdirs doesn't have plugin",
|
||||
plugdirs: ".",
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "normal",
|
||||
plugdirs: "./testdata/plugdir/good",
|
||||
expected: 7,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(t.Name(), func(t *testing.T) {
|
||||
plugin, err := LoadAll(c.plugdirs)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, plugin, c.expected, "expected %d plugins, got %d", c.expected, len(plugin))
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,224 @@
|
||||
/*
|
||||
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 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 &ConfigGetter{
|
||||
Protocols: protocols,
|
||||
}
|
||||
case "cli/v1":
|
||||
return &ConfigCLI{
|
||||
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(d))
|
||||
}
|
||||
}
|
||||
return &RuntimeConfigSubprocess{
|
||||
PlatformCommands: m.PlatformCommands,
|
||||
Command: m.Command,
|
||||
PlatformHooks: m.PlatformHooks,
|
||||
Hooks: m.Hooks,
|
||||
ProtocolCommands: protocolCommands,
|
||||
}
|
||||
}
|
||||
|
||||
func fromMetadataV1(mv1 MetadataV1) (*Metadata, error) {
|
||||
|
||||
config, err := convertMetadataConfig(mv1.Type, mv1.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
runtimeConfig, err := convertMetdataRuntimeConfig(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 convertMetadataConfig(pluginType string, configRaw map[string]any) (Config, error) {
|
||||
var err error
|
||||
var config Config
|
||||
|
||||
switch pluginType {
|
||||
case "test/v1":
|
||||
config, err = remarshalConfig[*schema.ConfigTestV1](configRaw)
|
||||
case "cli/v1":
|
||||
config, err = remarshalConfig[*ConfigCLI](configRaw)
|
||||
case "getter/v1":
|
||||
config, err = remarshalConfig[*ConfigGetter](configRaw)
|
||||
case "postrenderer/v1":
|
||||
config, err = remarshalConfig[*ConfigPostrenderer](configRaw)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported plugin type: %s", pluginType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal config for %s plugin type: %w", pluginType, err)
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func convertMetdataRuntimeConfig(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"`
|
||||
|
||||
// PlatformCommands is the plugin command, with a platform selector and support for args.
|
||||
PlatformCommands []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")
|
||||
}
|
||||
m.Usage = sanitizeString(m.Usage)
|
||||
|
||||
if len(m.PlatformCommands) > 0 && len(m.Command) > 0 {
|
||||
return fmt.Errorf("both platformCommand and command are set")
|
||||
}
|
||||
|
||||
if len(m.PlatformHooks) > 0 && len(m.Hooks) > 0 {
|
||||
return fmt.Errorf("both platformHooks and hooks are set")
|
||||
}
|
||||
|
||||
// Validate downloader plugins
|
||||
for i, downloader := range m.Downloaders {
|
||||
if downloader.Command == "" {
|
||||
return fmt.Errorf("downloader %d has empty command", i)
|
||||
}
|
||||
if len(downloader.Protocols) == 0 {
|
||||
return fmt.Errorf("downloader %d has no protocols", i)
|
||||
}
|
||||
for j, protocol := range downloader.Protocols {
|
||||
if protocol == "" {
|
||||
return fmt.Errorf("downloader %d has empty protocol at index %d", i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sanitizeString normalize spaces and removes non-printable characters.
|
||||
func sanitizeString(str string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if unicode.IsSpace(r) {
|
||||
return ' '
|
||||
}
|
||||
if unicode.IsPrint(r) {
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, str)
|
||||
}
|
@ -0,0 +1,141 @@
|
||||
/*
|
||||
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 (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidatePluginData(t *testing.T) {
|
||||
|
||||
// A mock plugin with no commands
|
||||
mockNoCommand := mockSubprocessCLIPlugin(t, "foo")
|
||||
mockNoCommand.metadata.RuntimeConfig = &RuntimeConfigSubprocess{
|
||||
PlatformCommands: []PlatformCommand{},
|
||||
PlatformHooks: map[string][]PlatformCommand{},
|
||||
}
|
||||
|
||||
// A mock plugin with legacy commands
|
||||
mockLegacyCommand := mockSubprocessCLIPlugin(t, "foo")
|
||||
mockLegacyCommand.metadata.RuntimeConfig = &RuntimeConfigSubprocess{
|
||||
PlatformCommands: []PlatformCommand{},
|
||||
Command: "echo \"mock plugin\"",
|
||||
PlatformHooks: map[string][]PlatformCommand{},
|
||||
Hooks: map[string]string{
|
||||
Install: "echo installing...",
|
||||
},
|
||||
}
|
||||
|
||||
// A mock plugin with a command also set
|
||||
mockWithCommand := mockSubprocessCLIPlugin(t, "foo")
|
||||
mockWithCommand.metadata.RuntimeConfig = &RuntimeConfigSubprocess{
|
||||
PlatformCommands: []PlatformCommand{
|
||||
{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"mock plugin\""}},
|
||||
},
|
||||
Command: "echo \"mock plugin\"",
|
||||
}
|
||||
|
||||
// A mock plugin with a hooks also set
|
||||
mockWithHooks := mockSubprocessCLIPlugin(t, "foo")
|
||||
mockWithHooks.metadata.RuntimeConfig = &RuntimeConfigSubprocess{
|
||||
PlatformCommands: []PlatformCommand{
|
||||
{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"mock plugin\""}},
|
||||
},
|
||||
PlatformHooks: map[string][]PlatformCommand{
|
||||
Install: {
|
||||
{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"installing...\""}},
|
||||
},
|
||||
},
|
||||
Hooks: map[string]string{
|
||||
Install: "echo installing...",
|
||||
},
|
||||
}
|
||||
|
||||
for i, item := range []struct {
|
||||
pass bool
|
||||
plug Plugin
|
||||
errString string
|
||||
}{
|
||||
{true, mockSubprocessCLIPlugin(t, "abcdefghijklmnopqrstuvwxyz0123456789_-ABC"), ""},
|
||||
{true, mockSubprocessCLIPlugin(t, "foo-bar-FOO-BAR_1234"), ""},
|
||||
{false, mockSubprocessCLIPlugin(t, "foo -bar"), "invalid name"},
|
||||
{false, mockSubprocessCLIPlugin(t, "$foo -bar"), "invalid name"}, // Test leading chars
|
||||
{false, mockSubprocessCLIPlugin(t, "foo -bar "), "invalid name"}, // Test trailing chars
|
||||
{false, mockSubprocessCLIPlugin(t, "foo\nbar"), "invalid name"}, // Test newline
|
||||
{true, mockNoCommand, ""}, // Test no command metadata works
|
||||
{true, mockLegacyCommand, ""}, // Test legacy command metadata works
|
||||
{false, mockWithCommand, "runtime config validation failed: both platformCommand and command are set"}, // Test platformCommand and command both set fails
|
||||
{false, mockWithHooks, "runtime config validation failed: both platformHooks and hooks are set"}, // Test platformHooks and hooks both set fails
|
||||
} {
|
||||
err := item.plug.Metadata().Validate()
|
||||
if item.pass && err != nil {
|
||||
t.Errorf("failed to validate case %d: %s", i, err)
|
||||
} else if !item.pass && err == nil {
|
||||
t.Errorf("expected case %d to fail", i)
|
||||
}
|
||||
if !item.pass && err.Error() != item.errString {
|
||||
t.Errorf("index [%d]: expected the following error: %s, but got: %s", i, item.errString, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataValidateMultipleErrors(t *testing.T) {
|
||||
// Create metadata with multiple validation issues
|
||||
metadata := Metadata{
|
||||
Name: "invalid name with spaces", // Invalid name
|
||||
APIVersion: "", // Empty API version
|
||||
Type: "", // Empty type
|
||||
Runtime: "", // Empty runtime
|
||||
Config: nil, // Missing config
|
||||
RuntimeConfig: nil, // Missing runtime config
|
||||
}
|
||||
|
||||
err := metadata.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation to fail with multiple errors")
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
|
||||
// Check that all expected errors are present in the joined error
|
||||
expectedErrors := []string{
|
||||
"invalid name",
|
||||
"empty APIVersion",
|
||||
"empty type field",
|
||||
"empty runtime field",
|
||||
"missing config field",
|
||||
"missing runtimeConfig field",
|
||||
}
|
||||
|
||||
for _, expectedErr := range expectedErrors {
|
||||
if !strings.Contains(errStr, expectedErr) {
|
||||
t.Errorf("expected error to contain %q, but got: %v", expectedErr, errStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that the error contains the correct number of error messages
|
||||
errorCount := 0
|
||||
for _, expectedErr := range expectedErrors {
|
||||
if strings.Contains(errStr, expectedErr) {
|
||||
errorCount++
|
||||
}
|
||||
}
|
||||
|
||||
if errorCount < len(expectedErrors) {
|
||||
t.Errorf("expected %d errors, but only found %d in: %v", len(expectedErrors), errorCount, errStr)
|
||||
}
|
||||
}
|
@ -0,0 +1,67 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// MetadataV1 is the APIVersion V1 plugin.yaml format
|
||||
type MetadataV1 struct {
|
||||
// APIVersion specifies the plugin API version
|
||||
APIVersion string `yaml:"apiVersion"`
|
||||
|
||||
// Name is the name of the plugin
|
||||
Name string `yaml:"name"`
|
||||
|
||||
// Type of plugin (eg, cli/v1, getter/v1, postrenderer/v1)
|
||||
Type string `yaml:"type"`
|
||||
|
||||
// Runtime specifies the runtime type (subprocess, wasm)
|
||||
Runtime string `yaml:"runtime"`
|
||||
|
||||
// Version is a SemVer 2 version of the plugin.
|
||||
Version string `yaml:"version"`
|
||||
|
||||
// SourceURL is the URL where this plugin can be found
|
||||
SourceURL string `yaml:"sourceURL,omitempty"`
|
||||
|
||||
// Config contains the type-specific configuration for this plugin
|
||||
Config map[string]any `yaml:"config"`
|
||||
|
||||
// RuntimeConfig contains the runtime-specific configuration
|
||||
RuntimeConfig map[string]any `yaml:"runtimeConfig"`
|
||||
}
|
||||
|
||||
func (m *MetadataV1) Validate() error {
|
||||
if !validPluginName.MatchString(m.Name) {
|
||||
return fmt.Errorf("invalid plugin `name`")
|
||||
}
|
||||
|
||||
if m.APIVersion != "v1" {
|
||||
return fmt.Errorf("invalid `apiVersion`: %q", m.APIVersion)
|
||||
}
|
||||
|
||||
if m.Type == "" {
|
||||
return fmt.Errorf("`type` missing")
|
||||
}
|
||||
|
||||
if m.Runtime == "" {
|
||||
return fmt.Errorf("`runtime` missing")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -0,0 +1,58 @@
|
||||
/*
|
||||
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 (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mockSubprocessCLIPlugin(t *testing.T, pluginName string) *SubprocessPluginRuntime {
|
||||
t.Helper()
|
||||
|
||||
rc := RuntimeConfigSubprocess{
|
||||
PlatformCommands: []PlatformCommand{
|
||||
{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"mock plugin\""}},
|
||||
{OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"mock plugin\""}},
|
||||
},
|
||||
PlatformHooks: map[string][]PlatformCommand{
|
||||
Install: {
|
||||
{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"installing...\""}},
|
||||
{OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"installing...\""}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
pluginDir := t.TempDir()
|
||||
|
||||
return &SubprocessPluginRuntime{
|
||||
metadata: Metadata{
|
||||
Name: pluginName,
|
||||
Version: "v0.1.2",
|
||||
Type: "cli/v1",
|
||||
APIVersion: "v1",
|
||||
Runtime: "subprocess",
|
||||
Config: &ConfigCLI{
|
||||
Usage: "Mock plugin",
|
||||
ShortHelp: "Mock plugin",
|
||||
LongHelp: "Mock plugin for testing",
|
||||
IgnoreFlags: false,
|
||||
},
|
||||
RuntimeConfig: &rc,
|
||||
},
|
||||
pluginDir: pluginDir, // NOTE: dir is empty (ie. plugin.yaml is not present)
|
||||
RuntimeConfig: rc,
|
||||
}
|
||||
}
|
@ -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.
|
||||
*/
|
||||
|
||||
/*
|
||||
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.TypeOf(schema.InputMessageTestV1{}),
|
||||
outputType: reflect.TypeOf(schema.OutputMessageTestV1{}),
|
||||
configType: reflect.TypeOf(schema.ConfigTestV1{}),
|
||||
},
|
||||
{
|
||||
pluginType: "cli/v1",
|
||||
inputType: reflect.TypeOf(schema.InputMessageCLIV1{}),
|
||||
outputType: reflect.TypeOf(schema.OutputMessageCLIV1{}),
|
||||
configType: reflect.TypeOf(ConfigCLI{}),
|
||||
},
|
||||
{
|
||||
pluginType: "getter/v1",
|
||||
inputType: reflect.TypeOf(schema.InputMessageGetterV1{}),
|
||||
outputType: reflect.TypeOf(schema.OutputMessageGetterV1{}),
|
||||
configType: reflect.TypeOf(ConfigGetter{}),
|
||||
},
|
||||
}
|
||||
|
||||
var pluginTypesIndex = func() map[string]*pluginTypeMeta {
|
||||
result := make(map[string]*pluginTypeMeta, len(pluginTypes))
|
||||
for _, m := range pluginTypes {
|
||||
result[m.pluginType] = &m
|
||||
}
|
||||
return result
|
||||
}()
|
@ -0,0 +1,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 plugin
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"helm.sh/helm/v4/internal/plugin/schema"
|
||||
)
|
||||
|
||||
func TestMakeOutputMessage(t *testing.T) {
|
||||
ptm := pluginTypesIndex["getter/v1"]
|
||||
outputType := reflect.Zero(ptm.outputType).Interface()
|
||||
assert.IsType(t, schema.OutputMessageGetterV1{}, outputType)
|
||||
|
||||
}
|
||||
|
||||
func TestMakeConfig(t *testing.T) {
|
||||
ptm := pluginTypesIndex["getter/v1"]
|
||||
config := reflect.New(ptm.configType).Interface().(Config)
|
||||
assert.IsType(t, &ConfigGetter{}, config)
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
/*
|
||||
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 (
|
||||
"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
|
||||
}
|
@ -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{
|
||||
Code: 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,124 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
|
||||
"helm.sh/helm/v4/internal/plugin/schema"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type pluginRaw struct {
|
||||
Metadata Metadata
|
||||
Dir string
|
||||
}
|
||||
|
||||
func buildLoadExtismPlugin(t *testing.T, dir string) pluginRaw {
|
||||
t.Helper()
|
||||
|
||||
pluginFile := filepath.Join(dir, PluginFileName)
|
||||
|
||||
metadataData, err := os.ReadFile(pluginFile)
|
||||
require.NoError(t, err)
|
||||
|
||||
m, err := loadMetadata(metadataData)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "extism/v1", m.Runtime, "expected plugin runtime to be extism/v1")
|
||||
|
||||
cmd := exec.Command("make", "-C", dir)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
require.NoError(t, cmd.Run(), "failed to build plugin in %q", dir)
|
||||
|
||||
return pluginRaw{
|
||||
Metadata: *m,
|
||||
Dir: dir,
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeConfigExtismV1Validate(t *testing.T) {
|
||||
rc := RuntimeConfigExtismV1{}
|
||||
err := rc.Validate()
|
||||
assert.NoError(t, err, "expected no error for empty RuntimeConfigExtismV1")
|
||||
}
|
||||
|
||||
func TestRuntimeExtismV1InvokePlugin(t *testing.T) {
|
||||
r := RuntimeExtismV1{}
|
||||
|
||||
pr := buildLoadExtismPlugin(t, "testdata/src/extismv1-test")
|
||||
require.Equal(t, "test/v1", pr.Metadata.Type)
|
||||
|
||||
p, err := r.CreatePlugin(pr.Dir, &pr.Metadata)
|
||||
|
||||
assert.NoError(t, err, "expected no error creating plugin")
|
||||
assert.NotNil(t, p, "expected plugin to be created")
|
||||
|
||||
output, err := p.Invoke(t.Context(), &Input{
|
||||
Message: schema.InputMessageTestV1{
|
||||
Name: "Phippy",
|
||||
},
|
||||
})
|
||||
require.Nil(t, err)
|
||||
|
||||
msg := output.Message.(schema.OutputMessageTestV1)
|
||||
assert.Equal(t, "Hello, Phippy! (6)", msg.Greeting)
|
||||
}
|
||||
|
||||
func TestBuildManifest(t *testing.T) {
|
||||
rc := &RuntimeConfigExtismV1{
|
||||
Memory: RuntimeConfigExtismV1Memory{
|
||||
MaxPages: 8,
|
||||
MaxHTTPResponseBytes: 81920,
|
||||
MaxVarBytes: 8192,
|
||||
},
|
||||
FileSystem: RuntimeConfigExtismV1FileSystem{
|
||||
CreateTempDir: true,
|
||||
},
|
||||
Config: map[string]string{"CONFIG_KEY": "config_value"},
|
||||
AllowedHosts: []string{"example.com", "api.example.com"},
|
||||
Timeout: 5000,
|
||||
}
|
||||
|
||||
expected := extism.Manifest{
|
||||
Wasm: []extism.Wasm{
|
||||
extism.WasmFile{
|
||||
Path: "/path/to/plugin/plugin.wasm",
|
||||
Name: "/path/to/plugin/plugin.wasm",
|
||||
},
|
||||
},
|
||||
Memory: &extism.ManifestMemory{
|
||||
MaxPages: 8,
|
||||
MaxHttpResponseBytes: 81920,
|
||||
MaxVarBytes: 8192,
|
||||
},
|
||||
Config: map[string]string{"CONFIG_KEY": "config_value"},
|
||||
AllowedHosts: []string{"example.com", "api.example.com"},
|
||||
AllowedPaths: map[string]string{"/tmp/foo": "/tmp"},
|
||||
Timeout: 5000,
|
||||
}
|
||||
|
||||
manifest, err := buildManifest("/path/to/plugin", "/tmp/foo", rc)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expected, manifest)
|
||||
}
|
@ -0,0 +1,289 @@
|
||||
/*
|
||||
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"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
|
||||
"helm.sh/helm/v4/internal/plugin/schema"
|
||||
"helm.sh/helm/v4/pkg/cli"
|
||||
)
|
||||
|
||||
// SubprocessProtocolCommand maps a given protocol to the getter command used to retrieve artifacts for that protcol
|
||||
type SubprocessProtocolCommand 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"`
|
||||
}
|
||||
|
||||
// RuntimeConfigSubprocess implements RuntimeConfig for RuntimeSubprocess
|
||||
type RuntimeConfigSubprocess struct {
|
||||
// PlatformCommand is a list containing a plugin command, with a platform selector and support for args.
|
||||
PlatformCommands []PlatformCommand `yaml:"platformCommand"`
|
||||
// Command is the plugin command, as a single string.
|
||||
// DEPRECATED: Use PlatformCommand instead. Remove in Helm 4.
|
||||
Command string `yaml:"command"`
|
||||
// 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. Remove in Helm 4.
|
||||
Hooks Hooks `yaml:"hooks"`
|
||||
// ProtocolCommands field is used if the plugin supply downloader mechanism
|
||||
// for special protocols.
|
||||
// (This is a compatibility hangover from the old plugin downloader mechanism, which was extended to support multiple
|
||||
// protocols in a given plugin)
|
||||
ProtocolCommands []SubprocessProtocolCommand `yaml:"protocolCommands,omitempty"`
|
||||
}
|
||||
|
||||
var _ RuntimeConfig = (*RuntimeConfigSubprocess)(nil)
|
||||
|
||||
func (r *RuntimeConfigSubprocess) GetType() string { return "subprocess" }
|
||||
|
||||
func (r *RuntimeConfigSubprocess) Validate() error {
|
||||
if len(r.PlatformCommands) > 0 && len(r.Command) > 0 {
|
||||
return fmt.Errorf("both platformCommand and command are set")
|
||||
}
|
||||
if len(r.PlatformHooks) > 0 && len(r.Hooks) > 0 {
|
||||
return fmt.Errorf("both platformHooks and hooks are set")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type RuntimeSubprocess struct{}
|
||||
|
||||
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)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SubprocessPluginRuntime implements the Plugin interface for subprocess execution
|
||||
type SubprocessPluginRuntime struct {
|
||||
metadata Metadata
|
||||
pluginDir string
|
||||
RuntimeConfig RuntimeConfigSubprocess
|
||||
}
|
||||
|
||||
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)
|
||||
prog := exec.Command(mainCmdExp, argv...)
|
||||
prog.Env = env
|
||||
prog.Stdin = stdin
|
||||
prog.Stdout = stdout
|
||||
prog.Stderr = stderr
|
||||
|
||||
if err := prog.Run(); err != nil {
|
||||
if eerr, ok := err.(*exec.ExitError); ok {
|
||||
os.Stderr.Write(eerr.Stderr)
|
||||
status := eerr.Sys().(syscall.WaitStatus)
|
||||
return &InvokeExecError{
|
||||
Err: fmt.Errorf("plugin %q exited with error", r.metadata.Name),
|
||||
Code: status.ExitStatus(),
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SubprocessPluginRuntime) InvokeHook(event string) error {
|
||||
// Get hook commands for the event
|
||||
var cmds []PlatformCommand
|
||||
expandArgs := true
|
||||
|
||||
cmds = r.RuntimeConfig.PlatformHooks[event]
|
||||
if len(cmds) == 0 && len(r.RuntimeConfig.Hooks) > 0 {
|
||||
cmd := r.RuntimeConfig.Hooks[event]
|
||||
if len(cmd) > 0 {
|
||||
cmds = []PlatformCommand{{Command: "sh", Args: []string{"-c", cmd}}}
|
||||
expandArgs = false
|
||||
}
|
||||
}
|
||||
|
||||
// If no hook commands are defined, just return successfully
|
||||
if len(cmds) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
main, argv, err := PrepareCommands(cmds, expandArgs, []string{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prog := exec.Command(main, argv...)
|
||||
prog.Stdout, prog.Stderr = os.Stdout, os.Stderr
|
||||
|
||||
if err := prog.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 {
|
||||
os.Stderr.Write(eerr.Stderr)
|
||||
return &InvokeExecError{
|
||||
Err: fmt.Errorf("plugin %q exited with error", pluginName),
|
||||
Code: 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.PlatformCommands
|
||||
if len(cmds) == 0 && len(r.RuntimeConfig.Command) > 0 {
|
||||
cmds = []PlatformCommand{{Command: r.RuntimeConfig.Command}}
|
||||
}
|
||||
|
||||
command, args, err := PrepareCommands(cmds, true, extraArgs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to prepare plugin command: %w", err)
|
||||
}
|
||||
|
||||
err2 := r.InvokeWithEnv(command, args, input.Env, input.Stdin, input.Stdout, input.Stderr)
|
||||
if err2 != nil {
|
||||
return nil, err2
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
msg := input.Message.(schema.InputMessagePostRendererV1)
|
||||
extraArgs := msg.ExtraArgs
|
||||
settings := msg.Settings
|
||||
|
||||
// Setup plugin environment
|
||||
SetupPluginEnv(settings, r.metadata.Name, r.pluginDir)
|
||||
|
||||
cmds := r.RuntimeConfig.PlatformCommands
|
||||
if len(cmds) == 0 && len(r.RuntimeConfig.Command) > 0 {
|
||||
cmds = []PlatformCommand{{Command: r.RuntimeConfig.Command}}
|
||||
}
|
||||
|
||||
command, args, err := PrepareCommands(cmds, true, extraArgs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to prepare plugin command: %w", err)
|
||||
}
|
||||
|
||||
// TODO de-duplicate code here by calling RuntimeSubprocess.invokeWithEnv()
|
||||
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 = pluginExec.env
|
||||
cmd.Stdout = postRendered
|
||||
cmd.Stderr = stderr
|
||||
|
||||
if err := executeCmd(cmd, r.metadata.Name); err != nil {
|
||||
slog.Info("plugin execution failed", slog.String("stderr", stderr.String()))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Output{
|
||||
Message: &schema.OutputMessagePostRendererV1{
|
||||
Manifests: postRendered,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetupPluginEnv prepares os.Env for plugins. It operates on os.Env because
|
||||
// the plugin subsystem itself needs access to the environment variables
|
||||
// created here.
|
||||
func SetupPluginEnv(settings *cli.EnvSettings, name, base string) { // TODO: remove
|
||||
env := settings.EnvVars()
|
||||
env["HELM_PLUGIN_NAME"] = name
|
||||
env["HELM_PLUGIN_DIR"] = base
|
||||
for key, val := range env {
|
||||
os.Setenv(key, val)
|
||||
}
|
||||
}
|
@ -0,0 +1,92 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"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)
|
||||
}
|
||||
|
||||
commands := strings.Split(d.Command, " ")
|
||||
args := append(
|
||||
commands[1:],
|
||||
msg.Options.CertFile,
|
||||
msg.Options.KeyFile,
|
||||
msg.Options.CAFile,
|
||||
msg.Href)
|
||||
|
||||
// TODO should we append to input.Env too?
|
||||
env := append(
|
||||
os.Environ(),
|
||||
fmt.Sprintf("HELM_PLUGIN_USERNAME=%s", msg.Options.Username),
|
||||
fmt.Sprintf("HELM_PLUGIN_PASSWORD=%s", msg.Options.Password),
|
||||
fmt.Sprintf("HELM_PLUGIN_PASS_CREDENTIALS_ALL=%t", msg.Options.PassCredentialsAll))
|
||||
|
||||
// TODO should we pass along input.Stdout?
|
||||
buf := bytes.Buffer{} // subprocess getters are expected to write content to stdout
|
||||
|
||||
pluginCommand := filepath.Join(r.pluginDir, commands[0])
|
||||
prog := exec.Command(
|
||||
pluginCommand,
|
||||
args...)
|
||||
prog.Env = env
|
||||
prog.Stdout = &buf
|
||||
prog.Stderr = os.Stderr
|
||||
if err := executeCmd(prog, r.metadata.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Output{
|
||||
Message: schema.OutputMessageGetterV1{
|
||||
Data: buf.Bytes(),
|
||||
},
|
||||
}, nil
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
/*
|
||||
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 (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"helm.sh/helm/v4/pkg/cli"
|
||||
)
|
||||
|
||||
func TestSetupEnv(t *testing.T) {
|
||||
name := "pequod"
|
||||
base := filepath.Join("testdata/helmhome/helm/plugins", name)
|
||||
|
||||
s := cli.New()
|
||||
s.PluginsDirectory = "testdata/helmhome/helm/plugins"
|
||||
|
||||
SetupPluginEnv(s, name, base)
|
||||
for _, tt := range []struct {
|
||||
name, expect string
|
||||
}{
|
||||
{"HELM_PLUGIN_NAME", name},
|
||||
{"HELM_PLUGIN_DIR", base},
|
||||
} {
|
||||
if got := os.Getenv(tt.name); got != tt.expect {
|
||||
t.Errorf("Expected $%s=%q, got %q", tt.name, tt.expect, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupEnvWithSpace(t *testing.T) {
|
||||
name := "sureshdsk"
|
||||
base := filepath.Join("testdata/helm home/helm/plugins", name)
|
||||
|
||||
s := cli.New()
|
||||
s.PluginsDirectory = "testdata/helm home/helm/plugins"
|
||||
|
||||
SetupPluginEnv(s, name, base)
|
||||
for _, tt := range []struct {
|
||||
name, expect string
|
||||
}{
|
||||
{"HELM_PLUGIN_NAME", name},
|
||||
{"HELM_PLUGIN_DIR", base},
|
||||
} {
|
||||
if got := os.Getenv(tt.name); got != tt.expect {
|
||||
t.Errorf("Expected $%s=%q, got %q", tt.name, tt.expect, got)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,63 @@
|
||||
/*
|
||||
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 (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseEnv(t *testing.T) {
|
||||
type testCase struct {
|
||||
env []string
|
||||
expected map[string]string
|
||||
}
|
||||
|
||||
testCases := map[string]testCase{
|
||||
"empty": {
|
||||
env: []string{},
|
||||
expected: map[string]string{},
|
||||
},
|
||||
"single": {
|
||||
env: []string{"KEY=value"},
|
||||
expected: map[string]string{"KEY": "value"},
|
||||
},
|
||||
"multiple": {
|
||||
env: []string{"KEY1=value1", "KEY2=value2"},
|
||||
expected: map[string]string{"KEY1": "value1", "KEY2": "value2"},
|
||||
},
|
||||
"no_value": {
|
||||
env: []string{"KEY1=value1", "KEY2="},
|
||||
expected: map[string]string{"KEY1": "value1", "KEY2": ""},
|
||||
},
|
||||
"duplicate_keys": {
|
||||
env: []string{"KEY=value1", "KEY=value2"},
|
||||
expected: map[string]string{"KEY": "value2"}, // last value should overwrite
|
||||
},
|
||||
"empty_strings": {
|
||||
env: []string{"", "KEY=value", ""},
|
||||
expected: map[string]string{"KEY": "value"},
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
result := parseEnv(tc.env)
|
||||
assert.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
@ -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 schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"helm.sh/helm/v4/pkg/cli"
|
||||
)
|
||||
|
||||
type InputMessageCLIV1 struct {
|
||||
ExtraArgs []string `json:"extraArgs"`
|
||||
Settings *cli.EnvSettings `json:"settings"`
|
||||
}
|
||||
|
||||
type OutputMessageCLIV1 struct {
|
||||
Data *bytes.Buffer `json:"data"`
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
/*
|
||||
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 (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TODO: can we generate these plugin input/outputs?
|
||||
|
||||
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"`
|
||||
}
|
@ -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
|
||||
}
|
@ -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 err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Look for plugin.yaml file
|
||||
if filepath.Base(header.Name) == "plugin.yaml" {
|
||||
data, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the plugin metadata
|
||||
metadata, err := loadMetadata(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New("plugin.yaml not found in tarball")
|
||||
}
|
||||
|
||||
// parsePluginMessageBlock parses a signed message block to extract plugin metadata and checksums
|
||||
func parsePluginMessageBlock(data []byte) (*Metadata, *provenance.SumCollection, error) {
|
||||
sc := &provenance.SumCollection{}
|
||||
|
||||
// We only need the checksums for verification, not the full metadata
|
||||
if err := provenance.ParseMessageBlock(data, nil, sc); err != nil {
|
||||
return nil, sc, err
|
||||
}
|
||||
return nil, sc, nil
|
||||
}
|
||||
|
||||
// CreatePluginTarball creates a gzipped tarball from a plugin directory
|
||||
func CreatePluginTarball(sourceDir, pluginName string, w io.Writer) error {
|
||||
gzw := gzip.NewWriter(w)
|
||||
defer gzw.Close()
|
||||
|
||||
tw := tar.NewWriter(gzw)
|
||||
defer tw.Close()
|
||||
|
||||
// Use the plugin name as the base directory in the tarball
|
||||
baseDir := pluginName
|
||||
|
||||
// Walk the directory tree
|
||||
return filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create header
|
||||
header, err := tar.FileInfoHeader(info, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update the name to be relative to the source directory
|
||||
relPath, err := filepath.Rel(sourceDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Include the base directory name in the tarball
|
||||
header.Name = filepath.Join(baseDir, relPath)
|
||||
|
||||
// Write header
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If it's a regular file, write its content
|
||||
if info.Mode().IsRegular() {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := io.Copy(tw, file); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
@ -0,0 +1,98 @@
|
||||
/*
|
||||
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 (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"helm.sh/helm/v4/pkg/provenance"
|
||||
)
|
||||
|
||||
func TestSignPlugin(t *testing.T) {
|
||||
// Create a test plugin directory
|
||||
tempDir := t.TempDir()
|
||||
pluginDir := filepath.Join(tempDir, "test-plugin")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a plugin.yaml file
|
||||
pluginYAML := `apiVersion: v1
|
||||
name: test-plugin
|
||||
type: cli/v1
|
||||
runtime: subprocess
|
||||
version: 1.0.0
|
||||
runtimeConfig:
|
||||
platformCommand:
|
||||
- command: echo`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(pluginYAML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a tarball
|
||||
tarballPath := filepath.Join(tempDir, "test-plugin.tgz")
|
||||
tarFile, err := os.Create(tarballPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CreatePluginTarball(pluginDir, "test-plugin", tarFile); err != nil {
|
||||
tarFile.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
tarFile.Close()
|
||||
|
||||
// Create a test key for signing
|
||||
keyring := "../../pkg/cmd/testdata/helm-test-key.secret"
|
||||
signer, err := provenance.NewFromKeyring(keyring, "helm-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := signer.DecryptKey(func(_ string) ([]byte, error) {
|
||||
return []byte(""), nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Read the tarball data
|
||||
tarballData, err := os.ReadFile(tarballPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read tarball: %v", err)
|
||||
}
|
||||
|
||||
// Sign the plugin tarball
|
||||
sig, err := SignPlugin(tarballData, filepath.Base(tarballPath), signer)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign plugin: %v", err)
|
||||
}
|
||||
|
||||
// Verify the signature contains the expected content
|
||||
if !strings.Contains(sig, "-----BEGIN PGP SIGNED MESSAGE-----") {
|
||||
t.Error("signature does not contain PGP header")
|
||||
}
|
||||
|
||||
// Verify the tarball hash is in the signature
|
||||
expectedHash, err := provenance.DigestFile(tarballPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The signature should contain the tarball hash
|
||||
if !strings.Contains(sig, "sha256:"+expectedHash) {
|
||||
t.Errorf("signature does not contain expected tarball hash: sha256:%s", expectedHash)
|
||||
}
|
||||
}
|
@ -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"
|
||||
|
||||
"golang.org/x/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,111 @@
|
||||
/*
|
||||
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) (string, []string, error) {
|
||||
cmdParts, args := getPlatformCommand(cmds)
|
||||
if len(cmdParts) == 0 || cmdParts[0] == "" {
|
||||
return "", nil, fmt.Errorf("no plugin command is applicable")
|
||||
}
|
||||
|
||||
main := os.ExpandEnv(cmdParts[0])
|
||||
baseArgs := []string{}
|
||||
if len(cmdParts) > 1 {
|
||||
for _, cmdPart := range cmdParts[1:] {
|
||||
if expandArgs {
|
||||
baseArgs = append(baseArgs, os.ExpandEnv(cmdPart))
|
||||
} else {
|
||||
baseArgs = append(baseArgs, cmdPart)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, arg := range args {
|
||||
if expandArgs {
|
||||
baseArgs = append(baseArgs, os.ExpandEnv(arg))
|
||||
} else {
|
||||
baseArgs = append(baseArgs, arg)
|
||||
}
|
||||
}
|
||||
|
||||
if len(extraArgs) > 0 {
|
||||
baseArgs = append(baseArgs, extraArgs...)
|
||||
}
|
||||
|
||||
return main, baseArgs, nil
|
||||
}
|
@ -0,0 +1,257 @@
|
||||
/*
|
||||
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 (
|
||||
"reflect"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPrepareCommand(t *testing.T) {
|
||||
cmdMain := "sh"
|
||||
cmdArgs := []string{"-c", "echo \"test\""}
|
||||
|
||||
platformCommands := []PlatformCommand{
|
||||
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs},
|
||||
}
|
||||
|
||||
cmd, args, err := PrepareCommands(platformCommands, true, []string{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmd != cmdMain {
|
||||
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
|
||||
}
|
||||
if !reflect.DeepEqual(args, cmdArgs) {
|
||||
t.Fatalf("Expected %v, got %v", cmdArgs, args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCommandExtraArgs(t *testing.T) {
|
||||
|
||||
cmdMain := "sh"
|
||||
cmdArgs := []string{"-c", "echo \"test\""}
|
||||
platformCommands := []PlatformCommand{
|
||||
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
}
|
||||
|
||||
extraArgs := []string{"--debug", "--foo", "bar"}
|
||||
|
||||
type testCaseExpected struct {
|
||||
cmdMain string
|
||||
args []string
|
||||
}
|
||||
|
||||
testCases := map[string]struct {
|
||||
ignoreFlags bool
|
||||
expected testCaseExpected
|
||||
}{
|
||||
"ignoreFlags false": {
|
||||
ignoreFlags: false,
|
||||
expected: testCaseExpected{
|
||||
cmdMain: cmdMain,
|
||||
args: []string{"-c", "echo \"test\"", "--debug", "--foo", "bar"},
|
||||
},
|
||||
},
|
||||
"ignoreFlags true": {
|
||||
ignoreFlags: true,
|
||||
expected: testCaseExpected{
|
||||
cmdMain: cmdMain,
|
||||
args: []string{"-c", "echo \"test\""},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
// extra args are expected when ignoreFlags is unset or false
|
||||
testExtraArgs := extraArgs
|
||||
if tc.ignoreFlags {
|
||||
testExtraArgs = []string{}
|
||||
}
|
||||
cmd, args, err := PrepareCommands(platformCommands, true, testExtraArgs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Equal(t, tc.expected.cmdMain, cmd, "Expected command to match")
|
||||
assert.Equal(t, tc.expected.args, args, "Expected args to match")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCommands(t *testing.T) {
|
||||
cmdMain := "sh"
|
||||
cmdArgs := []string{"-c", "echo \"test\""}
|
||||
|
||||
cmds := []PlatformCommand{
|
||||
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
}
|
||||
|
||||
cmd, args, err := PrepareCommands(cmds, true, []string{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmd != cmdMain {
|
||||
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
|
||||
}
|
||||
if !reflect.DeepEqual(args, cmdArgs) {
|
||||
t.Fatalf("Expected %v, got %v", cmdArgs, args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCommandsExtraArgs(t *testing.T) {
|
||||
cmdMain := "sh"
|
||||
cmdArgs := []string{"-c", "echo \"test\""}
|
||||
extraArgs := []string{"--debug", "--foo", "bar"}
|
||||
|
||||
cmds := []PlatformCommand{
|
||||
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: "sh", Args: []string{"-c", "echo \"test\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
}
|
||||
|
||||
expectedArgs := append(cmdArgs, extraArgs...)
|
||||
|
||||
cmd, args, err := PrepareCommands(cmds, true, extraArgs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmd != cmdMain {
|
||||
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
|
||||
}
|
||||
if !reflect.DeepEqual(args, expectedArgs) {
|
||||
t.Fatalf("Expected %v, got %v", expectedArgs, args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCommandsNoArch(t *testing.T) {
|
||||
cmdMain := "sh"
|
||||
cmdArgs := []string{"-c", "echo \"test\""}
|
||||
|
||||
cmds := []PlatformCommand{
|
||||
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: "", Command: "sh", Args: []string{"-c", "echo \"test\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
}
|
||||
|
||||
cmd, args, err := PrepareCommands(cmds, true, []string{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmd != cmdMain {
|
||||
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
|
||||
}
|
||||
if !reflect.DeepEqual(args, cmdArgs) {
|
||||
t.Fatalf("Expected %v, got %v", cmdArgs, args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCommandsNoOsNoArch(t *testing.T) {
|
||||
cmdMain := "sh"
|
||||
cmdArgs := []string{"-c", "echo \"test\""}
|
||||
|
||||
cmds := []PlatformCommand{
|
||||
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
{OperatingSystem: "", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"test\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
|
||||
}
|
||||
|
||||
cmd, args, err := PrepareCommands(cmds, true, []string{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmd != cmdMain {
|
||||
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
|
||||
}
|
||||
if !reflect.DeepEqual(args, cmdArgs) {
|
||||
t.Fatalf("Expected %v, got %v", cmdArgs, args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCommandsNoMatch(t *testing.T) {
|
||||
cmds := []PlatformCommand{
|
||||
{OperatingSystem: "no-os", Architecture: "no-arch", Command: "sh", Args: []string{"-c", "echo \"test\""}},
|
||||
{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "sh", Args: []string{"-c", "echo \"test\""}},
|
||||
{OperatingSystem: "no-os", Architecture: runtime.GOARCH, Command: "sh", Args: []string{"-c", "echo \"test\""}},
|
||||
}
|
||||
|
||||
if _, _, err := PrepareCommands(cmds, true, []string{}); err == nil {
|
||||
t.Fatalf("Expected error to be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCommandsNoCommands(t *testing.T) {
|
||||
cmds := []PlatformCommand{}
|
||||
|
||||
if _, _, err := PrepareCommands(cmds, true, []string{}); err == nil {
|
||||
t.Fatalf("Expected error to be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCommandsExpand(t *testing.T) {
|
||||
t.Setenv("TEST", "test")
|
||||
cmdMain := "sh"
|
||||
cmdArgs := []string{"-c", "echo \"${TEST}\""}
|
||||
cmds := []PlatformCommand{
|
||||
{OperatingSystem: "", Architecture: "", Command: cmdMain, Args: cmdArgs},
|
||||
}
|
||||
|
||||
expectedArgs := []string{"-c", "echo \"test\""}
|
||||
|
||||
cmd, args, err := PrepareCommands(cmds, true, []string{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmd != cmdMain {
|
||||
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
|
||||
}
|
||||
if !reflect.DeepEqual(args, expectedArgs) {
|
||||
t.Fatalf("Expected %v, got %v", expectedArgs, args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCommandsNoExpand(t *testing.T) {
|
||||
t.Setenv("TEST", "test")
|
||||
cmdMain := "sh"
|
||||
cmdArgs := []string{"-c", "echo \"${TEST}\""}
|
||||
cmds := []PlatformCommand{
|
||||
{OperatingSystem: "", Architecture: "", Command: cmdMain, Args: cmdArgs},
|
||||
}
|
||||
|
||||
cmd, args, err := PrepareCommands(cmds, false, []string{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmd != cmdMain {
|
||||
t.Fatalf("Expected %q, got %q", cmdMain, cmd)
|
||||
}
|
||||
if !reflect.DeepEqual(args, cmdArgs) {
|
||||
t.Fatalf("Expected %v, got %v", cmdArgs, args)
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
name: "duplicate-entries"
|
||||
version: "0.1.0"
|
||||
type: cli/v1
|
||||
apiVersion: v1
|
||||
runtime: subprocess
|
||||
config:
|
||||
shortHelp: "test duplicate entries"
|
||||
longHelp: |-
|
||||
description
|
||||
ignoreFlags: true
|
||||
runtimeConfig:
|
||||
command: "echo hello"
|
||||
hooks:
|
||||
install: "echo installing..."
|
||||
hooks:
|
||||
install: "echo installing something different"
|
@ -1,3 +1,4 @@
|
||||
---
|
||||
name: "downloader"
|
||||
version: "1.2.3"
|
||||
usage: "usage"
|
@ -1,4 +1,5 @@
|
||||
name: "echo"
|
||||
---
|
||||
name: "echo-legacy"
|
||||
version: "1.2.3"
|
||||
usage: "echo something"
|
||||
description: |-
|
@ -0,0 +1,15 @@
|
||||
---
|
||||
name: "echo-v1"
|
||||
version: "1.2.3"
|
||||
type: cli/v1
|
||||
apiVersion: v1
|
||||
runtime: subprocess
|
||||
config:
|
||||
shortHelp: "echo something"
|
||||
longHelp: |-
|
||||
This is a testing fixture.
|
||||
ignoreFlags: false
|
||||
runtimeConfig:
|
||||
command: "echo Hello"
|
||||
hooks:
|
||||
install: "echo Installing"
|
@ -0,0 +1,16 @@
|
||||
---
|
||||
name: "getter"
|
||||
version: "1.2.3"
|
||||
type: getter/v1
|
||||
apiVersion: v1
|
||||
runtime: subprocess
|
||||
config:
|
||||
protocols:
|
||||
- "myprotocol"
|
||||
- "myprotocols"
|
||||
runtimeConfig:
|
||||
protocolCommands:
|
||||
- command: "echo getter"
|
||||
protocols:
|
||||
- "myprotocol"
|
||||
- "myprotocols"
|
@ -1,25 +1,22 @@
|
||||
name: "hello"
|
||||
---
|
||||
name: "hello-legacy"
|
||||
version: "0.1.0"
|
||||
usage: "usage"
|
||||
usage: "echo hello message"
|
||||
description: |-
|
||||
description
|
||||
platformCommand:
|
||||
- os: linux
|
||||
arch:
|
||||
command: "sh"
|
||||
args: ["-c", "${HELM_PLUGIN_DIR}/hello.sh"]
|
||||
- os: windows
|
||||
arch:
|
||||
command: "pwsh"
|
||||
args: ["-c", "${HELM_PLUGIN_DIR}/hello.ps1"]
|
||||
ignoreFlags: true
|
||||
platformHooks:
|
||||
install:
|
||||
- os: linux
|
||||
arch: ""
|
||||
command: "sh"
|
||||
args: ["-c", 'echo "installing..."']
|
||||
- os: windows
|
||||
arch: ""
|
||||
command: "pwsh"
|
||||
args: ["-c", 'echo "installing..."']
|
@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env pwsh
|
||||
|
||||
Write-Host "Hello, world!"
|
@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "Hello from a Helm plugin"
|
||||
|
||||
echo "PARAMS"
|
||||
echo $*
|
||||
|
||||
$HELM_BIN ls --all
|
||||
|
@ -0,0 +1,32 @@
|
||||
---
|
||||
name: "hello-v1"
|
||||
version: "0.1.0"
|
||||
type: cli/v1
|
||||
apiVersion: v1
|
||||
runtime: subprocess
|
||||
config:
|
||||
usage: hello [params]...
|
||||
shortHelp: "echo hello message"
|
||||
longHelp: |-
|
||||
description
|
||||
ignoreFlags: true
|
||||
runtimeConfig:
|
||||
platformCommand:
|
||||
- os: linux
|
||||
arch:
|
||||
command: "sh"
|
||||
args: ["-c", "${HELM_PLUGIN_DIR}/hello.sh"]
|
||||
- os: windows
|
||||
arch:
|
||||
command: "pwsh"
|
||||
args: ["-c", "${HELM_PLUGIN_DIR}/hello.ps1"]
|
||||
platformHooks:
|
||||
install:
|
||||
- os: linux
|
||||
arch: ""
|
||||
command: "sh"
|
||||
args: ["-c", 'echo "installing..."']
|
||||
- os: windows
|
||||
arch: ""
|
||||
command: "pwsh"
|
||||
args: ["-c", 'echo "installing..."']
|
@ -0,0 +1,8 @@
|
||||
name: "postrenderer-v1"
|
||||
version: "1.2.3"
|
||||
type: postrenderer/v1
|
||||
apiVersion: v1
|
||||
runtime: subprocess
|
||||
runtimeConfig:
|
||||
platformCommand:
|
||||
- command: "${HELM_PLUGIN_DIR}/sed-test.sh"
|
@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
if [ $# -eq 0 ]; then
|
||||
sed s/FOOTEST/BARTEST/g <&0
|
||||
else
|
||||
sed s/FOOTEST/"$*"/g <&0
|
||||
fi
|
@ -0,0 +1 @@
|
||||
plugin.wasm
|
@ -0,0 +1,12 @@
|
||||
|
||||
.DEFAULT: build
|
||||
.PHONY: build test vet
|
||||
|
||||
.PHONY: plugin.wasm
|
||||
plugin.wasm:
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm .
|
||||
|
||||
build: plugin.wasm
|
||||
|
||||
vet:
|
||||
GOOS=wasip1 GOARCH=wasm go vet ./...
|
@ -0,0 +1,5 @@
|
||||
module helm.sh/helm/v4/internal/plugin/src/extismv1-test
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/extism/go-pdk v1.1.3
|
@ -0,0 +1,2 @@
|
||||
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
|
||||
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
|
@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
pdk "github.com/extism/go-pdk"
|
||||
)
|
||||
|
||||
type InputMessageTestV1 struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
type OutputMessageTestV1 struct {
|
||||
Greeting string
|
||||
}
|
||||
|
||||
type ConfigTestV1 struct{}
|
||||
|
||||
func runGetterPluginImpl(input InputMessageTestV1) (*OutputMessageTestV1, error) {
|
||||
name := input.Name
|
||||
|
||||
greeting := fmt.Sprintf("Hello, %s! (%d)", name, len(name))
|
||||
err := os.WriteFile("/tmp/greeting.txt", []byte(greeting), 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to write temp file: %w", err)
|
||||
}
|
||||
return &OutputMessageTestV1{
|
||||
Greeting: greeting,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func RunGetterPlugin() error {
|
||||
var input InputMessageTestV1
|
||||
if err := pdk.InputJSON(&input); err != nil {
|
||||
return fmt.Errorf("failed to parse input json: %w", err)
|
||||
}
|
||||
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("Received input: %+v", input))
|
||||
output, err := runGetterPluginImpl(input)
|
||||
if err != nil {
|
||||
pdk.Log(pdk.LogError, fmt.Sprintf("failed: %s", err.Error()))
|
||||
return err
|
||||
}
|
||||
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("Sending output: %+v", output))
|
||||
if err := pdk.OutputJSON(output); err != nil {
|
||||
return fmt.Errorf("failed to write output json: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
//go:wasmexport helm_plugin_main
|
||||
func HelmPlugin() uint32 {
|
||||
pdk.Log(pdk.LogDebug, "running example-extism-getter plugin")
|
||||
|
||||
if err := RunGetterPlugin(); err != nil {
|
||||
pdk.Log(pdk.LogError, err.Error())
|
||||
pdk.SetError(err)
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func main() {}
|
@ -0,0 +1,9 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
type: test/v1
|
||||
name: extismv1-test
|
||||
version: 0.1.0
|
||||
runtime: extism/v1
|
||||
runtimeConfig:
|
||||
fileSystem:
|
||||
createTempDir: true
|
@ -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,214 @@
|
||||
/*
|
||||
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 (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"helm.sh/helm/v4/pkg/provenance"
|
||||
)
|
||||
|
||||
const testKeyFile = "../../pkg/cmd/testdata/helm-test-key.secret"
|
||||
const testPubFile = "../../pkg/cmd/testdata/helm-test-key.pub"
|
||||
|
||||
const testPluginYAML = `apiVersion: v1
|
||||
name: test-plugin
|
||||
type: cli/v1
|
||||
runtime: subprocess
|
||||
version: 1.0.0
|
||||
runtimeConfig:
|
||||
platformCommand:
|
||||
- command: echo`
|
||||
|
||||
func TestVerifyPlugin(t *testing.T) {
|
||||
// Create a test plugin and sign it
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create plugin directory
|
||||
pluginDir := filepath.Join(tempDir, "verify-test-plugin")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(testPluginYAML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create tarball
|
||||
tarballPath := filepath.Join(tempDir, "verify-test-plugin.tar.gz")
|
||||
tarFile, err := os.Create(tarballPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := CreatePluginTarball(pluginDir, "test-plugin", tarFile); err != nil {
|
||||
tarFile.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
tarFile.Close()
|
||||
|
||||
// Sign the plugin with source directory
|
||||
signer, err := provenance.NewFromKeyring(testKeyFile, "helm-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := signer.DecryptKey(func(_ string) ([]byte, error) {
|
||||
return []byte(""), nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Read the tarball data
|
||||
tarballData, err := os.ReadFile(tarballPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sig, err := SignPlugin(tarballData, filepath.Base(tarballPath), signer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Write the signature to .prov file
|
||||
provFile := tarballPath + ".prov"
|
||||
if err := os.WriteFile(provFile, []byte(sig), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Read the files for verification
|
||||
archiveData, err := os.ReadFile(tarballPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
provData, err := os.ReadFile(provFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Now verify the plugin
|
||||
verification, err := VerifyPlugin(archiveData, provData, filepath.Base(tarballPath), testPubFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to verify plugin: %v", err)
|
||||
}
|
||||
|
||||
// Check verification results
|
||||
if verification.SignedBy == nil {
|
||||
t.Error("SignedBy is nil")
|
||||
}
|
||||
|
||||
if verification.FileName != "verify-test-plugin.tar.gz" {
|
||||
t.Errorf("Expected filename 'verify-test-plugin.tar.gz', got %s", verification.FileName)
|
||||
}
|
||||
|
||||
if verification.FileHash == "" {
|
||||
t.Error("FileHash is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPluginBadSignature(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create a plugin tarball
|
||||
pluginDir := filepath.Join(tempDir, "bad-plugin")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(testPluginYAML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tarballPath := filepath.Join(tempDir, "bad-plugin.tar.gz")
|
||||
tarFile, err := os.Create(tarballPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := CreatePluginTarball(pluginDir, "test-plugin", tarFile); err != nil {
|
||||
tarFile.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
tarFile.Close()
|
||||
|
||||
// Create a bad signature (just some text)
|
||||
badSig := `-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA512
|
||||
|
||||
This is not a real signature
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
InvalidSignatureData
|
||||
|
||||
-----END PGP SIGNATURE-----`
|
||||
|
||||
provFile := tarballPath + ".prov"
|
||||
if err := os.WriteFile(provFile, []byte(badSig), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Read the files
|
||||
archiveData, err := os.ReadFile(tarballPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
provData, err := os.ReadFile(provFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Try to verify - should fail
|
||||
_, err = VerifyPlugin(archiveData, provData, filepath.Base(tarballPath), testPubFile)
|
||||
if err == nil {
|
||||
t.Error("Expected verification to fail with bad signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPluginMissingProvenance(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
tarballPath := filepath.Join(tempDir, "no-prov.tar.gz")
|
||||
|
||||
// Create a minimal tarball
|
||||
if err := os.WriteFile(tarballPath, []byte("dummy"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Read the tarball data
|
||||
archiveData, err := os.ReadFile(tarballPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Try to verify with empty provenance data
|
||||
_, err = VerifyPlugin(archiveData, nil, filepath.Base(tarballPath), testPubFile)
|
||||
if err == nil {
|
||||
t.Error("Expected verification to fail with empty provenance data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPluginMalformedData(t *testing.T) {
|
||||
// Test with malformed tarball data - should fail
|
||||
malformedData := []byte("not a tarball")
|
||||
provData := []byte("fake provenance")
|
||||
|
||||
_, err := VerifyPlugin(malformedData, provData, "malformed.tar.gz", testPubFile)
|
||||
if err == nil {
|
||||
t.Error("Expected malformed data verification to fail, but it succeeded")
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue