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:
parent
0d25f3f430
commit
b5961d79f8
56 changed files with 2222 additions and 819 deletions
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
165
pkg/libhelm/sdk/client.go
Normal 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...)
|
||||
}
|
156
pkg/libhelm/sdk/client_test.go
Normal file
156
pkg/libhelm/sdk/client_test.go
Normal 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
210
pkg/libhelm/sdk/install.go
Normal 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
|
||||
}
|
190
pkg/libhelm/sdk/install_test.go
Normal file
190
pkg/libhelm/sdk/install_test.go
Normal 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
108
pkg/libhelm/sdk/list.go
Normal 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
|
||||
}
|
72
pkg/libhelm/sdk/list_test.go
Normal file
72
pkg/libhelm/sdk/list_test.go
Normal 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")
|
||||
}
|
23
pkg/libhelm/sdk/manager.go
Normal file
23
pkg/libhelm/sdk/manager.go
Normal 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
|
||||
}
|
||||
}
|
29
pkg/libhelm/sdk/manager_test.go
Normal file
29
pkg/libhelm/sdk/manager_test.go
Normal 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
|
||||
}
|
351
pkg/libhelm/sdk/search_repo.go
Normal file
351
pkg/libhelm/sdk/search_repo.go
Normal 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
|
||||
}
|
84
pkg/libhelm/sdk/search_repo_test.go
Normal file
84
pkg/libhelm/sdk/search_repo_test.go
Normal 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
131
pkg/libhelm/sdk/show.go
Normal 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
|
||||
}
|
102
pkg/libhelm/sdk/show_test.go
Normal file
102
pkg/libhelm/sdk/show_test.go
Normal 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")
|
||||
})
|
||||
}
|
70
pkg/libhelm/sdk/uninstall.go
Normal file
70
pkg/libhelm/sdk/uninstall.go
Normal 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
|
||||
}
|
65
pkg/libhelm/sdk/uninstall_test.go
Normal file
65
pkg/libhelm/sdk/uninstall_test.go
Normal 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
42
pkg/libhelm/sdk/values.go
Normal 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
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package libhelmtest
|
||||
package test
|
||||
|
||||
import (
|
||||
"os"
|
|
@ -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{}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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},
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue