1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-19 05:19:39 +02:00

refactor(helm): helm binary to sdk refactor [r8s-229] (#463)

Co-authored-by: stevensbkang <skan070@gmail.com>
This commit is contained in:
Ali 2025-03-13 12:20:16 +13:00 committed by GitHub
parent 0d25f3f430
commit b5961d79f8
56 changed files with 2222 additions and 819 deletions

View file

@ -42,9 +42,6 @@ var (
// DepDockerVersion is the version of the Docker binary shipped with the application.
DepDockerVersion string
// DepHelmVersion is the version of the Helm binary shipped with the application.
DepHelmVersion string
// DepKubectlVersion is the version of the Kubectl binary shipped with the application.
DepKubectlVersion string
)
@ -92,7 +89,6 @@ func GetBuildInfo() BuildInfo {
func GetDependenciesInfo() DependenciesInfo {
return DependenciesInfo{
DockerVersion: DepDockerVersion,
HelmVersion: DepHelmVersion,
KubectlVersion: DepKubectlVersion,
ComposeVersion: DepComposeVersion,
}

View file

@ -1,29 +0,0 @@
package binary
import (
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
)
// Get runs `helm get` with specified get options.
// The get options translate to CLI arguments which are passed in to the helm binary when executing install.
func (hbpm *helmBinaryPackageManager) Get(getOpts options.GetOptions) ([]byte, error) {
if getOpts.Name == "" || getOpts.ReleaseResource == "" {
return nil, errors.New("release name and release resource are required")
}
args := []string{
string(getOpts.ReleaseResource),
getOpts.Name,
}
if getOpts.Namespace != "" {
args = append(args, "--namespace", getOpts.Namespace)
}
result, err := hbpm.runWithKubeConfig("get", args, getOpts.KubernetesClusterAccess, getOpts.Env)
if err != nil {
return nil, errors.Wrap(err, "failed to run helm get on specified args")
}
return result, nil
}

View file

@ -1,62 +0,0 @@
package binary
import (
"bytes"
"os"
"os/exec"
"path"
"runtime"
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
)
// helmBinaryPackageManager is a wrapper for the helm binary which implements HelmPackageManager
type helmBinaryPackageManager struct {
binaryPath string
}
// NewHelmBinaryPackageManager initializes a new HelmPackageManager service.
func NewHelmBinaryPackageManager(binaryPath string) *helmBinaryPackageManager {
return &helmBinaryPackageManager{binaryPath: binaryPath}
}
// runWithKubeConfig will execute run against the provided Kubernetes cluster with kubeconfig as cli arguments.
func (hbpm *helmBinaryPackageManager) runWithKubeConfig(command string, args []string, kca *options.KubernetesClusterAccess, env []string) ([]byte, error) {
cmdArgs := make([]string, 0)
if kca != nil {
cmdArgs = append(cmdArgs, "--kube-apiserver", kca.ClusterServerURL)
cmdArgs = append(cmdArgs, "--kube-token", kca.AuthToken)
cmdArgs = append(cmdArgs, "--kube-ca-file", kca.CertificateAuthorityFile)
}
cmdArgs = append(cmdArgs, args...)
return hbpm.run(command, cmdArgs, env)
}
// run will execute helm command against the provided Kubernetes cluster.
// The endpointId and authToken are dynamic params (based on the user) that allow helm to execute commands
// in the context of the current user against specified k8s cluster.
func (hbpm *helmBinaryPackageManager) run(command string, args []string, env []string) ([]byte, error) {
cmdArgs := make([]string, 0)
cmdArgs = append(cmdArgs, command)
cmdArgs = append(cmdArgs, args...)
helmPath := path.Join(hbpm.binaryPath, "helm")
if runtime.GOOS == "windows" {
helmPath = path.Join(hbpm.binaryPath, "helm.exe")
}
var stderr bytes.Buffer
cmd := exec.Command(helmPath, cmdArgs...)
cmd.Stderr = &stderr
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, env...)
output, err := cmd.Output()
if err != nil {
return nil, errors.Wrap(err, stderr.String())
}
return output, nil
}

View file

@ -1,48 +0,0 @@
package binary
import (
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/pkg/errors"
"github.com/segmentio/encoding/json"
)
// Install runs `helm install` with specified install options.
// The install options translate to CLI arguments which are passed in to the helm binary when executing install.
func (hbpm *helmBinaryPackageManager) Install(installOpts options.InstallOptions) (*release.Release, error) {
if installOpts.Name == "" {
installOpts.Name = "--generate-name"
}
args := []string{
installOpts.Name,
installOpts.Chart,
"--repo", installOpts.Repo,
"--output", "json",
}
if installOpts.Namespace != "" {
args = append(args, "--namespace", installOpts.Namespace)
}
if installOpts.ValuesFile != "" {
args = append(args, "--values", installOpts.ValuesFile)
}
if installOpts.Wait {
args = append(args, "--wait")
}
if installOpts.PostRenderer != "" {
args = append(args, "--post-renderer", installOpts.PostRenderer)
}
result, err := hbpm.runWithKubeConfig("install", args, installOpts.KubernetesClusterAccess, installOpts.Env)
if err != nil {
return nil, errors.Wrap(err, "failed to run helm install on specified args")
}
response := &release.Release{}
err = json.Unmarshal(result, &response)
if err != nil {
return nil, errors.Wrap(err, "failed to unmarshal helm install response to Release struct")
}
return response, nil
}

View file

@ -1,118 +0,0 @@
package binary
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/stretchr/testify/assert"
)
func createValuesFile(values string) (string, error) {
file, err := os.CreateTemp("", "helm-values")
if err != nil {
return "", err
}
_, err = file.WriteString(values)
if err != nil {
file.Close()
return "", err
}
err = file.Close()
if err != nil {
return "", err
}
return file.Name(), nil
}
// getHelmBinaryPath is helper function to get local helm binary path (if helm is in path)
func getHelmBinaryPath() (string, error) {
path, err := exec.LookPath("helm")
if err != nil {
return "", err
}
dir, err := filepath.Abs(filepath.Dir(path))
if err != nil {
return "", err
}
return dir, nil
}
func Test_Install(t *testing.T) {
ensureIntegrationTest(t)
is := assert.New(t)
path, err := getHelmBinaryPath()
is.NoError(err, "helm binary must exist in path to run tests")
hbpm := NewHelmBinaryPackageManager(path)
t.Run("successfully installs nginx chart with name test-nginx", func(t *testing.T) {
// helm install test-nginx --repo https://kubernetes.github.io/ingress-nginx nginx
installOpts := options.InstallOptions{
Name: "test-nginx",
Chart: "nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
}
release, err := hbpm.Install(installOpts)
defer hbpm.run("uninstall", []string{"test-nginx"}, nil)
is.NoError(err, "should successfully install release", release)
})
t.Run("successfully installs nginx chart with generated name", func(t *testing.T) {
// helm install --generate-name --repo https://kubernetes.github.io/ingress-nginx nginx
installOpts := options.InstallOptions{
Chart: "nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
}
release, err := hbpm.Install(installOpts)
defer hbpm.run("uninstall", []string{release.Name}, nil)
is.NoError(err, "should successfully install release", release)
})
t.Run("successfully installs nginx with values", func(t *testing.T) {
// helm install test-nginx-2 --repo https://kubernetes.github.io/ingress-nginx nginx --values /tmp/helm-values3161785816
values, err := createValuesFile("service:\n port: 8081")
is.NoError(err, "should create a values file")
defer os.Remove(values)
installOpts := options.InstallOptions{
Name: "test-nginx-2",
Chart: "nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
ValuesFile: values,
}
release, err := hbpm.Install(installOpts)
defer hbpm.run("uninstall", []string{"test-nginx-2"}, nil)
is.NoError(err, "should successfully install release", release)
})
t.Run("successfully installs portainer chart with name portainer-test", func(t *testing.T) {
// helm install portainer-test portainer --repo https://portainer.github.io/k8s/
installOpts := options.InstallOptions{
Name: "portainer-test",
Chart: "portainer",
Repo: "https://portainer.github.io/k8s/",
}
release, err := hbpm.Install(installOpts)
defer hbpm.run("uninstall", []string{installOpts.Name}, nil)
is.NoError(err, "should successfully install release", release)
})
}
func ensureIntegrationTest(t *testing.T) {
if _, ok := os.LookupEnv("INTEGRATION_TEST"); !ok {
t.Skip("skip an integration test")
}
}

View file

@ -1,38 +0,0 @@
package binary
import (
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/pkg/errors"
"github.com/segmentio/encoding/json"
)
// List runs `helm list --output json --filter <filter> --selector <selector> --namespace <namespace>` with specified list options.
// The list options translate to CLI args the helm binary
func (hbpm *helmBinaryPackageManager) List(listOpts options.ListOptions) ([]release.ReleaseElement, error) {
args := []string{"--output", "json"}
if listOpts.Filter != "" {
args = append(args, "--filter", listOpts.Filter)
}
if listOpts.Selector != "" {
args = append(args, "--selector", listOpts.Selector)
}
if listOpts.Namespace != "" {
args = append(args, "--namespace", listOpts.Namespace)
}
result, err := hbpm.runWithKubeConfig("list", args, listOpts.KubernetesClusterAccess, listOpts.Env)
if err != nil {
return []release.ReleaseElement{}, errors.Wrap(err, "failed to run helm list on specified args")
}
response := []release.ReleaseElement{}
err = json.Unmarshal(result, &response)
if err != nil {
return []release.ReleaseElement{}, errors.Wrap(err, "failed to unmarshal helm list response to releastElement list")
}
return response, nil
}

View file

@ -1,118 +0,0 @@
package binary
// Package common implements common functionality for the helm.
// The functionality does not rely on the implementation of `HelmPackageManager`
import (
"net/http"
"net/url"
"path"
"time"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/pkg/errors"
"github.com/segmentio/encoding/json"
"gopkg.in/yaml.v3"
)
var errRequiredSearchOptions = errors.New("repo is required")
var errInvalidRepoURL = errors.New("the request failed since either the Helm repository was not found or the index.yaml is not valid")
type File struct {
APIVersion string `yaml:"apiVersion" json:"apiVersion"`
Entries map[string][]Entry `yaml:"entries" json:"entries"`
Generated string `yaml:"generated" json:"generated"`
}
type Annotations struct {
Category string `yaml:"category" json:"category"`
}
type Entry struct {
Annotations *Annotations `yaml:"annotations" json:"annotations,omitempty"`
Created string `yaml:"created" json:"created"`
Deprecated bool `yaml:"deprecated" json:"deprecated"`
Description string `yaml:"description" json:"description"`
Digest string `yaml:"digest" json:"digest"`
Home string `yaml:"home" json:"home"`
Name string `yaml:"name" json:"name"`
Sources []string `yaml:"sources" json:"sources"`
Urls []string `yaml:"urls" json:"urls"`
Version string `yaml:"version" json:"version"`
Icon string `yaml:"icon" json:"icon,omitempty"`
}
// SearchRepo downloads the `index.yaml` file for specified repo, parses it and returns JSON to caller.
// The functionality is similar to that of what `helm search repo [chart] --repo <repo>` CLI runs;
// this approach is used instead since the `helm search repo` requires a repo to be added to the global helm cache
func (hbpm *helmBinaryPackageManager) SearchRepo(searchRepoOpts options.SearchRepoOptions) ([]byte, error) {
if searchRepoOpts.Repo == "" {
return nil, errRequiredSearchOptions
}
client := searchRepoOpts.Client
if client == nil {
// The current index.yaml is ~9MB on bitnami.
// At a slow @2mbit download = 40s. @100bit = ~1s.
// I'm seeing 3 - 4s over wifi.
// Give ample time but timeout for now. Can be improved in the future
client = &http.Client{
Timeout: 300 * time.Second,
Transport: http.DefaultTransport,
}
}
// Allow redirect behavior to be overridden if specified.
if client.CheckRedirect == nil {
client.CheckRedirect = defaultCheckRedirect
}
url, err := url.ParseRequestURI(searchRepoOpts.Repo)
if err != nil {
return nil, errors.Wrap(err, "invalid helm chart URL: "+searchRepoOpts.Repo)
}
url.Path = path.Join(url.Path, "index.yaml")
resp, err := client.Get(url.String())
if err != nil {
return nil, errInvalidRepoURL
}
defer resp.Body.Close()
var file File
err = yaml.NewDecoder(resp.Body).Decode(&file)
if err != nil {
return nil, errInvalidRepoURL
}
// Validate index.yaml
if file.APIVersion == "" || file.Entries == nil {
return nil, errInvalidRepoURL
}
result, err := json.Marshal(file)
if err != nil {
return nil, errInvalidRepoURL
}
return result, nil
}
// defaultCheckRedirect is a default CheckRedirect for helm
// We don't allow redirects to URLs not ending in index.yaml
// After that we follow the go http client behavior which is to stop
// after a maximum of 10 redirects
func defaultCheckRedirect(req *http.Request, via []*http.Request) error {
// The request url must end in index.yaml
if path.Base(req.URL.Path) != "index.yaml" {
return errors.New("the request URL must end in index.yaml")
}
// default behavior below
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
return nil
}

View file

@ -1,50 +0,0 @@
package binary
import (
"testing"
"github.com/portainer/portainer/pkg/libhelm/libhelmtest"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/stretchr/testify/assert"
)
func Test_SearchRepo(t *testing.T) {
libhelmtest.EnsureIntegrationTest(t)
is := assert.New(t)
hpm := NewHelmBinaryPackageManager("")
type testCase struct {
name string
url string
invalid bool
}
tests := []testCase{
{"not a helm repo", "https://portainer.io", true},
{"ingress helm repo", "https://kubernetes.github.io/ingress-nginx", false},
{"portainer helm repo", "https://portainer.github.io/k8s/", false},
{"gitlap helm repo with trailing slash", "https://charts.gitlab.io/", false},
{"elastic helm repo with trailing slash", "https://helm.elastic.co/", false},
{"fabric8.io helm repo with trailing slash", "https://fabric8.io/helm/", false},
{"lensesio helm repo without trailing slash", "https://lensesio.github.io/kafka-helm-charts", false},
}
for _, test := range tests {
func(tc testCase) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
response, err := hpm.SearchRepo(options.SearchRepoOptions{Repo: tc.url})
if tc.invalid {
is.Errorf(err, "error expected: %s", tc.url)
} else {
is.NoError(err, "no error expected: %s", tc.url)
}
if err == nil {
is.NotEmpty(response, "response expected")
}
})
}(test)
}
}

