diff --git a/api/http/handler/helm/helm_delete_test.go b/api/http/handler/helm/helm_delete_test.go index ed77abdd2..3c90c1758 100644 --- a/api/http/handler/helm/helm_delete_test.go +++ b/api/http/handler/helm/helm_delete_test.go @@ -42,7 +42,7 @@ func Test_helmDelete(t *testing.T) { // Install a single chart directly, to be deleted by the handler 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) { req := httptest.NewRequest(http.MethodDelete, "/1/kubernetes/helm/"+options.Name, nil) diff --git a/api/http/handler/helm/helm_install.go b/api/http/handler/helm/helm_install.go index fffdf21bb..d7254c1f4 100644 --- a/api/http/handler/helm/helm_install.go +++ b/api/http/handler/helm/helm_install.go @@ -125,7 +125,7 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*r installOpts.ValuesFile = file.Name() } - release, err := handler.helmPackageManager.Install(installOpts) + release, err := handler.helmPackageManager.Upgrade(installOpts) if err != nil { return nil, err } diff --git a/api/http/handler/helm/helm_list_test.go b/api/http/handler/helm/helm_list_test.go index b8dd40354..9f69fe11d 100644 --- a/api/http/handler/helm/helm_list_test.go +++ b/api/http/handler/helm/helm_list_test.go @@ -43,7 +43,7 @@ func Test_helmList(t *testing.T) { // Install a single chart. We expect to get these values back 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) { req := httptest.NewRequest(http.MethodGet, "/1/kubernetes/helm", nil) diff --git a/pkg/libhelm/options/install_options.go b/pkg/libhelm/options/install_options.go index 5cc6081fb..943f28041 100644 --- a/pkg/libhelm/options/install_options.go +++ b/pkg/libhelm/options/install_options.go @@ -1,5 +1,7 @@ package options +import "time" + type InstallOptions struct { Name string Chart string @@ -8,6 +10,7 @@ type InstallOptions struct { Wait bool ValuesFile string PostRenderer string + Timeout time.Duration KubernetesClusterAccess *KubernetesClusterAccess // Optional environment vars to pass when running helm diff --git a/pkg/libhelm/sdk/common.go b/pkg/libhelm/sdk/common.go new file mode 100644 index 000000000..b1d218cae --- /dev/null +++ b/pkg/libhelm/sdk/common.go @@ -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 +} diff --git a/pkg/libhelm/sdk/install.go b/pkg/libhelm/sdk/install.go index 26b50927b..29f578806 100644 --- a/pkg/libhelm/sdk/install.go +++ b/pkg/libhelm/sdk/install.go @@ -1,22 +1,18 @@ package sdk import ( - "os" + "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/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) { +func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) (*release.Release, error) { log.Debug(). Str("context", "HelmClient"). Str("chart", installOpts.Chart). @@ -42,12 +38,7 @@ func (hspm *HelmSDKPackageManager) Install(installOpts options.InstallOptions) ( 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") + // error is already logged in initActionConfig 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") } - chart, err := hspm.loadAndValidateChart(installClient, installOpts) + chart, err := hspm.loadAndValidateChartWithPathOptions(&installClient.ChartPathOptions, installOpts.Chart, installOpts.Repo, installClient.DependencyUpdate, "release installation") if err != nil { log.Error(). Str("context", "HelmClient"). @@ -114,90 +105,29 @@ func (hspm *HelmSDKPackageManager) Install(installOpts options.InstallOptions) ( }, 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 + 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 != "" { postRenderer, err := postrender.NewExec(installOpts.PostRenderer) if err != nil { diff --git a/pkg/libhelm/sdk/install_test.go b/pkg/libhelm/sdk/install_test.go index 9bd0d04f3..d08ad3441 100644 --- a/pkg/libhelm/sdk/install_test.go +++ b/pkg/libhelm/sdk/install_test.go @@ -9,26 +9,6 @@ import ( "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) @@ -44,10 +24,14 @@ func Test_Install(t *testing.T) { 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 { 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) { // 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") - defer os.Remove(values) installOpts := options.InstallOptions{ @@ -71,10 +54,15 @@ func Test_Install(t *testing.T) { Repo: "https://kubernetes.github.io/ingress-nginx", ValuesFile: values, } - release, err := hspm.Install(installOpts) + + hspm.Uninstall(options.UninstallOptions{ + Name: installOpts.Name, + }) + + release, err := hspm.Upgrade(installOpts) if release != nil { defer hspm.Uninstall(options.UninstallOptions{ - Name: "test-nginx-2", + Name: installOpts.Name, }) } @@ -92,7 +80,12 @@ func Test_Install(t *testing.T) { Chart: "portainer", 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 { defer hspm.Uninstall(options.UninstallOptions{ Name: installOpts.Name, @@ -108,9 +101,8 @@ func Test_Install(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 - values, err := createValuesFile("service:\n port: 8082") + values, err := test.CreateValuesFile("service:\n port: 8082") is.NoError(err, "should create a values file") - defer os.Remove(values) // Install with values file @@ -120,10 +112,15 @@ func Test_Install(t *testing.T) { Repo: "https://kubernetes.github.io/ingress-nginx", ValuesFile: values, } - release, err := hspm.Install(installOpts) + + hspm.Uninstall(options.UninstallOptions{ + Name: installOpts.Name, + }) + + release, err := hspm.Upgrade(installOpts) if release != nil { 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", Namespace: "default", } - release, err := hspm.Install(installOpts) + + hspm.Uninstall(options.UninstallOptions{ + Name: installOpts.Name, + }) + + release, err := hspm.Upgrade(installOpts) if release != nil { defer hspm.Uninstall(options.UninstallOptions{ - Name: "test-nginx-4", - Namespace: "default", + Name: installOpts.Name, }) } @@ -159,10 +160,15 @@ func Test_Install(t *testing.T) { Chart: "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.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) { @@ -172,9 +178,8 @@ func Test_Install(t *testing.T) { Chart: "non-existent-chart", 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.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) { @@ -184,7 +189,12 @@ func Test_Install(t *testing.T) { Chart: "nginx", 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") }) } diff --git a/pkg/libhelm/sdk/list.go b/pkg/libhelm/sdk/list.go index 494298a91..6e688d1b6 100644 --- a/pkg/libhelm/sdk/list.go +++ b/pkg/libhelm/sdk/list.go @@ -26,11 +26,7 @@ func (hspm *HelmSDKPackageManager) List(listOpts options.ListOptions) ([]release 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") + // error is already logged in initActionConfig return nil, errors.Wrap(err, "failed to initialize helm configuration") } diff --git a/pkg/libhelm/sdk/release.go b/pkg/libhelm/sdk/release.go new file mode 100644 index 000000000..76f6840ff --- /dev/null +++ b/pkg/libhelm/sdk/release.go @@ -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 +} diff --git a/pkg/libhelm/sdk/show.go b/pkg/libhelm/sdk/show.go index aa674a8c0..45f77719a 100644 --- a/pkg/libhelm/sdk/show.go +++ b/pkg/libhelm/sdk/show.go @@ -32,13 +32,11 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e Str("output_format", string(showOpts.OutputFormat)). Msg("Showing chart information") - // Initialize action configuration + // Initialize action configuration (no namespace or cluster access needed) 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") + err := hspm.initActionConfig(actionConfig, "", nil) + if err != nil { + // error is already logged in initActionConfig return nil, fmt.Errorf("failed to initialize helm configuration: %w", err) } diff --git a/pkg/libhelm/sdk/show_test.go b/pkg/libhelm/sdk/show_test.go index 124aac02d..302f188ca 100644 --- a/pkg/libhelm/sdk/show_test.go +++ b/pkg/libhelm/sdk/show_test.go @@ -21,7 +21,7 @@ func Test_Show(t *testing.T) { Chart: "ingress-nginx", Repo: "https://kubernetes.github.io/ingress-nginx", } - release, err := hspm.Install(installOpts) + release, err := hspm.Upgrade(installOpts) if release != nil || err != nil { defer hspm.Uninstall(options.UninstallOptions{ Name: "ingress-nginx", diff --git a/pkg/libhelm/sdk/testutils/values.go b/pkg/libhelm/sdk/testutils/values.go new file mode 100644 index 000000000..c1d14c3e7 --- /dev/null +++ b/pkg/libhelm/sdk/testutils/values.go @@ -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 +} diff --git a/pkg/libhelm/sdk/uninstall.go b/pkg/libhelm/sdk/uninstall.go index 5459b9e84..c2927999c 100644 --- a/pkg/libhelm/sdk/uninstall.go +++ b/pkg/libhelm/sdk/uninstall.go @@ -26,12 +26,7 @@ func (hspm *HelmSDKPackageManager) Uninstall(uninstallOpts options.UninstallOpti 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") + // error is already logged in initActionConfig return errors.Wrap(err, "failed to initialize helm configuration") } diff --git a/pkg/libhelm/sdk/uninstall_test.go b/pkg/libhelm/sdk/uninstall_test.go index 28ef1cb21..684be1780 100644 --- a/pkg/libhelm/sdk/uninstall_test.go +++ b/pkg/libhelm/sdk/uninstall_test.go @@ -48,7 +48,7 @@ func Test_Uninstall(t *testing.T) { } // Install the release - _, err := hspm.Install(installOpts) + _, err := hspm.Upgrade(installOpts) if err != nil { t.Logf("Error installing release: %v", err) t.Skip("Skipping uninstall test because install failed") diff --git a/pkg/libhelm/sdk/upgrade.go b/pkg/libhelm/sdk/upgrade.go new file mode 100644 index 000000000..cc284cf31 --- /dev/null +++ b/pkg/libhelm/sdk/upgrade.go @@ -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 +} diff --git a/pkg/libhelm/sdk/upgrade_test.go b/pkg/libhelm/sdk/upgrade_test.go new file mode 100644 index 000000000..86b88d0c5 --- /dev/null +++ b/pkg/libhelm/sdk/upgrade_test.go @@ -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") + }) +} diff --git a/pkg/libhelm/test/mock.go b/pkg/libhelm/test/mock.go index 0b7712aab..58d53715c 100644 --- a/pkg/libhelm/test/mock.go +++ b/pkg/libhelm/test/mock.go @@ -73,6 +73,11 @@ func (hpm *helmMockPackageManager) Install(installOpts options.InstallOptions) ( 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 func (hpm *helmMockPackageManager) Show(showOpts options.ShowOptions) ([]byte, error) { switch showOpts.OutputFormat { diff --git a/pkg/libhelm/test/values.go b/pkg/libhelm/test/values.go new file mode 100644 index 000000000..a3f5df44c --- /dev/null +++ b/pkg/libhelm/test/values.go @@ -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 +} diff --git a/pkg/libhelm/types/types.go b/pkg/libhelm/types/types.go index 91b777270..8c0caa80b 100644 --- a/pkg/libhelm/types/types.go +++ b/pkg/libhelm/types/types.go @@ -12,7 +12,7 @@ type HelmPackageManager interface { Show(showOpts options.ShowOptions) ([]byte, error) SearchRepo(searchRepoOpts options.SearchRepoOptions) ([]byte, 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 }