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

feat(helm): use helm upgrade for install [r8s-258] (#568)

This commit is contained in:
Ali 2025-03-26 11:32:26 +13:00 committed by GitHub
parent e68bd53e30
commit 0ebfe047d1
19 changed files with 613 additions and 150 deletions

View file

@ -42,7 +42,7 @@ func Test_helmDelete(t *testing.T) {
// Install a single chart directly, to be deleted by the handler // Install a single chart directly, to be deleted by the handler
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"} options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"}
h.helmPackageManager.Install(options) h.helmPackageManager.Upgrade(options)
t.Run("helmDelete succeeds with admin user", func(t *testing.T) { t.Run("helmDelete succeeds with admin user", func(t *testing.T) {
req := httptest.NewRequest(http.MethodDelete, "/1/kubernetes/helm/"+options.Name, nil) req := httptest.NewRequest(http.MethodDelete, "/1/kubernetes/helm/"+options.Name, nil)

View file

@ -125,7 +125,7 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*r
installOpts.ValuesFile = file.Name() installOpts.ValuesFile = file.Name()
} }
release, err := handler.helmPackageManager.Install(installOpts) release, err := handler.helmPackageManager.Upgrade(installOpts)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -43,7 +43,7 @@ func Test_helmList(t *testing.T) {
// Install a single chart. We expect to get these values back // Install a single chart. We expect to get these values back
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"} options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"}
h.helmPackageManager.Install(options) h.helmPackageManager.Upgrade(options)
t.Run("helmList", func(t *testing.T) { t.Run("helmList", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/1/kubernetes/helm", nil) req := httptest.NewRequest(http.MethodGet, "/1/kubernetes/helm", nil)

View file

@ -1,5 +1,7 @@
package options package options
import "time"
type InstallOptions struct { type InstallOptions struct {
Name string Name string
Chart string Chart string
@ -8,6 +10,7 @@ type InstallOptions struct {
Wait bool Wait bool
ValuesFile string ValuesFile string
PostRenderer string PostRenderer string
Timeout time.Duration
KubernetesClusterAccess *KubernetesClusterAccess KubernetesClusterAccess *KubernetesClusterAccess
// Optional environment vars to pass when running helm // Optional environment vars to pass when running helm

87
pkg/libhelm/sdk/common.go Normal file
View file

@ -0,0 +1,87 @@
package sdk
import (
"os"
"github.com/pkg/errors"
"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"
)
// loadAndValidateChartWithPathOptions 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) loadAndValidateChartWithPathOptions(chartPathOptions *action.ChartPathOptions, chartName, repoURL string, dependencyUpdate bool, operation string) (*chart.Chart, error) {
// Locate and load the chart
chartPathOptions.RepoURL = repoURL
chartPath, err := chartPathOptions.LocateChart(chartName, hspm.settings)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("chart", chartName).
Err(err).
Msg("Failed to locate chart for helm " + operation)
return nil, errors.Wrapf(err, "failed to find the helm chart at the path: %s/%s", repoURL, chartName)
}
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 " + operation)
return nil, errors.Wrap(err, "failed to load chart for helm "+operation)
}
// 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 "+operation)
if !dependencyUpdate {
return nil, err
}
log.Debug().
Str("context", "HelmClient").
Str("chart", chartName).
Msg("Updating chart dependencies for helm " + operation)
providers := getter.All(hspm.settings)
manager := &downloader.Manager{
Out: os.Stdout,
ChartPath: chartPath,
Keyring: 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", chartName).
Err(err).
Msg("Failed to update chart dependencies for helm " + operation)
return nil, errors.Wrap(err, "failed to update chart dependencies for helm "+operation)
}
// 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 " + operation)
return nil, errors.Wrap(err, "failed to reload chart after dependency update for helm "+operation)
}
}
}
return chartReq, nil
}

View file

@ -1,22 +1,18 @@
package sdk package sdk
import ( import (
"os" "time"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options" "github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release" "github.com/portainer/portainer/pkg/libhelm/release"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/action" "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" "helm.sh/helm/v3/pkg/postrender"
) )
// Install implements the HelmPackageManager interface by using the Helm SDK to install a chart. // Install implements the HelmPackageManager interface by using the Helm SDK to install a chart.
func (hspm *HelmSDKPackageManager) Install(installOpts options.InstallOptions) (*release.Release, error) { func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) (*release.Release, error) {
log.Debug(). log.Debug().
Str("context", "HelmClient"). Str("context", "HelmClient").
Str("chart", installOpts.Chart). Str("chart", installOpts.Chart).
@ -42,12 +38,7 @@ func (hspm *HelmSDKPackageManager) Install(installOpts options.InstallOptions) (
actionConfig := new(action.Configuration) actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, installOpts.Namespace, installOpts.KubernetesClusterAccess) err := hspm.initActionConfig(actionConfig, installOpts.Namespace, installOpts.KubernetesClusterAccess)
if err != nil { if err != nil {
log.Error(). // error is already logged in initActionConfig
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") return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release installation")
} }
@ -69,7 +60,7 @@ func (hspm *HelmSDKPackageManager) Install(installOpts options.InstallOptions) (
return nil, errors.Wrap(err, "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) chart, err := hspm.loadAndValidateChartWithPathOptions(&installClient.ChartPathOptions, installOpts.Chart, installOpts.Repo, installClient.DependencyUpdate, "release installation")
if err != nil { if err != nil {
log.Error(). log.Error().
Str("context", "HelmClient"). Str("context", "HelmClient").
@ -114,90 +105,29 @@ func (hspm *HelmSDKPackageManager) Install(installOpts options.InstallOptions) (
}, nil }, 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 // initInstallClient initializes the install client with the given options
// and return the install client. // and return the install client.
func initInstallClient(actionConfig *action.Configuration, installOpts options.InstallOptions) (*action.Install, error) { func initInstallClient(actionConfig *action.Configuration, installOpts options.InstallOptions) (*action.Install, error) {
installClient := action.NewInstall(actionConfig) installClient := action.NewInstall(actionConfig)
installClient.CreateNamespace = true installClient.CreateNamespace = true
installClient.DependencyUpdate = true installClient.DependencyUpdate = true
installClient.ReleaseName = installOpts.Name installClient.ReleaseName = installOpts.Name
installClient.Namespace = installOpts.Namespace
installClient.ChartPathOptions.RepoURL = installOpts.Repo installClient.ChartPathOptions.RepoURL = installOpts.Repo
installClient.Wait = installOpts.Wait installClient.Wait = installOpts.Wait
installClient.Timeout = installOpts.Timeout
// Set default values if not specified
if installOpts.Timeout == 0 {
installClient.Timeout = 5 * time.Minute
} else {
installClient.Timeout = installOpts.Timeout
}
if installOpts.Namespace == "" {
installClient.Namespace = "default"
} else {
installClient.Namespace = installOpts.Namespace
}
if installOpts.PostRenderer != "" { if installOpts.PostRenderer != "" {
postRenderer, err := postrender.NewExec(installOpts.PostRenderer) postRenderer, err := postrender.NewExec(installOpts.PostRenderer)
if err != nil { if err != nil {

View file

@ -9,26 +9,6 @@ import (
"github.com/stretchr/testify/assert" "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) { func Test_Install(t *testing.T) {
test.EnsureIntegrationTest(t) test.EnsureIntegrationTest(t)
is := assert.New(t) is := assert.New(t)
@ -44,10 +24,14 @@ func Test_Install(t *testing.T) {
Repo: "https://kubernetes.github.io/ingress-nginx", Repo: "https://kubernetes.github.io/ingress-nginx",
} }
release, err := hspm.Install(installOpts) hspm.Uninstall(options.UninstallOptions{
Name: installOpts.Name,
})
release, err := hspm.Upgrade(installOpts)
if release != nil { if release != nil {
defer hspm.Uninstall(options.UninstallOptions{ defer hspm.Uninstall(options.UninstallOptions{
Name: "test-nginx", Name: installOpts.Name,
}) })
} }
@ -60,9 +44,8 @@ func Test_Install(t *testing.T) {
t.Run("successfully installs nginx with values", func(t *testing.T) { 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 // 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") values, err := test.CreateValuesFile("service:\n port: 8081")
is.NoError(err, "should create a values file") is.NoError(err, "should create a values file")
defer os.Remove(values) defer os.Remove(values)
installOpts := options.InstallOptions{ installOpts := options.InstallOptions{
@ -71,10 +54,15 @@ func Test_Install(t *testing.T) {
Repo: "https://kubernetes.github.io/ingress-nginx", Repo: "https://kubernetes.github.io/ingress-nginx",
ValuesFile: values, ValuesFile: values,
} }
release, err := hspm.Install(installOpts)
hspm.Uninstall(options.UninstallOptions{
Name: installOpts.Name,
})
release, err := hspm.Upgrade(installOpts)
if release != nil { if release != nil {
defer hspm.Uninstall(options.UninstallOptions{ defer hspm.Uninstall(options.UninstallOptions{
Name: "test-nginx-2", Name: installOpts.Name,
}) })
} }
@ -92,7 +80,12 @@ func Test_Install(t *testing.T) {
Chart: "portainer", Chart: "portainer",
Repo: "https://portainer.github.io/k8s/", Repo: "https://portainer.github.io/k8s/",
} }
release, err := hspm.Install(installOpts)
hspm.Uninstall(options.UninstallOptions{
Name: installOpts.Name,
})
release, err := hspm.Upgrade(installOpts)
if release != nil { if release != nil {
defer hspm.Uninstall(options.UninstallOptions{ defer hspm.Uninstall(options.UninstallOptions{
Name: installOpts.Name, Name: installOpts.Name,
@ -108,9 +101,8 @@ func Test_Install(t *testing.T) {
t.Run("install with values as string", func(t *testing.T) { 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 // First create a values file since InstallOptions doesn't support values as string directly
values, err := createValuesFile("service:\n port: 8082") values, err := test.CreateValuesFile("service:\n port: 8082")
is.NoError(err, "should create a values file") is.NoError(err, "should create a values file")
defer os.Remove(values) defer os.Remove(values)
// Install with values file // Install with values file
@ -120,10 +112,15 @@ func Test_Install(t *testing.T) {
Repo: "https://kubernetes.github.io/ingress-nginx", Repo: "https://kubernetes.github.io/ingress-nginx",
ValuesFile: values, ValuesFile: values,
} }
release, err := hspm.Install(installOpts)
hspm.Uninstall(options.UninstallOptions{
Name: installOpts.Name,
})
release, err := hspm.Upgrade(installOpts)
if release != nil { if release != nil {
defer hspm.Uninstall(options.UninstallOptions{ defer hspm.Uninstall(options.UninstallOptions{
Name: "test-nginx-3", Name: installOpts.Name,
}) })
} }
@ -140,11 +137,15 @@ func Test_Install(t *testing.T) {
Repo: "https://kubernetes.github.io/ingress-nginx", Repo: "https://kubernetes.github.io/ingress-nginx",
Namespace: "default", Namespace: "default",
} }
release, err := hspm.Install(installOpts)
hspm.Uninstall(options.UninstallOptions{
Name: installOpts.Name,
})
release, err := hspm.Upgrade(installOpts)
if release != nil { if release != nil {
defer hspm.Uninstall(options.UninstallOptions{ defer hspm.Uninstall(options.UninstallOptions{
Name: "test-nginx-4", Name: installOpts.Name,
Namespace: "default",
}) })
} }
@ -159,10 +160,15 @@ func Test_Install(t *testing.T) {
Chart: "ingress-nginx", Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx", Repo: "https://kubernetes.github.io/ingress-nginx",
} }
_, err := hspm.Install(installOpts)
hspm.Uninstall(options.UninstallOptions{
Name: installOpts.Name,
})
_, err := hspm.Upgrade(installOpts)
is.Error(err, "should return an error when name is not provided") is.Error(err, "should return an error when name is not provided")
is.Equal(err.Error(), "name is required") // is.Equal(err.Error(), "name is required for helm release installation")
}) })
t.Run("install with invalid chart", func(t *testing.T) { t.Run("install with invalid chart", func(t *testing.T) {
@ -172,9 +178,8 @@ func Test_Install(t *testing.T) {
Chart: "non-existent-chart", Chart: "non-existent-chart",
Repo: "https://kubernetes.github.io/ingress-nginx", Repo: "https://kubernetes.github.io/ingress-nginx",
} }
_, err := hspm.Install(installOpts) _, err := hspm.Upgrade(installOpts)
is.Error(err, "should return error when chart doesn't exist") 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) { t.Run("install with invalid repo", func(t *testing.T) {
@ -184,7 +189,12 @@ func Test_Install(t *testing.T) {
Chart: "nginx", Chart: "nginx",
Repo: "https://non-existent-repo.example.com", Repo: "https://non-existent-repo.example.com",
} }
_, err := hspm.Install(installOpts)
hspm.Uninstall(options.UninstallOptions{
Name: installOpts.Name,
})
_, err := hspm.Upgrade(installOpts)
is.Error(err, "should return error when repo doesn't exist") is.Error(err, "should return error when repo doesn't exist")
}) })
} }

View file

@ -26,11 +26,7 @@ func (hspm *HelmSDKPackageManager) List(listOpts options.ListOptions) ([]release
actionConfig := new(action.Configuration) actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, listOpts.Namespace, listOpts.KubernetesClusterAccess) err := hspm.initActionConfig(actionConfig, listOpts.Namespace, listOpts.KubernetesClusterAccess)
if err != nil { if err != nil {
log.Error(). // error is already logged in initActionConfig
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") return nil, errors.Wrap(err, "failed to initialize helm configuration")
} }

View file

@ -0,0 +1,47 @@
package sdk
import (
"fmt"
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/release"
"helm.sh/helm/v3/pkg/storage/driver"
)
func (hspm *HelmSDKPackageManager) doesReleaseExist(releaseName, namespace string, clusterAccess *options.KubernetesClusterAccess) (bool, error) {
// Initialize action configuration
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, namespace, clusterAccess)
if err != nil {
// error is already logged in initActionConfig
return false, fmt.Errorf("failed to initialize helm configuration: %w", err)
}
historyClient, err := hspm.initHistoryClient(actionConfig, namespace, clusterAccess)
if err != nil {
// error is already logged in initHistoryClient
return false, fmt.Errorf("failed to initialize helm history client: %w", err)
}
versions, err := historyClient.Run(releaseName)
if errors.Is(err, driver.ErrReleaseNotFound) || isReleaseUninstalled(versions) {
return false, nil
} else if err != nil {
return false, fmt.Errorf("failed to get history: %w", err)
}
return true, nil
}
func isReleaseUninstalled(versions []*release.Release) bool {
return len(versions) > 0 && versions[len(versions)-1].Info.Status == release.StatusUninstalled
}
func (hspm *HelmSDKPackageManager) initHistoryClient(actionConfig *action.Configuration, namespace string, clusterAccess *options.KubernetesClusterAccess) (*action.History, error) {
historyClient := action.NewHistory(actionConfig)
historyClient.Max = 1
return historyClient, nil
}

View file

@ -32,13 +32,11 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e
Str("output_format", string(showOpts.OutputFormat)). Str("output_format", string(showOpts.OutputFormat)).
Msg("Showing chart information") Msg("Showing chart information")
// Initialize action configuration // Initialize action configuration (no namespace or cluster access needed)
actionConfig := new(action.Configuration) actionConfig := new(action.Configuration)
if err := actionConfig.Init(nil, "", "", func(format string, v ...interface{}) {}); err != nil { err := hspm.initActionConfig(actionConfig, "", nil)
log.Error(). if err != nil {
Str("context", "HelmClient"). // error is already logged in initActionConfig
Err(err).
Msg("Failed to initialize helm configuration")
return nil, fmt.Errorf("failed to initialize helm configuration: %w", err) return nil, fmt.Errorf("failed to initialize helm configuration: %w", err)
} }

View file

@ -21,7 +21,7 @@ func Test_Show(t *testing.T) {
Chart: "ingress-nginx", Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx", Repo: "https://kubernetes.github.io/ingress-nginx",
} }
release, err := hspm.Install(installOpts) release, err := hspm.Upgrade(installOpts)
if release != nil || err != nil { if release != nil || err != nil {
defer hspm.Uninstall(options.UninstallOptions{ defer hspm.Uninstall(options.UninstallOptions{
Name: "ingress-nginx", Name: "ingress-nginx",

View file

@ -0,0 +1,26 @@
package testutils
import (
"os"
)
// CreateValuesFile creates a temporary file with the given content for testing
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
}

View file

@ -26,12 +26,7 @@ func (hspm *HelmSDKPackageManager) Uninstall(uninstallOpts options.UninstallOpti
actionConfig := new(action.Configuration) actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, uninstallOpts.Namespace, uninstallOpts.KubernetesClusterAccess) err := hspm.initActionConfig(actionConfig, uninstallOpts.Namespace, uninstallOpts.KubernetesClusterAccess)
if err != nil { if err != nil {
log.Error(). // error is already logged in initActionConfig
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") return errors.Wrap(err, "failed to initialize helm configuration")
} }

View file

@ -48,7 +48,7 @@ func Test_Uninstall(t *testing.T) {
} }
// Install the release // Install the release
_, err := hspm.Install(installOpts) _, err := hspm.Upgrade(installOpts)
if err != nil { if err != nil {
t.Logf("Error installing release: %v", err) t.Logf("Error installing release: %v", err)
t.Skip("Skipping uninstall test because install failed") t.Skip("Skipping uninstall test because install failed")

161
pkg/libhelm/sdk/upgrade.go Normal file
View file

@ -0,0 +1,161 @@
package sdk
import (
"time"
"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/postrender"
)
// Upgrade implements the HelmPackageManager interface by using the Helm SDK to upgrade a chart.
// If the release does not exist, it will install it instead.
func (hspm *HelmSDKPackageManager) Upgrade(upgradeOpts options.InstallOptions) (*release.Release, error) {
log.Debug().
Str("context", "HelmClient").
Str("chart", upgradeOpts.Chart).
Str("name", upgradeOpts.Name).
Str("namespace", upgradeOpts.Namespace).
Str("repo", upgradeOpts.Repo).
Bool("wait", upgradeOpts.Wait).
Msg("Upgrading Helm chart")
if upgradeOpts.Name == "" {
log.Error().
Str("context", "HelmClient").
Str("chart", upgradeOpts.Chart).
Str("name", upgradeOpts.Name).
Str("namespace", upgradeOpts.Namespace).
Str("repo", upgradeOpts.Repo).
Bool("wait", upgradeOpts.Wait).
Msg("Name is required for helm release upgrade")
return nil, errors.New("name is required for helm release upgrade")
}
// Check if the release exists
exists, err := hspm.doesReleaseExist(upgradeOpts.Name, upgradeOpts.Namespace, upgradeOpts.KubernetesClusterAccess)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("name", upgradeOpts.Name).
Str("namespace", upgradeOpts.Namespace).
Err(err).
Msg("Failed to check if release exists")
return nil, errors.Wrap(err, "failed to check if release exists")
}
// If the release doesn't exist, install it instead
if !exists {
log.Info().
Str("context", "HelmClient").
Str("chart", upgradeOpts.Chart).
Str("name", upgradeOpts.Name).
Str("namespace", upgradeOpts.Namespace).
Msg("Release doesn't exist, installing instead")
return hspm.install(upgradeOpts)
}
// Initialize action configuration with kubernetes config
actionConfig := new(action.Configuration)
err = hspm.initActionConfig(actionConfig, upgradeOpts.Namespace, upgradeOpts.KubernetesClusterAccess)
if err != nil {
// error is already logged in initActionConfig
return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release upgrade")
}
upgradeClient, err := initUpgradeClient(actionConfig, upgradeOpts)
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to initialize helm upgrade client for helm release upgrade")
return nil, errors.Wrap(err, "failed to initialize helm upgrade client for helm release upgrade")
}
values, err := hspm.GetHelmValuesFromFile(upgradeOpts.ValuesFile)
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to get Helm values from file for helm release upgrade")
return nil, errors.Wrap(err, "failed to get Helm values from file for helm release upgrade")
}
chart, err := hspm.loadAndValidateChartWithPathOptions(&upgradeClient.ChartPathOptions, upgradeOpts.Chart, upgradeOpts.Repo, upgradeClient.DependencyUpdate, "release upgrade")
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to load and validate chart for helm release upgrade")
return nil, errors.Wrap(err, "failed to load and validate chart for helm release upgrade")
}
log.Info().
Str("context", "HelmClient").
Str("chart", upgradeOpts.Chart).
Str("name", upgradeOpts.Name).
Str("namespace", upgradeOpts.Namespace).
Msg("Running chart upgrade for helm release")
helmRelease, err := upgradeClient.Run(upgradeOpts.Name, chart, values)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("chart", upgradeOpts.Chart).
Str("name", upgradeOpts.Name).
Str("namespace", upgradeOpts.Namespace).
Err(err).
Msg("Failed to upgrade helm chart for helm release upgrade")
return nil, errors.Wrap(err, "helm was not able to upgrade the chart for helm release upgrade")
}
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
}
// initUpgradeClient initializes the upgrade client with the given options
// and return the upgrade client.
func initUpgradeClient(actionConfig *action.Configuration, upgradeOpts options.InstallOptions) (*action.Upgrade, error) {
upgradeClient := action.NewUpgrade(actionConfig)
upgradeClient.DependencyUpdate = true
upgradeClient.Atomic = true
upgradeClient.ChartPathOptions.RepoURL = upgradeOpts.Repo
upgradeClient.Wait = upgradeOpts.Wait
// Set default values if not specified
if upgradeOpts.Timeout == 0 {
upgradeClient.Timeout = 5 * time.Minute
} else {
upgradeClient.Timeout = upgradeOpts.Timeout
}
if upgradeOpts.Namespace == "" {
upgradeOpts.Namespace = "default"
} else {
upgradeClient.Namespace = upgradeOpts.Namespace
}
if upgradeOpts.PostRenderer != "" {
postRenderer, err := postrender.NewExec(upgradeOpts.PostRenderer)
if err != nil {
return nil, errors.Wrap(err, "failed to create post renderer")
}
upgradeClient.PostRenderer = postRenderer
}
return upgradeClient, nil
}

View file

@ -0,0 +1,179 @@
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 TestUpgrade(t *testing.T) {
test.EnsureIntegrationTest(t)
is := assert.New(t)
// Create a new SDK package manager
hspm := NewHelmSDKPackageManager()
t.Run("when no release exists, the chart should be installed", func(t *testing.T) {
// SDK equivalent of: helm upgrade --install test-new-nginx --repo https://kubernetes.github.io/ingress-nginx ingress-nginx
upgradeOpts := options.InstallOptions{
Name: "test-new-nginx",
Namespace: "default",
Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
}
// Ensure the release doesn't exist before test
hspm.Uninstall(options.UninstallOptions{
Name: upgradeOpts.Name,
})
release, err := hspm.Upgrade(upgradeOpts)
if release != nil {
defer hspm.Uninstall(options.UninstallOptions{
Name: upgradeOpts.Name,
})
}
is.NoError(err, "should successfully install release via upgrade")
is.NotNil(release, "should return non-nil release")
is.Equal(upgradeOpts.Name, release.Name, "release name should match")
is.Equal(1, release.Version, "release version should be 1 for new install")
is.NotEmpty(release.Manifest, "release manifest should not be empty")
// Cleanup
defer hspm.Uninstall(options.UninstallOptions{
Name: upgradeOpts.Name,
})
})
t.Run("when release exists, the chart should be upgraded", func(t *testing.T) {
// First install a release
installOpts := options.InstallOptions{
Name: "test-upgrade-nginx",
Chart: "ingress-nginx",
Namespace: "default",
Repo: "https://kubernetes.github.io/ingress-nginx",
}
// Ensure the release doesn't exist before test
hspm.Uninstall(options.UninstallOptions{
Name: installOpts.Name,
})
release, err := hspm.Upgrade(installOpts)
defer hspm.Uninstall(options.UninstallOptions{
Name: installOpts.Name,
})
is.NoError(err, "should successfully install release")
is.NotNil(release, "should return non-nil release")
// Upgrade the release with the same options
upgradedRelease, err := hspm.Upgrade(installOpts)
is.NoError(err, "should successfully upgrade release")
is.NotNil(upgradedRelease, "should return non-nil release")
is.Equal("test-upgrade-nginx", upgradedRelease.Name, "release name should match")
is.Equal(2, upgradedRelease.Version, "release version should be incremented to 2")
is.NotEmpty(upgradedRelease.Manifest, "release manifest should not be empty")
})
t.Run("should be able to upgrade with override values", func(t *testing.T) {
// First install a release
installOpts := options.InstallOptions{
Name: "test-values-nginx",
Chart: "ingress-nginx",
Namespace: "default",
Repo: "https://kubernetes.github.io/ingress-nginx",
}
// Ensure the release doesn't exist before test
hspm.Uninstall(options.UninstallOptions{
Name: installOpts.Name,
})
release, err := hspm.Upgrade(installOpts) // Cleanup
defer hspm.Uninstall(options.UninstallOptions{
Name: installOpts.Name,
})
is.NoError(err, "should successfully install release")
is.NotNil(release, "should return non-nil release")
// Create values file
values, err := test.CreateValuesFile("service:\n port: 8083")
is.NoError(err, "should create a values file")
defer os.Remove(values)
// Now upgrade with values
upgradeOpts := options.InstallOptions{
Name: "test-values-nginx",
Chart: "ingress-nginx",
Namespace: "default",
Repo: "https://kubernetes.github.io/ingress-nginx",
ValuesFile: values,
}
upgradedRelease, err := hspm.Upgrade(upgradeOpts)
is.NoError(err, "should successfully upgrade release with values")
is.NotNil(upgradedRelease, "should return non-nil release")
is.Equal("test-values-nginx", upgradedRelease.Name, "release name should match")
is.Equal(2, upgradedRelease.Version, "release version should be incremented to 2")
is.NotEmpty(upgradedRelease.Manifest, "release manifest should not be empty")
})
t.Run("should give an error if the override values are invalid", func(t *testing.T) {
// First install a release
installOpts := options.InstallOptions{
Name: "test-invalid-values",
Chart: "ingress-nginx",
Namespace: "default",
Repo: "https://kubernetes.github.io/ingress-nginx",
}
// Ensure the release doesn't exist before test
hspm.Uninstall(options.UninstallOptions{
Name: installOpts.Name,
})
release, err := hspm.Upgrade(installOpts)
defer hspm.Uninstall(options.UninstallOptions{
Name: installOpts.Name,
})
is.NoError(err, "should successfully install release")
is.NotNil(release, "should return non-nil release")
// Create invalid values file
values, err := test.CreateValuesFile("this is not valid yaml")
is.NoError(err, "should create a values file")
defer os.Remove(values)
// Now upgrade with invalid values
upgradeOpts := options.InstallOptions{
Name: "test-invalid-values",
Chart: "ingress-nginx",
Namespace: "default",
Repo: "https://kubernetes.github.io/ingress-nginx",
ValuesFile: values,
}
_, err = hspm.Upgrade(upgradeOpts)
is.Error(err, "should return error with invalid values")
})
t.Run("should return error when name is not provided", func(t *testing.T) {
upgradeOpts := options.InstallOptions{
Chart: "ingress-nginx",
Namespace: "default",
Repo: "https://kubernetes.github.io/ingress-nginx",
}
_, err := hspm.Upgrade(upgradeOpts)
is.Error(err, "should return an error when name is not provided")
is.Equal("name is required for helm release upgrade", err.Error(), "should return correct error message")
})
}

View file

@ -73,6 +73,11 @@ func (hpm *helmMockPackageManager) Install(installOpts options.InstallOptions) (
return newMockRelease(releaseElement), nil return newMockRelease(releaseElement), nil
} }
// Upgrade a helm chart (not thread safe)
func (hpm *helmMockPackageManager) Upgrade(upgradeOpts options.InstallOptions) (*release.Release, error) {
return hpm.Install(upgradeOpts)
}
// Show values/readme/chart etc // Show values/readme/chart etc
func (hpm *helmMockPackageManager) Show(showOpts options.ShowOptions) ([]byte, error) { func (hpm *helmMockPackageManager) Show(showOpts options.ShowOptions) ([]byte, error) {
switch showOpts.OutputFormat { switch showOpts.OutputFormat {

View file

@ -0,0 +1,26 @@
package test
import (
"os"
)
// CreateValuesFile creates a temporary file with the given content for testing
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
}

View file

@ -12,7 +12,7 @@ type HelmPackageManager interface {
Show(showOpts options.ShowOptions) ([]byte, error) Show(showOpts options.ShowOptions) ([]byte, error)
SearchRepo(searchRepoOpts options.SearchRepoOptions) ([]byte, error) SearchRepo(searchRepoOpts options.SearchRepoOptions) ([]byte, error)
List(listOpts options.ListOptions) ([]release.ReleaseElement, error) List(listOpts options.ListOptions) ([]release.ReleaseElement, error)
Install(installOpts options.InstallOptions) (*release.Release, error) Upgrade(upgradeOpts options.InstallOptions) (*release.Release, error)
Uninstall(uninstallOpts options.UninstallOptions) error Uninstall(uninstallOpts options.UninstallOptions) error
} }