View file

@ -1,29 +0,0 @@
package binary
import (
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
)
var errRequiredShowOptions = errors.New("chart, repo and output format are required")
// Show runs `helm show <command> <chart> --repo <repo>` with specified show options.
// The show options translate to CLI arguments which are passed in to the helm binary when executing install.
func (hbpm *helmBinaryPackageManager) Show(showOpts options.ShowOptions) ([]byte, error) {
if showOpts.Chart == "" || showOpts.Repo == "" || showOpts.OutputFormat == "" {
return nil, errRequiredShowOptions
}
args := []string{
string(showOpts.OutputFormat),
showOpts.Chart,
"--repo", showOpts.Repo,
}
result, err := hbpm.run("show", args, showOpts.Env)
if err != nil {
return nil, errors.New("the request failed since either the Helm repository was not found or the chart does not exist")
}
return result, nil
}

View file

@ -1,29 +0,0 @@
package binary
import (
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
)
var errRequiredUninstallOptions = errors.New("release name is required")
// Uninstall runs `helm uninstall <name> --namespace <namespace>` with specified uninstall options.
// The uninstall options translate to CLI arguments which are passed in to the helm binary when executing uninstall.
func (hbpm *helmBinaryPackageManager) Uninstall(uninstallOpts options.UninstallOptions) error {
if uninstallOpts.Name == "" {
return errRequiredUninstallOptions
}
args := []string{uninstallOpts.Name}
if uninstallOpts.Namespace != "" {
args = append(args, "--namespace", uninstallOpts.Namespace)
}
_, err := hbpm.runWithKubeConfig("uninstall", args, uninstallOpts.KubernetesClusterAccess, uninstallOpts.Env)
if err != nil {
return errors.Wrap(err, "failed to run helm uninstall on specified args")
}
return nil
}

View file

@ -1,22 +1,11 @@
package libhelm
import (
"errors"
"github.com/portainer/portainer/pkg/libhelm/binary"
"github.com/portainer/portainer/pkg/libhelm/sdk"
"github.com/portainer/portainer/pkg/libhelm/types"
)
// HelmConfig is a struct that holds the configuration for the Helm package manager
type HelmConfig struct {
BinaryPath string `example:"/portainer/dist"`
}
var errBinaryPathNotSpecified = errors.New("binary path not specified")
// NewHelmPackageManager returns a new instance of HelmPackageManager based on HelmConfig
func NewHelmPackageManager(config HelmConfig) (HelmPackageManager, error) {
if config.BinaryPath != "" {
return binary.NewHelmBinaryPackageManager(config.BinaryPath), nil
}
return nil, errBinaryPathNotSpecified
func NewHelmPackageManager() (types.HelmPackageManager, error) {
return sdk.NewHelmSDKPackageManager(), nil
}

View file

@ -2,6 +2,9 @@ package options
// KubernetesClusterAccess represents core details which can be used to generate KubeConfig file/data
type KubernetesClusterAccess struct {
ClusterName string `example:"portainer-cluster-endpoint-1"`
ContextName string `example:"portainer-ctx-endpoint-1"`
UserName string `example:"portainer-user-endpoint-1"`
ClusterServerURL string `example:"https://mycompany.k8s.com"`
CertificateAuthorityFile string `example:"/data/tls/localhost.crt"`
AuthToken string `example:"ey..."`

165
pkg/libhelm/sdk/client.go Normal file
View file

@ -0,0 +1,165 @@
package sdk
import (
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/chartutil"
"helm.sh/helm/v3/pkg/cli"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/client-go/discovery"
"k8s.io/client-go/discovery/cached/memory"
"k8s.io/client-go/rest"
"k8s.io/client-go/restmapper"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/tools/clientcmd/api"
)
// newRESTClientGetter creates a custom RESTClientGetter using the provided client config
type clientConfigGetter struct {
clientConfig clientcmd.ClientConfig
namespace string
}
// initActionConfig initializes the action configuration with kubernetes config
func (hspm *HelmSDKPackageManager) initActionConfig(actionConfig *action.Configuration, namespace string, k8sAccess *options.KubernetesClusterAccess) error {
// If namespace is not provided, use the default namespace
if namespace == "" {
namespace = "default"
}
if k8sAccess == nil {
// Use default kubeconfig
settings := cli.New()
clientGetter := settings.RESTClientGetter()
return actionConfig.Init(clientGetter, namespace, "secret", hspm.logf)
}
// Create client config
configAPI := generateConfigAPI(namespace, k8sAccess)
clientConfig := clientcmd.NewDefaultClientConfig(*configAPI, &clientcmd.ConfigOverrides{})
// Create a custom RESTClientGetter that uses our in-memory config
clientGetter, err := newRESTClientGetter(clientConfig, namespace)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("cluster_name", k8sAccess.ClusterName).
Str("cluster_url", k8sAccess.ClusterServerURL).
Str("user_name", k8sAccess.UserName).
Err(err).
Msg("failed to create client getter")
return err
}
return actionConfig.Init(clientGetter, namespace, "secret", hspm.logf)
}
// generateConfigAPI generates a new kubeconfig configuration
func generateConfigAPI(namespace string, k8sAccess *options.KubernetesClusterAccess) *api.Config {
// Create in-memory kubeconfig configuration
configAPI := api.NewConfig()
// Create cluster
cluster := api.NewCluster()
cluster.Server = k8sAccess.ClusterServerURL
if k8sAccess.CertificateAuthorityFile != "" {
// If we have a CA file, use it
cluster.CertificateAuthority = k8sAccess.CertificateAuthorityFile
} else {
// Otherwise skip TLS verification
cluster.InsecureSkipTLSVerify = true
}
// Create auth info with token
authInfo := api.NewAuthInfo()
authInfo.Token = k8sAccess.AuthToken
// Create context
context := api.NewContext()
context.Cluster = k8sAccess.ClusterName
context.AuthInfo = k8sAccess.UserName
context.Namespace = namespace
// Add to config
configAPI.Clusters[k8sAccess.ClusterName] = cluster
configAPI.AuthInfos[k8sAccess.UserName] = authInfo
configAPI.Contexts[k8sAccess.ContextName] = context
configAPI.CurrentContext = k8sAccess.ContextName
return configAPI
}
func newRESTClientGetter(clientConfig clientcmd.ClientConfig, namespace string) (*clientConfigGetter, error) {
if clientConfig == nil {
log.Error().
Str("context", "HelmClient").
Msg("client config is nil")
return nil, errors.New("client config provided during the helm client initialization was nil. Check the kubernetes cluster access configuration")
}
return &clientConfigGetter{
clientConfig: clientConfig,
namespace: namespace,
}, nil
}
func (c *clientConfigGetter) ToRESTConfig() (*rest.Config, error) {
if c.clientConfig == nil {
log.Error().
Str("context", "HelmClient").
Msg("client config is nil")
return nil, errors.New("client config provided during the helm client initialization was nil. Check the kubernetes cluster access configuration")
}
return c.clientConfig.ClientConfig()
}
func (c *clientConfigGetter) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) {
config, err := c.ToRESTConfig()
if err != nil {
return nil, err
}
// Create the discovery client
discoveryClient, err := discovery.NewDiscoveryClientForConfig(config)
if err != nil {
return nil, err
}
// Wrap the discovery client with a cached discovery client
return memory.NewMemCacheClient(discoveryClient), nil
}
func (c *clientConfigGetter) ToRESTMapper() (meta.RESTMapper, error) {
discoveryClient, err := c.ToDiscoveryClient()
if err != nil {
return nil, err
}
// Create a REST mapper from the discovery client
return restmapper.NewDeferredDiscoveryRESTMapper(discoveryClient), nil
}
func (c *clientConfigGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig {
return c.clientConfig
}
// parseValues parses YAML values data into a map
func (hspm *HelmSDKPackageManager) parseValues(data []byte) (map[string]any, error) {
// Use Helm's built-in chartutil.ReadValues which properly handles the conversion
// from map[interface{}]interface{} to map[string]interface{}
return chartutil.ReadValues(data)
}
// logf is a log helper function for Helm
func (hspm *HelmSDKPackageManager) logf(format string, v ...any) {
// Use zerolog for structured logging
log.Debug().
Str("context", "HelmClient").
Msgf(format, v...)
}

View file

@ -0,0 +1,156 @@
package sdk
import (
"testing"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/stretchr/testify/assert"
"helm.sh/helm/v3/pkg/action"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/tools/clientcmd/api"
)
func Test_InitActionConfig(t *testing.T) {
is := assert.New(t)
hspm := NewHelmSDKPackageManager()
t.Run("with nil k8sAccess should use default kubeconfig", func(t *testing.T) {
actionConfig := new(action.Configuration)
err := hspm.(*HelmSDKPackageManager).initActionConfig(actionConfig, "default", nil)
// The function should not fail by design, even when not running in a k8s environment
is.NoError(err, "should not return error when not in k8s environment")
})
t.Run("with k8sAccess should create in-memory config", func(t *testing.T) {
actionConfig := new(action.Configuration)
k8sAccess := &options.KubernetesClusterAccess{
ClusterServerURL: "https://kubernetes.default.svc",
AuthToken: "test-token",
}
// The function should not fail by design
err := hspm.(*HelmSDKPackageManager).initActionConfig(actionConfig, "default", k8sAccess)
is.NoError(err, "should not return error when using in-memory config")
})
t.Run("with k8sAccess and CA file should create config with CA", func(t *testing.T) {
actionConfig := new(action.Configuration)
k8sAccess := &options.KubernetesClusterAccess{
ClusterServerURL: "https://kubernetes.default.svc",
AuthToken: "test-token",
CertificateAuthorityFile: "/path/to/ca.crt",
}
// The function should not fail by design
err := hspm.(*HelmSDKPackageManager).initActionConfig(actionConfig, "default", k8sAccess)
is.NoError(err, "should not return error when using in-memory config with CA")
})
}
func Test_ClientConfigGetter(t *testing.T) {
is := assert.New(t)
// Create a mock client config
configAPI := api.NewConfig()
// Create cluster
cluster := api.NewCluster()
cluster.Server = "https://kubernetes.default.svc"
cluster.InsecureSkipTLSVerify = true
// Create auth info
authInfo := api.NewAuthInfo()
authInfo.Token = "test-token"
// Create context
context := api.NewContext()
context.Cluster = "test-cluster"
context.AuthInfo = "test-user"
context.Namespace = "default"
// Add to config
configAPI.Clusters["test-cluster"] = cluster
configAPI.AuthInfos["test-user"] = authInfo
configAPI.Contexts["test-context"] = context
configAPI.CurrentContext = "test-context"
clientConfig := clientcmd.NewDefaultClientConfig(*configAPI, &clientcmd.ConfigOverrides{})
// Create client config getter
clientGetter, err := newRESTClientGetter(clientConfig, "default")
is.NoError(err, "should not return error when creating client getter")
// Test ToRESTConfig
restConfig, err := clientGetter.ToRESTConfig()
is.NoError(err, "should not return error when creating REST config")
is.NotNil(restConfig, "should return non-nil REST config")
is.Equal("https://kubernetes.default.svc", restConfig.Host, "host should be https://kubernetes.default.svc")
is.Equal("test-token", restConfig.BearerToken, "bearer token should be test-token")
// Test ToDiscoveryClient
discoveryClient, err := clientGetter.ToDiscoveryClient()
is.NoError(err, "should not return error when creating discovery client")
is.NotNil(discoveryClient, "should return non-nil discovery client")
// Test ToRESTMapper
restMapper, err := clientGetter.ToRESTMapper()
is.NoError(err, "should not return error when creating REST mapper")
is.NotNil(restMapper, "should return non-nil REST mapper")
// Test ToRawKubeConfigLoader
config := clientGetter.ToRawKubeConfigLoader()
is.NotNil(config, "should return non-nil config loader")
}
func Test_ParseValues(t *testing.T) {
is := assert.New(t)
hspm := NewHelmSDKPackageManager()
t.Run("should parse valid YAML values", func(t *testing.T) {
yamlData := []byte(`
service:
type: ClusterIP
port: 80
resources:
limits:
cpu: 100m
memory: 128Mi
`)
values, err := hspm.(*HelmSDKPackageManager).parseValues(yamlData)
is.NoError(err, "should parse valid YAML without error")
is.NotNil(values, "should return non-nil values")
// Verify structure
service, ok := values["service"].(map[string]interface{})
is.True(ok, "service should be a map")
is.Equal("ClusterIP", service["type"], "service type should be ClusterIP")
is.Equal(float64(80), service["port"], "service port should be 80")
resources, ok := values["resources"].(map[string]interface{})
is.True(ok, "resources should be a map")
limits, ok := resources["limits"].(map[string]interface{})
is.True(ok, "limits should be a map")
is.Equal("100m", limits["cpu"], "cpu limit should be 100m")
is.Equal("128Mi", limits["memory"], "memory limit should be 128Mi")
})
t.Run("should handle invalid YAML", func(t *testing.T) {
yamlData := []byte(`
service:
type: ClusterIP
port: 80
invalid yaml
`)
_, err := hspm.(*HelmSDKPackageManager).parseValues(yamlData)
is.Error(err, "should return error for invalid YAML")
})
t.Run("should handle empty YAML", func(t *testing.T) {
yamlData := []byte(``)
values, err := hspm.(*HelmSDKPackageManager).parseValues(yamlData)
is.NoError(err, "should not return error for empty YAML")
is.NotNil(values, "should return non-nil values for empty YAML")
is.Len(values, 0, "should return empty map for empty YAML")
})
}

210
pkg/libhelm/sdk/install.go Normal file
View file

@ -0,0 +1,210 @@
package sdk
import (
"os"
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/chart/loader"
"helm.sh/helm/v3/pkg/downloader"
"helm.sh/helm/v3/pkg/getter"
"helm.sh/helm/v3/pkg/postrender"
)
// Install implements the HelmPackageManager interface by using the Helm SDK to install a chart.
func (hspm *HelmSDKPackageManager) Install(installOpts options.InstallOptions) (*release.Release, error) {
log.Debug().
Str("context", "HelmClient").
Str("chart", installOpts.Chart).
Str("name", installOpts.Name).
Str("namespace", installOpts.Namespace).
Str("repo", installOpts.Repo).
Bool("wait", installOpts.Wait).
Msg("Installing Helm chart")
if installOpts.Name == "" {
log.Error().
Str("context", "HelmClient").
Str("chart", installOpts.Chart).
Str("name", installOpts.Name).
Str("namespace", installOpts.Namespace).
Str("repo", installOpts.Repo).
Bool("wait", installOpts.Wait).
Msg("Name is required for helm release installation")
return nil, errors.New("name is required for helm release installation")
}
// Initialize action configuration with kubernetes config
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, installOpts.Namespace, installOpts.KubernetesClusterAccess)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("chart", installOpts.Chart).
Str("namespace", installOpts.Namespace).
Err(err).
Msg("Failed to initialize helm configuration for helm release installation")
return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release installation")
}
installClient, err := initInstallClient(actionConfig, installOpts)
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to initialize helm install client for helm release installation")
return nil, errors.Wrap(err, "failed to initialize helm install client for helm release installation")
}
values, err := hspm.GetHelmValuesFromFile(installOpts.ValuesFile)
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to get Helm values from file for helm release installation")
return nil, errors.Wrap(err, "failed to get Helm values from file for helm release installation")
}
chart, err := hspm.loadAndValidateChart(installClient, installOpts)
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to load and validate chart for helm release installation")
return nil, errors.Wrap(err, "failed to load and validate chart for helm release installation")
}
// Run the installation
log.Info().
Str("context", "HelmClient").
Str("chart", installOpts.Chart).
Str("name", installOpts.Name).
Str("namespace", installOpts.Namespace).
Msg("Running chart installation for helm release")
helmRelease, err := installClient.Run(chart, values)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("chart", installOpts.Chart).
Str("name", installOpts.Name).
Str("namespace", installOpts.Namespace).
Err(err).
Msg("Failed to install helm chart for helm release installation")
return nil, errors.Wrap(err, "helm was not able to install the chart for helm release installation")
}
return &release.Release{
Name: helmRelease.Name,
Namespace: helmRelease.Namespace,
Chart: release.Chart{
Metadata: &release.Metadata{
Name: helmRelease.Chart.Metadata.Name,
Version: helmRelease.Chart.Metadata.Version,
AppVersion: helmRelease.Chart.Metadata.AppVersion,
},
},
Labels: helmRelease.Labels,
Version: helmRelease.Version,
Manifest: helmRelease.Manifest,
}, nil
}
// loadAndValidateChart locates and loads the chart, and validates it.
// it also checks for chart dependencies and updates them if necessary.
// it returns the chart information.
func (hspm *HelmSDKPackageManager) loadAndValidateChart(installClient *action.Install, installOpts options.InstallOptions) (*chart.Chart, error) {
// Locate and load the chart
chartPath, err := installClient.ChartPathOptions.LocateChart(installOpts.Chart, hspm.settings)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("chart", installOpts.Chart).
Err(err).
Msg("Failed to locate chart for helm release installation")
return nil, errors.Wrapf(err, "failed to find the helm chart at the path: %s/%s", installOpts.Repo, installOpts.Chart)
}
chartReq, err := loader.Load(chartPath)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("chart_path", chartPath).
Err(err).
Msg("Failed to load chart for helm release installation")
return nil, errors.Wrap(err, "failed to load chart for helm release installation")
}
// Check chart dependencies to make sure all are present in /charts
if chartDependencies := chartReq.Metadata.Dependencies; chartDependencies != nil {
if err := action.CheckDependencies(chartReq, chartDependencies); err != nil {
err = errors.Wrap(err, "failed to check chart dependencies for helm release installation")
if !installClient.DependencyUpdate {
return nil, err
}
log.Debug().
Str("context", "HelmClient").
Str("chart", installOpts.Chart).
Msg("Updating chart dependencies for helm release installation")
providers := getter.All(hspm.settings)
manager := &downloader.Manager{
Out: os.Stdout,
ChartPath: chartPath,
Keyring: installClient.ChartPathOptions.Keyring,
SkipUpdate: false,
Getters: providers,
RepositoryConfig: hspm.settings.RepositoryConfig,
RepositoryCache: hspm.settings.RepositoryCache,
Debug: hspm.settings.Debug,
}
if err := manager.Update(); err != nil {
log.Error().
Str("context", "HelmClient").
Str("chart", installOpts.Chart).
Err(err).
Msg("Failed to update chart dependencies for helm release installation")
return nil, errors.Wrap(err, "failed to update chart dependencies for helm release installation")
}
// Reload the chart with the updated Chart.lock file.
if chartReq, err = loader.Load(chartPath); err != nil {
log.Error().
Str("context", "HelmClient").
Str("chart_path", chartPath).
Err(err).
Msg("Failed to reload chart after dependency update for helm release installation")
return nil, errors.Wrap(err, "failed to reload chart after dependency update for helm release installation")
}
}
}
return chartReq, nil
}
// initInstallClient initializes the install client with the given options
// and return the install client.
func initInstallClient(actionConfig *action.Configuration, installOpts options.InstallOptions) (*action.Install, error) {
installClient := action.NewInstall(actionConfig)
installClient.CreateNamespace = true
installClient.DependencyUpdate = true
installClient.ReleaseName = installOpts.Name
installClient.Namespace = installOpts.Namespace
installClient.ChartPathOptions.RepoURL = installOpts.Repo
installClient.Wait = installOpts.Wait
if installOpts.PostRenderer != "" {
postRenderer, err := postrender.NewExec(installOpts.PostRenderer)
if err != nil {
return nil, errors.Wrap(err, "failed to create post renderer")
}
installClient.PostRenderer = postRenderer
}
return installClient, nil
}

View file

@ -0,0 +1,190 @@
package sdk
import (
"os"
"testing"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/test"
"github.com/stretchr/testify/assert"
)
func createValuesFile(values string) (string, error) {
file, err := os.CreateTemp("", "helm-values")
if err != nil {
return "", err
}
_, err = file.WriteString(values)
if err != nil {
file.Close()
return "", err
}
err = file.Close()
if err != nil {
return "", err
}
return file.Name(), nil
}
func Test_Install(t *testing.T) {
test.EnsureIntegrationTest(t)
is := assert.New(t)
// Create a new SDK package manager
hspm := NewHelmSDKPackageManager()
t.Run("successfully installs nginx chart with name test-nginx", func(t *testing.T) {
// SDK equivalent of: helm install test-nginx --repo https://kubernetes.github.io/ingress-nginx nginx
installOpts := options.InstallOptions{
Name: "test-nginx",
Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
}
release, err := hspm.Install(installOpts)
if release != nil {
defer hspm.Uninstall(options.UninstallOptions{
Name: "test-nginx",
})
}
is.NoError(err, "should successfully install release")
is.NotNil(release, "should return non-nil release")
is.Equal("test-nginx", release.Name, "release name should match")
is.Equal(1, release.Version, "release version should be 1")
is.NotEmpty(release.Manifest, "release manifest should not be empty")
})
t.Run("successfully installs nginx with values", func(t *testing.T) {
// SDK equivalent of: helm install test-nginx-2 --repo https://kubernetes.github.io/ingress-nginx nginx --values /tmp/helm-values3161785816
values, err := createValuesFile("service:\n port: 8081")
is.NoError(err, "should create a values file")
defer os.Remove(values)
installOpts := options.InstallOptions{
Name: "test-nginx-2",
Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
ValuesFile: values,
}
release, err := hspm.Install(installOpts)
if release != nil {
defer hspm.Uninstall(options.UninstallOptions{
Name: "test-nginx-2",
})
}
is.NoError(err, "should successfully install release")
is.NotNil(release, "should return non-nil release")
is.Equal("test-nginx-2", release.Name, "release name should match")
is.Equal(1, release.Version, "release version should be 1")
is.NotEmpty(release.Manifest, "release manifest should not be empty")
})
t.Run("successfully installs portainer chart with name portainer-test", func(t *testing.T) {
// SDK equivalent of: helm install portainer-test portainer --repo https://portainer.github.io/k8s/
installOpts := options.InstallOptions{
Name: "portainer-test",
Chart: "portainer",
Repo: "https://portainer.github.io/k8s/",
}
release, err := hspm.Install(installOpts)
if release != nil {
defer hspm.Uninstall(options.UninstallOptions{
Name: installOpts.Name,
})
}
is.NoError(err, "should successfully install release")
is.NotNil(release, "should return non-nil release")
is.Equal("portainer-test", release.Name, "release name should match")
is.Equal(1, release.Version, "release version should be 1")
is.NotEmpty(release.Manifest, "release manifest should not be empty")
})
t.Run("install with values as string", func(t *testing.T) {
// First create a values file since InstallOptions doesn't support values as string directly
values, err := createValuesFile("service:\n port: 8082")
is.NoError(err, "should create a values file")
defer os.Remove(values)
// Install with values file
installOpts := options.InstallOptions{
Name: "test-nginx-3",
Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
ValuesFile: values,
}
release, err := hspm.Install(installOpts)
if release != nil {
defer hspm.Uninstall(options.UninstallOptions{
Name: "test-nginx-3",
})
}
is.NoError(err, "should successfully install release")
is.NotNil(release, "should return non-nil release")
is.Equal("test-nginx-3", release.Name, "release name should match")
})
t.Run("install with namespace", func(t *testing.T) {
// Install with namespace
installOpts := options.InstallOptions{
Name: "test-nginx-4",
Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
Namespace: "default",
}
release, err := hspm.Install(installOpts)
if release != nil {
defer hspm.Uninstall(options.UninstallOptions{
Name: "test-nginx-4",
Namespace: "default",
})
}
is.NoError(err, "should successfully install release")
is.NotNil(release, "should return non-nil release")
is.Equal("test-nginx-4", release.Name, "release name should match")
is.Equal("default", release.Namespace, "release namespace should match")
})
t.Run("returns an error when name is not provided", func(t *testing.T) {
installOpts := options.InstallOptions{
Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
}
_, err := hspm.Install(installOpts)
is.Error(err, "should return an error when name is not provided")
is.Equal(err.Error(), "name is required")
})
t.Run("install with invalid chart", func(t *testing.T) {
// Install with invalid chart
installOpts := options.InstallOptions{
Name: "test-invalid",
Chart: "non-existent-chart",
Repo: "https://kubernetes.github.io/ingress-nginx",
}
_, err := hspm.Install(installOpts)
is.Error(err, "should return error when chart doesn't exist")
is.Equal(err.Error(), "failed to find the helm chart at the path: https://kubernetes.github.io/ingress-nginx/non-existent-chart")
})
t.Run("install with invalid repo", func(t *testing.T) {
// Install with invalid repo
installOpts := options.InstallOptions{
Name: "test-invalid-repo",
Chart: "nginx",
Repo: "https://non-existent-repo.example.com",
}
_, err := hspm.Install(installOpts)
is.Error(err, "should return error when repo doesn't exist")
})
}

108
pkg/libhelm/sdk/list.go Normal file
View file

@ -0,0 +1,108 @@
package sdk
import (
"fmt"
"strconv"
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/action"
sdkrelease "helm.sh/helm/v3/pkg/release"
)
// List implements the HelmPackageManager interface by using the Helm SDK to list releases.
// It returns a slice of ReleaseElement.
func (hspm *HelmSDKPackageManager) List(listOpts options.ListOptions) ([]release.ReleaseElement, error) {
log.Debug().
Str("context", "HelmClient").
Str("namespace", listOpts.Namespace).
Str("filter", listOpts.Filter).
Str("selector", listOpts.Selector).
Msg("Listing Helm releases")
// Initialize action configuration with kubernetes config
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, listOpts.Namespace, listOpts.KubernetesClusterAccess)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("namespace", listOpts.Namespace).
Err(err).
Msg("Failed to initialize helm configuration")
return nil, errors.Wrap(err, "failed to initialize helm configuration")
}
listClient, err := initListClient(actionConfig, listOpts)
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to initialize helm list client")
}
// Run the list operation
releases, err := listClient.Run()
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to list helm releases")
return []release.ReleaseElement{}, errors.Wrap(err, "failed to list helm releases")
}
// Convert from SDK release type to our release element type and return
return convertToReleaseElements(releases), nil
}
// convertToReleaseElements converts from the SDK release type to our release element type
func convertToReleaseElements(releases []*sdkrelease.Release) []release.ReleaseElement {
elements := make([]release.ReleaseElement, len(releases))
for i, rel := range releases {
chartName := fmt.Sprintf("%s-%s", rel.Chart.Metadata.Name, rel.Chart.Metadata.Version)
elements[i] = release.ReleaseElement{
Name: rel.Name,
Namespace: rel.Namespace,
Revision: strconv.Itoa(rel.Version),
Updated: rel.Info.LastDeployed.String(),
Status: string(rel.Info.Status),
Chart: chartName,
AppVersion: rel.Chart.Metadata.AppVersion,
}
}
return elements
}
// initListClient initializes the list client with the given options
// and return the list client.
func initListClient(actionConfig *action.Configuration, listOpts options.ListOptions) (*action.List, error) {
listClient := action.NewList(actionConfig)
// Configure list options
if listOpts.Filter != "" {
listClient.Filter = listOpts.Filter
}
if listOpts.Selector != "" {
listClient.Selector = listOpts.Selector
}
// If no namespace is specified in options, list across all namespaces
if listOpts.Namespace == "" {
listClient.AllNamespaces = true
}
// No limit by default
listClient.Limit = 0
// Show all releases, even if in a pending or failed state
listClient.All = true
// Set state mask to ensure proper filtering by status
listClient.SetStateMask()
return listClient, nil
}

View file

@ -0,0 +1,72 @@
package sdk
import (
"testing"
"github.com/stretchr/testify/assert"
"helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/release"
"helm.sh/helm/v3/pkg/time"
)
func Test_ConvertToReleaseElements(t *testing.T) {
is := assert.New(t)
// Create mock releases
releases := []*release.Release{
{
Name: "release1",
Namespace: "default",
Version: 1,
Info: &release.Info{
Status: release.StatusDeployed,
LastDeployed: time.Now(),
},
Chart: &chart.Chart{
Metadata: &chart.Metadata{
Name: "chart1",
Version: "1.0.0",
AppVersion: "1.0.0",
},
},
},
{
Name: "release2",
Namespace: "kube-system",
Version: 2,
Info: &release.Info{
Status: release.StatusFailed,
LastDeployed: time.Now(),
},
Chart: &chart.Chart{
Metadata: &chart.Metadata{
Name: "chart2",
Version: "2.0.0",
AppVersion: "2.0.0",
},
},
},
}
// Convert to release elements
elements := convertToReleaseElements(releases)
// Verify conversion
is.Len(elements, 2, "should return 2 release elements")
// Verify first release
is.Equal("release1", elements[0].Name, "first release name should be release1")
is.Equal("default", elements[0].Namespace, "first release namespace should be default")
is.Equal("1", elements[0].Revision, "first release revision should be 1")
is.Equal(string(release.StatusDeployed), elements[0].Status, "first release status should be deployed")
is.Equal("chart1-1.0.0", elements[0].Chart, "first release chart should be chart1-1.0.0")
is.Equal("1.0.0", elements[0].AppVersion, "first release app version should be 1.0.0")
// Verify second release
is.Equal("release2", elements[1].Name, "second release name should be release2")
is.Equal("kube-system", elements[1].Namespace, "second release namespace should be kube-system")
is.Equal("2", elements[1].Revision, "second release revision should be 2")
is.Equal(string(release.StatusFailed), elements[1].Status, "second release status should be failed")
is.Equal("chart2-2.0.0", elements[1].Chart, "second release chart should be chart2-2.0.0")
is.Equal("2.0.0", elements[1].AppVersion, "second release app version should be 2.0.0")
}

View file

@ -0,0 +1,23 @@
package sdk
import (
"time"
"github.com/portainer/portainer/pkg/libhelm/types"
"helm.sh/helm/v3/pkg/cli"
)
// HelmSDKPackageManager is a wrapper for the helm SDK which implements HelmPackageManager
type HelmSDKPackageManager struct {
settings *cli.EnvSettings
timeout time.Duration
}
// NewHelmSDKPackageManager initializes a new HelmPackageManager service using the Helm SDK
func NewHelmSDKPackageManager() types.HelmPackageManager {
settings := cli.New()
return &HelmSDKPackageManager{
settings: settings,
timeout: 300 * time.Second, // 5 minutes default timeout
}
}

View file

@ -0,0 +1,29 @@
package sdk
import (
"testing"
"time"
"github.com/portainer/portainer/pkg/libhelm/types"
"github.com/stretchr/testify/assert"
)
func Test_NewHelmSDKPackageManager(t *testing.T) {
is := assert.New(t)
// Test that NewHelmSDKPackageManager returns a non-nil HelmPackageManager
manager := NewHelmSDKPackageManager()
is.NotNil(manager, "should return non-nil HelmPackageManager")
// Test that the returned manager is of the correct type
_, ok := manager.(*HelmSDKPackageManager)
is.True(ok, "should return a *HelmSDKPackageManager")
// Test that the manager has the expected fields
sdkManager := manager.(*HelmSDKPackageManager)
is.NotNil(sdkManager.settings, "should have non-nil settings")
is.Equal(300*time.Second, sdkManager.timeout, "should have 5 minute timeout")
// Test that the manager implements the HelmPackageManager interface
var _ types.HelmPackageManager = manager
}

View file

@ -0,0 +1,351 @@
package sdk
import (
"net/url"
"os"
"path/filepath"
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
"helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/getter"
"helm.sh/helm/v3/pkg/repo"
)
var (
errRequiredSearchOptions = errors.New("repo is required")
errInvalidRepoURL = errors.New("the request failed since either the Helm repository was not found or the index.yaml is not valid")
)
type RepoIndex struct {
APIVersion string `json:"apiVersion"`
Entries map[string][]ChartInfo `json:"entries"`
Generated string `json:"generated"`
}
// SearchRepo downloads the `index.yaml` file for specified repo, parses it and returns JSON to caller.
func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoOptions) ([]byte, error) {
// Validate input options
if err := validateSearchRepoOptions(searchRepoOpts); err != nil {
log.Error().
Str("context", "HelmClient").
Str("repo", searchRepoOpts.Repo).
Err(err).
Msg("Missing required search repo options")
return nil, err
}
log.Debug().
Str("context", "HelmClient").
Str("repo", searchRepoOpts.Repo).
Msg("Searching repository")
// Parse and validate the repository URL
repoURL, err := parseRepoURL(searchRepoOpts.Repo)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("repo", searchRepoOpts.Repo).
Err(err).
Msg("Invalid repository URL")
return nil, err
}
// Set up Helm CLI environment
repoSettings := cli.New()
// Ensure all required Helm directories exist
if err := ensureHelmDirectoriesExist(repoSettings); err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to ensure Helm directories exist")
return nil, errors.Wrap(err, "failed to ensure Helm directories exist")
}
// Download the index file and update repository configuration
indexPath, err := downloadRepoIndex(repoURL.String(), repoSettings, searchRepoOpts.Repo)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("repo_url", repoURL.String()).
Err(err).
Msg("Failed to download repository index")
return nil, err
}
// Load and parse the index file
log.Debug().
Str("context", "HelmClient").
Str("index_path", indexPath).
Msg("Loading index file")
indexFile, err := loadIndexFile(indexPath)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("index_path", indexPath).
Err(err).
Msg("Failed to load index file")
return nil, err
}
// Convert the index file to our response format
result, err := convertIndexToResponse(indexFile)
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to convert index to response format")
return nil, errors.Wrap(err, "failed to convert index to response format")
}
log.Debug().
Str("context", "HelmClient").
Str("repo", searchRepoOpts.Repo).
Int("entries_count", len(indexFile.Entries)).
Msg("Successfully searched repository")
return json.Marshal(result)
}
// validateSearchRepoOptions validates the required search repository options.
func validateSearchRepoOptions(opts options.SearchRepoOptions) error {
if opts.Repo == "" {
return errRequiredSearchOptions
}
return nil
}
// parseRepoURL parses and validates the repository URL.
func parseRepoURL(repoURL string) (*url.URL, error) {
parsedURL, err := url.ParseRequestURI(repoURL)
if err != nil {
return nil, errors.Wrap(err, "invalid helm chart URL: "+repoURL)
}
return parsedURL, nil
}
// downloadRepoIndex downloads the index.yaml file from the repository and updates
// the repository configuration.
func downloadRepoIndex(repoURLString string, repoSettings *cli.EnvSettings, repoName string) (string, error) {
log.Debug().
Str("context", "helm_sdk_repo_index").
Str("repo_url", repoURLString).
Str("repo_name", repoName).
Msg("Creating chart repository object")
// Create chart repository object
rep, err := repo.NewChartRepository(
&repo.Entry{
URL: repoURLString,
},
getter.All(repoSettings),
)
if err != nil {
log.Error().
Str("context", "helm_sdk_repo_index").
Str("repo_url", repoURLString).
Err(err).
Msg("Failed to create chart repository object")
return "", errInvalidRepoURL
}
// Load repository configuration file
f, err := repo.LoadFile(repoSettings.RepositoryConfig)
if err != nil {
log.Error().
Str("context", "helm_sdk_repo_index").
Str("repo_config", repoSettings.RepositoryConfig).
Err(err).
Msg("Failed to load repo config")
return "", errors.Wrap(err, "failed to load repo config")
}
// Download the index file
log.Debug().
Str("context", "helm_sdk_repo_index").
Str("repo_url", repoURLString).
Msg("Downloading index file")
indexPath, err := rep.DownloadIndexFile()
if err != nil {
log.Error().
Str("context", "helm_sdk_repo_index").
Str("repo_url", repoURLString).
Err(err).
Msg("Failed to download index file")
return "", errors.Wrapf(err, "looks like %q is not a valid chart repository or cannot be reached", repoURLString)
}
// Update repository configuration
c := repo.Entry{
Name: repoName,
URL: repoURLString,
}
f.Update(&c)
// Write updated configuration
repoFile := repoSettings.RepositoryConfig
if err := f.WriteFile(repoFile, 0644); err != nil {
log.Error().
Str("context", "helm_sdk_repo_index").
Str("repo_file", repoSettings.RepositoryConfig).
Err(err).
Msg("Failed to write repository configuration")
return "", errors.Wrap(err, "failed to write repository configuration")
}
log.Debug().
Str("context", "helm_sdk_repo_index").
Str("index_path", indexPath).
Msg("Successfully downloaded index file")
return indexPath, nil
}
// loadIndexFile loads the index file from the given path.
func loadIndexFile(indexPath string) (*repo.IndexFile, error) {
indexFile, err := repo.LoadIndexFile(indexPath)
if err != nil {
return nil, errors.Wrapf(err, "failed to load downloaded index file: %s", indexPath)
}
return indexFile, nil
}
// convertIndexToResponse converts the Helm index file to our response format.
func convertIndexToResponse(indexFile *repo.IndexFile) (RepoIndex, error) {
result := RepoIndex{
APIVersion: indexFile.APIVersion,
Entries: make(map[string][]ChartInfo),
Generated: indexFile.Generated.String(),
}
// Convert Helm SDK types to our response types
for name, charts := range indexFile.Entries {
result.Entries[name] = convertChartsToChartInfo(charts)
}
return result, nil
}
// convertChartsToChartInfo converts Helm chart entries to ChartInfo objects.
func convertChartsToChartInfo(charts []*repo.ChartVersion) []ChartInfo {
chartInfos := make([]ChartInfo, len(charts))
for i, chart := range charts {
chartInfos[i] = ChartInfo{
Name: chart.Name,
Version: chart.Version,
AppVersion: chart.AppVersion,
Description: chart.Description,
Deprecated: chart.Deprecated,
Created: chart.Created.String(),
Digest: chart.Digest,
Home: chart.Home,
Sources: chart.Sources,
URLs: chart.URLs,
Icon: chart.Icon,
Annotations: chart.Annotations,
}
}
return chartInfos
}
// ChartInfo represents a Helm chart in the repository index
type ChartInfo struct {
Name string `json:"name"`
Version string `json:"version"`
AppVersion string `json:"appVersion"`
Description string `json:"description"`
Deprecated bool `json:"deprecated"`
Created string `json:"created"`
Digest string `json:"digest"`
Home string `json:"home"`
Sources []string `json:"sources"`
URLs []string `json:"urls"`
Icon string `json:"icon,omitempty"`
Annotations any `json:"annotations,omitempty"`
}
// ensureHelmDirectoriesExist checks and creates required Helm directories if they don't exist
func ensureHelmDirectoriesExist(settings *cli.EnvSettings) error {
log.Debug().
Str("context", "helm_sdk_dirs").
Msg("Ensuring Helm directories exist")
// List of directories to ensure exist
directories := []string{
filepath.Dir(settings.RepositoryConfig), // Repository config directory
settings.RepositoryCache, // Repository cache directory
filepath.Dir(settings.RegistryConfig), // Registry config directory
settings.PluginsDirectory, // Plugins directory
}
// Create each directory if it doesn't exist
for _, dir := range directories {
if dir == "" {
continue // Skip empty paths
}
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err := os.MkdirAll(dir, 0700); err != nil {
log.Error().
Str("context", "helm_sdk_dirs").
Str("directory", dir).
Err(err).
Msg("Failed to create directory")
return errors.Wrapf(err, "failed to create directory: %s", dir)
}
}
}
// Ensure registry config file exists
if settings.RegistryConfig != "" {
if _, err := os.Stat(settings.RegistryConfig); os.IsNotExist(err) {
// Create the directory if it doesn't exist
dir := filepath.Dir(settings.RegistryConfig)
if err := os.MkdirAll(dir, 0700); err != nil {
log.Error().
Str("context", "helm_sdk_dirs").
Str("directory", dir).
Err(err).
Msg("Failed to create directory")
return errors.Wrapf(err, "failed to create directory: %s", dir)
}
// Create an empty registry config file
if _, err := os.Create(settings.RegistryConfig); err != nil {
log.Error().
Str("context", "helm_sdk_dirs").
Str("file", settings.RegistryConfig).
Err(err).
Msg("Failed to create registry config file")
return errors.Wrapf(err, "failed to create registry config file: %s", settings.RegistryConfig)
}
}
}
// Ensure repository config file exists
if settings.RepositoryConfig != "" {
if _, err := os.Stat(settings.RepositoryConfig); os.IsNotExist(err) {
// Create an empty repository config file with default yaml structure
f := repo.NewFile()
if err := f.WriteFile(settings.RepositoryConfig, 0644); err != nil {
log.Error().
Str("context", "helm_sdk_dirs").
Str("file", settings.RepositoryConfig).
Err(err).
Msg("Failed to create repository config file")
return errors.Wrapf(err, "failed to create repository config file: %s", settings.RepositoryConfig)
}
}
}
log.Debug().
Str("context", "helm_sdk_dirs").
Msg("Successfully ensured all Helm directories exist")
return nil
}

View file

@ -0,0 +1,84 @@
package sdk
import (
"encoding/json"
"testing"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/stretchr/testify/assert"
)
type testCase struct {
name string
url string
invalid bool
}
var tests = []testCase{
{"not a helm repo", "https://portainer.io", true},
{"ingress helm repo", "https://kubernetes.github.io/ingress-nginx", false},
{"portainer helm repo", "https://portainer.github.io/k8s/", false},
{"elastic helm repo with trailing slash", "https://helm.elastic.co/", false},
{"lensesio helm repo without trailing slash", "https://lensesio.github.io/kafka-helm-charts", false},
}
func Test_SearchRepo(t *testing.T) {
is := assert.New(t)
// Create a new SDK package manager
hspm := NewHelmSDKPackageManager()
for _, test := range tests {
func(tc testCase) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
response, err := hspm.SearchRepo(options.SearchRepoOptions{Repo: tc.url})
if tc.invalid {
is.Errorf(err, "error expected: %s", tc.url)
} else {
is.NoError(err, "no error expected: %s", tc.url)
}
if err == nil {
is.NotEmpty(response, "response expected")
}
})
}(test)
}
t.Run("search repo with keyword", func(t *testing.T) {
// Search for charts with keyword
searchOpts := options.SearchRepoOptions{
Repo: "https://kubernetes.github.io/ingress-nginx",
}
responseBytes, err := hspm.SearchRepo(searchOpts)
// The function should not fail by design, even when not running in a k8s environment
is.NoError(err, "should not return error when not in k8s environment")
is.NotNil(responseBytes, "should return non-nil response")
is.NotEmpty(responseBytes, "should return non-empty response")
// Parse the ext response
var repoIndex RepoIndex
err = json.Unmarshal(responseBytes, &repoIndex)
is.NoError(err, "should parse JSON response without error")
is.NotEmpty(repoIndex, "should have at least one chart")
// Verify charts structure apiVersion, entries, generated
is.Equal("v1", repoIndex.APIVersion, "apiVersion should be v1")
is.NotEmpty(repoIndex.Entries, "entries should not be empty")
is.NotEmpty(repoIndex.Generated, "generated should not be empty")
// there should be at least one chart
is.Greater(len(repoIndex.Entries), 0, "should have at least one chart")
})
t.Run("search repo with empty repo URL", func(t *testing.T) {
// Search with empty repo URL
searchOpts := options.SearchRepoOptions{
Repo: "",
}
_, err := hspm.SearchRepo(searchOpts)
is.Error(err, "should return error when repo URL is empty")
})
}

131
pkg/libhelm/sdk/show.go Normal file
View file

@ -0,0 +1,131 @@
package sdk
import (
"fmt"
"os"
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/action"
)
var errRequiredShowOptions = errors.New("chart, repo and output format are required")
// Show implements the HelmPackageManager interface by using the Helm SDK to show chart information.
// It supports showing chart values, readme, and chart details based on the provided ShowOptions.
func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, error) {
if showOpts.Chart == "" || showOpts.Repo == "" || showOpts.OutputFormat == "" {
log.Error().
Str("context", "HelmClient").
Str("chart", showOpts.Chart).
Str("repo", showOpts.Repo).
Str("output_format", string(showOpts.OutputFormat)).
Msg("Missing required show options")
return nil, errRequiredShowOptions
}
log.Debug().
Str("context", "HelmClient").
Str("chart", showOpts.Chart).
Str("repo", showOpts.Repo).
Str("output_format", string(showOpts.OutputFormat)).
Msg("Showing chart information")
// Initialize action configuration
actionConfig := new(action.Configuration)
if err := actionConfig.Init(nil, "", "", func(format string, v ...interface{}) {}); err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to initialize helm configuration")
return nil, fmt.Errorf("failed to initialize helm configuration: %w", err)
}
// Create temporary directory for chart download
tempDir, err := os.MkdirTemp("", "helm-show-*")
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to create temp directory")
return nil, fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tempDir)
// Create showClient action
showClient, err := initShowClient(actionConfig, showOpts)
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to initialize helm show client")
return nil, fmt.Errorf("failed to initialize helm show client: %w", err)
}
// Locate and load the chart
log.Debug().
Str("context", "HelmClient").
Str("chart", showOpts.Chart).
Str("repo", showOpts.Repo).
Msg("Locating chart")
chartPath, err := showClient.ChartPathOptions.LocateChart(showOpts.Chart, hspm.settings)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("chart", showOpts.Chart).
Str("repo", showOpts.Repo).
Err(err).
Msg("Failed to locate chart")
return nil, fmt.Errorf("failed to locate chart: %w", err)
}
// Get the output based on the requested format
output, err := showClient.Run(chartPath)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("chart_path", chartPath).
Str("output_format", string(showOpts.OutputFormat)).
Err(err).
Msg("Failed to show chart info")
return nil, fmt.Errorf("failed to show chart info: %w", err)
}
log.Debug().
Str("context", "HelmClient").
Str("chart", showOpts.Chart).
Int("output_size", len(output)).
Msg("Successfully retrieved chart information")
return []byte(output), nil
}
// initShowClient initializes the show client with the given options
// and return the show client.
func initShowClient(actionConfig *action.Configuration, showOpts options.ShowOptions) (*action.Show, error) {
showClient := action.NewShowWithConfig(action.ShowAll, actionConfig)
showClient.ChartPathOptions.RepoURL = showOpts.Repo
showClient.ChartPathOptions.Version = "" // Latest version
// Set output type based on ShowOptions
switch showOpts.OutputFormat {
case options.ShowAll:
showClient.OutputFormat = action.ShowAll
case options.ShowChart:
showClient.OutputFormat = action.ShowChart
case options.ShowValues:
showClient.OutputFormat = action.ShowValues
case options.ShowReadme:
showClient.OutputFormat = action.ShowReadme
default:
log.Error().
Str("context", "HelmClient").
Str("output_format", string(showOpts.OutputFormat)).
Msg("Unsupported output format")
return nil, fmt.Errorf("unsupported output format: %s", showOpts.OutputFormat)
}
return showClient, nil
}

View file

@ -0,0 +1,102 @@
package sdk
import (
"testing"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/test"
"github.com/stretchr/testify/assert"
)
func Test_Show(t *testing.T) {
test.EnsureIntegrationTest(t)
is := assert.New(t)
// Create a new SDK package manager
hspm := NewHelmSDKPackageManager()
// install the ingress-nginx chart to test the show command
installOpts := options.InstallOptions{
Name: "ingress-nginx",
Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
}
release, err := hspm.Install(installOpts)
if release != nil || err != nil {
defer hspm.Uninstall(options.UninstallOptions{
Name: "ingress-nginx",
})
}
t.Run("show requires chart, repo and output format", func(t *testing.T) {
showOpts := options.ShowOptions{
Chart: "",
Repo: "",
OutputFormat: "",
}
_, err := hspm.Show(showOpts)
is.Error(err, "should return error when required options are missing")
is.Contains(err.Error(), "chart, repo and output format are required", "error message should indicate required options")
})
t.Run("show chart values", func(t *testing.T) {
showOpts := options.ShowOptions{
Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
OutputFormat: options.ShowValues,
}
values, err := hspm.Show(showOpts)
is.NoError(err, "should not return error when not in k8s environment")
is.NotEmpty(values, "should return non-empty values")
})
t.Run("show chart readme", func(t *testing.T) {
showOpts := options.ShowOptions{
Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
OutputFormat: options.ShowReadme,
}
readme, err := hspm.Show(showOpts)
is.NoError(err, "should not return error when not in k8s environment")
is.NotEmpty(readme, "should return non-empty readme")
})
t.Run("show chart definition", func(t *testing.T) {
showOpts := options.ShowOptions{
Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
OutputFormat: options.ShowChart,
}
chart, err := hspm.Show(showOpts)
is.NoError(err, "should not return error when not in k8s environment")
is.NotNil(chart, "should return non-nil chart definition")
})
t.Run("show all chart info", func(t *testing.T) {
showOpts := options.ShowOptions{
Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
OutputFormat: options.ShowAll,
}
info, err := hspm.Show(showOpts)
is.NoError(err, "should not return error when not in k8s environment")
is.NotEmpty(info, "should return non-empty chart info")
})
t.Run("show with invalid output format", func(t *testing.T) {
// Show with invalid output format
showOpts := options.ShowOptions{
Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
OutputFormat: "invalid",
}
_, err := hspm.Show(showOpts)
is.Error(err, "should return error with invalid output format")
is.Contains(err.Error(), "unsupported output format", "error message should indicate invalid output format")
})
}

View file

@ -0,0 +1,70 @@
package sdk
import (
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/action"
)
// Uninstall implements the HelmPackageManager interface by using the Helm SDK to uninstall a release.
func (hspm *HelmSDKPackageManager) Uninstall(uninstallOpts options.UninstallOptions) error {
if uninstallOpts.Name == "" {
log.Error().
Str("context", "HelmClient").
Msg("Release name is required")
return errors.New("release name is required")
}
log.Debug().
Str("context", "HelmClient").
Str("release", uninstallOpts.Name).
Str("namespace", uninstallOpts.Namespace).
Msg("Uninstalling Helm release")
// Initialize action configuration with kubernetes config
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, uninstallOpts.Namespace, uninstallOpts.KubernetesClusterAccess)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("release", uninstallOpts.Name).
Str("namespace", uninstallOpts.Namespace).
Err(err).
Msg("Failed to initialize helm configuration")
return errors.Wrap(err, "failed to initialize helm configuration")
}
// Create uninstallClient action
uninstallClient := action.NewUninstall(actionConfig)
// 'foreground' means the parent object remains in a "terminating" state until all of its children are deleted. This ensures that all dependent resources are completely removed before finalizing the deletion of the parent resource.
uninstallClient.DeletionPropagation = "foreground" // "background" or "orphan"
// Run the uninstallation
log.Info().
Str("context", "HelmClient").
Str("release", uninstallOpts.Name).
Str("namespace", uninstallOpts.Namespace).
Msg("Running uninstallation")
result, err := uninstallClient.Run(uninstallOpts.Name)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("release", uninstallOpts.Name).
Str("namespace", uninstallOpts.Namespace).
Err(err).
Msg("Failed to uninstall helm release")
return errors.Wrap(err, "failed to uninstall helm release")
}
if result != nil {
log.Debug().
Str("context", "HelmClient").
Str("release", uninstallOpts.Name).
Str("release_info", result.Release.Info.Description).
Msg("Uninstall result details")
}
return nil
}

View file

@ -0,0 +1,65 @@
package sdk
import (
"testing"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/test"
"github.com/stretchr/testify/assert"
)
func Test_Uninstall(t *testing.T) {
test.EnsureIntegrationTest(t)
is := assert.New(t)
// Create a new SDK package manager
hspm := NewHelmSDKPackageManager()
t.Run("uninstall requires a release name", func(t *testing.T) {
// Try to uninstall without a release name
uninstallOpts := options.UninstallOptions{
Name: "",
}
err := hspm.Uninstall(uninstallOpts)
is.Error(err, "should return error when release name is empty")
is.Contains(err.Error(), "release name is required", "error message should indicate release name is required")
})
t.Run("uninstall a non-existent release", func(t *testing.T) {
// Try to uninstall a release that doesn't exist
uninstallOpts := options.UninstallOptions{
Name: "non-existent-release",
}
err := hspm.Uninstall(uninstallOpts)
// The function should not fail by design, even when not running in a k8s environment
// However, it should return an error for a non-existent release
is.Error(err, "should return error when release doesn't exist")
is.Contains(err.Error(), "not found", "error message should indicate release not found")
})
// This test is commented out as it requires a real release to be installed first
t.Run("successfully uninstall an existing release", func(t *testing.T) {
// First install a release
installOpts := options.InstallOptions{
Name: "test-uninstall",
Chart: "nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
}
// Install the release
_, err := hspm.Install(installOpts)
if err != nil {
t.Logf("Error installing release: %v", err)
t.Skip("Skipping uninstall test because install failed")
return
}
// Now uninstall it
uninstallOpts := options.UninstallOptions{
Name: "test-uninstall",
}
err = hspm.Uninstall(uninstallOpts)
is.NoError(err, "should successfully uninstall release")
})
}

42
pkg/libhelm/sdk/values.go Normal file
View file

@ -0,0 +1,42 @@
package sdk
import (
"os"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
// GetHelmValuesFromFile reads the values file and parses it into a map[string]any
// and returns the map.
func (hspm *HelmSDKPackageManager) GetHelmValuesFromFile(valuesFile string) (map[string]any, error) {
var vals map[string]any
if valuesFile != "" {
log.Debug().
Str("context", "HelmClient").
Str("values_file", valuesFile).
Msg("Reading values file")
valuesData, err := os.ReadFile(valuesFile)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("values_file", valuesFile).
Err(err).
Msg("Failed to read values file")
return nil, errors.Wrap(err, "failed to read values file")
}
vals, err = hspm.parseValues(valuesData)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("values_file", valuesFile).
Err(err).
Msg("Failed to parse values file")
return nil, errors.Wrap(err, "failed to parse values file")
}
}
return vals, nil
}

View file

@ -1,4 +1,4 @@
package libhelmtest
package test
import (
"os"

View file

@ -3,9 +3,9 @@ package test
import (
"strings"
"github.com/portainer/portainer/pkg/libhelm"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/portainer/portainer/pkg/libhelm/types"
"github.com/pkg/errors"
"github.com/segmentio/encoding/json"
@ -31,8 +31,8 @@ const (
// Do not use this package for concurrent tests.
type helmMockPackageManager struct{}
// NewMockHelmBinaryPackageManager initializes a new HelmPackageManager service (a mock instance)
func NewMockHelmBinaryPackageManager(binaryPath string) libhelm.HelmPackageManager {
// NewMockHelmPackageManager initializes a new HelmPackageManager service (a mock instance)
func NewMockHelmPackageManager() types.HelmPackageManager {
return &helmMockPackageManager{}
}

View file

@ -1,16 +1,26 @@
package libhelm
package types
import (
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/repo"
)
// HelmPackageManager represents a service that interfaces with Helm
type HelmPackageManager interface {
Show(showOpts options.ShowOptions) ([]byte, error)
SearchRepo(searchRepoOpts options.SearchRepoOptions) ([]byte, error)
Get(getOpts options.GetOptions) ([]byte, error)
List(listOpts options.ListOptions) ([]release.ReleaseElement, error)
Install(installOpts options.InstallOptions) (*release.Release, error)
Uninstall(uninstallOpts options.UninstallOptions) error
}
type Repository interface {
Charts() (repo.ChartVersions, error)
}
type HelmRepo struct {
Settings *cli.EnvSettings
Orig *repo.Entry
}

View file

@ -15,6 +15,10 @@ func ValidateHelmRepositoryURL(repoUrl string, client *http.Client) error {
return errors.New("URL is required")
}
if strings.HasPrefix(repoUrl, "oci://") {
return errors.New("OCI repositories are not supported yet")
}
url, err := url.ParseRequestURI(repoUrl)
if err != nil {
return fmt.Errorf("invalid helm repository URL '%s': %w", repoUrl, err)

View file

@ -3,12 +3,12 @@ package libhelm
import (
"testing"
"github.com/portainer/portainer/pkg/libhelm/libhelmtest"
"github.com/portainer/portainer/pkg/libhelm/test"
"github.com/stretchr/testify/assert"
)
func Test_ValidateHelmRepositoryURL(t *testing.T) {
libhelmtest.EnsureIntegrationTest(t)
test.EnsureIntegrationTest(t)
is := assert.New(t)
type testCase struct {
@ -26,7 +26,7 @@ func Test_ValidateHelmRepositoryURL(t *testing.T) {
{"not helm repo", "http://google.com", true},
{"not valid repo with trailing slash", "http://google.com/", true},
{"not valid repo with trailing slashes", "http://google.com////", true},
{"ingress helm repo", "https://kubernetes.github.io/ingress-nginx/", false},
{"bitnami helm repo", "https://charts.bitnami.com/bitnami/", false},
{"gitlap helm repo", "https://charts.gitlab.io/", false},
{"portainer helm repo", "https://portainer.github.io/k8s/", false},
{"elastic helm repo", "https://helm.elastic.co/", false},