github.com/Racer159/jackal@v0.32.7-0.20240401174413-0bd2339e4f2e/src/internal/packager/helm/chart.go (about)

     1  // SPDX-License-Identifier: Apache-2.0
     2  // SPDX-FileCopyrightText: 2021-Present The Jackal Authors
     3  
     4  // Package helm contains operations for working with helm charts.
     5  package helm
     6  
     7  import (
     8  	"errors"
     9  	"fmt"
    10  	"time"
    11  
    12  	"github.com/defenseunicorns/pkg/helpers"
    13  
    14  	"github.com/Masterminds/semver/v3"
    15  	"github.com/Racer159/jackal/src/config"
    16  	"github.com/Racer159/jackal/src/types"
    17  	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    18  	"sigs.k8s.io/yaml"
    19  
    20  	"github.com/Racer159/jackal/src/pkg/message"
    21  	"helm.sh/helm/v3/pkg/action"
    22  	"helm.sh/helm/v3/pkg/chartutil"
    23  	"helm.sh/helm/v3/pkg/releaseutil"
    24  
    25  	"helm.sh/helm/v3/pkg/chart"
    26  	"helm.sh/helm/v3/pkg/release"
    27  	"helm.sh/helm/v3/pkg/storage/driver"
    28  )
    29  
    30  // InstallOrUpgradeChart performs a helm install of the given chart.
    31  func (h *Helm) InstallOrUpgradeChart() (types.ConnectStrings, string, error) {
    32  	fromMessage := h.chart.URL
    33  	if fromMessage == "" {
    34  		fromMessage = "Jackal-generated helm chart"
    35  	}
    36  	spinner := message.NewProgressSpinner("Processing helm chart %s:%s from %s",
    37  		h.chart.Name,
    38  		h.chart.Version,
    39  		fromMessage)
    40  	defer spinner.Stop()
    41  
    42  	// If no release name is specified, use the chart name.
    43  	if h.chart.ReleaseName == "" {
    44  		h.chart.ReleaseName = h.chart.Name
    45  	}
    46  
    47  	// Do not wait for the chart to be ready if data injections are present.
    48  	if len(h.component.DataInjections) > 0 {
    49  		spinner.Updatef("Data injections detected, not waiting for chart to be ready")
    50  		h.chart.NoWait = true
    51  	}
    52  
    53  	// Setup K8s connection.
    54  	err := h.createActionConfig(h.chart.Namespace, spinner)
    55  	if err != nil {
    56  		return nil, "", fmt.Errorf("unable to initialize the K8s client: %w", err)
    57  	}
    58  
    59  	postRender, err := h.newRenderer()
    60  	if err != nil {
    61  		return nil, "", fmt.Errorf("unable to create helm renderer: %w", err)
    62  	}
    63  
    64  	histClient := action.NewHistory(h.actionConfig)
    65  	tryHelm := func() error {
    66  		var err error
    67  		var output *release.Release
    68  
    69  		releases, histErr := histClient.Run(h.chart.ReleaseName)
    70  
    71  		spinner.Updatef("Checking for existing helm deployment")
    72  
    73  		if errors.Is(histErr, driver.ErrReleaseNotFound) {
    74  			// No prior release, try to install it.
    75  			spinner.Updatef("Attempting chart installation")
    76  
    77  			output, err = h.installChart(postRender)
    78  		} else if histErr == nil && len(releases) > 0 {
    79  			// Otherwise, there is a prior release so upgrade it.
    80  			spinner.Updatef("Attempting chart upgrade")
    81  
    82  			lastRelease := releases[len(releases)-1]
    83  
    84  			output, err = h.upgradeChart(lastRelease, postRender)
    85  		} else {
    86  			// 😭 things aren't working
    87  			return fmt.Errorf("unable to verify the chart installation status: %w", histErr)
    88  		}
    89  
    90  		if err != nil {
    91  			return fmt.Errorf("unable to complete the helm chart install/upgrade: %w", err)
    92  		}
    93  
    94  		message.Debug(output.Info.Description)
    95  		spinner.Success()
    96  		return nil
    97  	}
    98  
    99  	err = helpers.Retry(tryHelm, h.retries, 5*time.Second, message.Warnf)
   100  	if err != nil {
   101  		// Try to rollback any deployed releases
   102  		releases, _ := histClient.Run(h.chart.ReleaseName)
   103  		previouslyDeployedVersion := 0
   104  
   105  		// Check for previous releases that successfully deployed
   106  		for _, release := range releases {
   107  			if release.Info.Status == "deployed" {
   108  				previouslyDeployedVersion = release.Version
   109  			}
   110  		}
   111  
   112  		// On total failure try to rollback (if there was a previously deployed version) or uninstall.
   113  		if previouslyDeployedVersion > 0 {
   114  			spinner.Updatef("Performing chart rollback")
   115  
   116  			err = h.rollbackChart(h.chart.ReleaseName, previouslyDeployedVersion)
   117  			if err != nil {
   118  				return nil, "", fmt.Errorf("unable to upgrade chart after %d attempts and unable to rollback: %w", h.retries, err)
   119  			}
   120  
   121  			return nil, "", fmt.Errorf("unable to upgrade chart after %d attempts", h.retries)
   122  		}
   123  
   124  		spinner.Updatef("Performing chart uninstall")
   125  		_, err = h.uninstallChart(h.chart.ReleaseName)
   126  		if err != nil {
   127  			return nil, "", fmt.Errorf("unable to install chart after %d attempts and unable to uninstall: %w", h.retries, err)
   128  		}
   129  
   130  		return nil, "", fmt.Errorf("unable to install chart after %d attempts", h.retries)
   131  	}
   132  
   133  	// return any collected connect strings for jackal connect.
   134  	return postRender.connectStrings, h.chart.ReleaseName, nil
   135  }
   136  
   137  // TemplateChart generates a helm template from a given chart.
   138  func (h *Helm) TemplateChart() (manifest string, chartValues chartutil.Values, err error) {
   139  	message.Debugf("helm.TemplateChart()")
   140  	spinner := message.NewProgressSpinner("Templating helm chart %s", h.chart.Name)
   141  	defer spinner.Stop()
   142  
   143  	err = h.createActionConfig(h.chart.Namespace, spinner)
   144  
   145  	// Setup K8s connection.
   146  	if err != nil {
   147  		return "", nil, fmt.Errorf("unable to initialize the K8s client: %w", err)
   148  	}
   149  
   150  	// Bind the helm action.
   151  	client := action.NewInstall(h.actionConfig)
   152  
   153  	client.DryRun = true
   154  	client.Replace = true // Skip the name check.
   155  	client.ClientOnly = true
   156  	client.IncludeCRDs = true
   157  	// TODO: Further research this with regular/OCI charts
   158  	client.Verify = false
   159  	client.InsecureSkipTLSverify = config.CommonOptions.Insecure
   160  	if h.kubeVersion != "" {
   161  		parsedKubeVersion, err := chartutil.ParseKubeVersion(h.kubeVersion)
   162  		if err != nil {
   163  			return "", nil, fmt.Errorf("invalid kube version '%s': %s", h.kubeVersion, err)
   164  		}
   165  		client.KubeVersion = parsedKubeVersion
   166  	}
   167  	client.ReleaseName = h.chart.ReleaseName
   168  
   169  	// If no release name is specified, use the chart name.
   170  	if client.ReleaseName == "" {
   171  		client.ReleaseName = h.chart.Name
   172  	}
   173  
   174  	// Namespace must be specified.
   175  	client.Namespace = h.chart.Namespace
   176  
   177  	loadedChart, chartValues, err := h.loadChartData()
   178  	if err != nil {
   179  		return "", nil, fmt.Errorf("unable to load chart data: %w", err)
   180  	}
   181  
   182  	client.PostRenderer, err = h.newRenderer()
   183  	if err != nil {
   184  		return "", nil, fmt.Errorf("unable to create helm renderer: %w", err)
   185  	}
   186  
   187  	// Perform the loadedChart installation.
   188  	templatedChart, err := client.Run(loadedChart, chartValues)
   189  	if err != nil {
   190  		return "", nil, fmt.Errorf("error generating helm chart template: %w", err)
   191  	}
   192  
   193  	manifest = templatedChart.Manifest
   194  
   195  	for _, hook := range templatedChart.Hooks {
   196  		manifest += fmt.Sprintf("\n---\n%s", hook.Manifest)
   197  	}
   198  
   199  	spinner.Success()
   200  
   201  	return manifest, chartValues, nil
   202  }
   203  
   204  // RemoveChart removes a chart from the cluster.
   205  func (h *Helm) RemoveChart(namespace string, name string, spinner *message.Spinner) error {
   206  	// Establish a new actionConfig for the namespace.
   207  	_ = h.createActionConfig(namespace, spinner)
   208  	// Perform the uninstall.
   209  	response, err := h.uninstallChart(name)
   210  	message.Debug(response)
   211  	return err
   212  }
   213  
   214  // UpdateReleaseValues updates values for a given chart release
   215  // (note: this only works on single-deep charts, charts with dependencies (like loki-stack) will not work)
   216  func (h *Helm) UpdateReleaseValues(updatedValues map[string]interface{}) error {
   217  	spinner := message.NewProgressSpinner("Updating values for helm release %s", h.chart.ReleaseName)
   218  	defer spinner.Stop()
   219  
   220  	err := h.createActionConfig(h.chart.Namespace, spinner)
   221  	if err != nil {
   222  		return fmt.Errorf("unable to initialize the K8s client: %w", err)
   223  	}
   224  
   225  	postRender, err := h.newRenderer()
   226  	if err != nil {
   227  		return fmt.Errorf("unable to create helm renderer: %w", err)
   228  	}
   229  
   230  	histClient := action.NewHistory(h.actionConfig)
   231  	histClient.Max = 1
   232  	releases, histErr := histClient.Run(h.chart.ReleaseName)
   233  	if histErr == nil && len(releases) > 0 {
   234  		lastRelease := releases[len(releases)-1]
   235  
   236  		// Setup a new upgrade action
   237  		client := action.NewUpgrade(h.actionConfig)
   238  
   239  		// Let each chart run for the default timeout.
   240  		client.Timeout = h.timeout
   241  
   242  		client.SkipCRDs = true
   243  
   244  		// Namespace must be specified.
   245  		client.Namespace = h.chart.Namespace
   246  
   247  		// Post-processing our manifests to apply vars and run jackal helm logic in cluster
   248  		client.PostRenderer = postRender
   249  
   250  		// Set reuse values to only override the values we are explicitly given
   251  		client.ReuseValues = true
   252  
   253  		// Wait for the update operation to successfully complete
   254  		client.Wait = true
   255  
   256  		// Perform the loadedChart upgrade.
   257  		_, err = client.Run(h.chart.ReleaseName, lastRelease.Chart, updatedValues)
   258  		if err != nil {
   259  			return err
   260  		}
   261  
   262  		spinner.Success()
   263  
   264  		return nil
   265  	}
   266  
   267  	return fmt.Errorf("unable to find the %s helm release", h.chart.ReleaseName)
   268  }
   269  
   270  func (h *Helm) installChart(postRender *renderer) (*release.Release, error) {
   271  	// Bind the helm action.
   272  	client := action.NewInstall(h.actionConfig)
   273  
   274  	// Let each chart run for the default timeout.
   275  	client.Timeout = h.timeout
   276  
   277  	// Default helm behavior for Jackal is to wait for the resources to deploy, NoWait overrides that for special cases (such as data-injection).
   278  	client.Wait = !h.chart.NoWait
   279  
   280  	// We need to include CRDs or operator installations will fail spectacularly.
   281  	client.SkipCRDs = false
   282  
   283  	// Must be unique per-namespace and < 53 characters. @todo: restrict helm loadedChart name to this.
   284  	client.ReleaseName = h.chart.ReleaseName
   285  
   286  	// Namespace must be specified.
   287  	client.Namespace = h.chart.Namespace
   288  
   289  	// Post-processing our manifests to apply vars and run jackal helm logic in cluster
   290  	client.PostRenderer = postRender
   291  
   292  	loadedChart, chartValues, err := h.loadChartData()
   293  	if err != nil {
   294  		return nil, fmt.Errorf("unable to load chart data: %w", err)
   295  	}
   296  
   297  	// Perform the loadedChart installation.
   298  	return client.Run(loadedChart, chartValues)
   299  }
   300  
   301  func (h *Helm) upgradeChart(lastRelease *release.Release, postRender *renderer) (*release.Release, error) {
   302  	// Migrate any deprecated APIs (if applicable)
   303  	err := h.migrateDeprecatedAPIs(lastRelease)
   304  	if err != nil {
   305  		return nil, fmt.Errorf("unable to check for API deprecations: %w", err)
   306  	}
   307  
   308  	// Setup a new upgrade action
   309  	client := action.NewUpgrade(h.actionConfig)
   310  
   311  	// Let each chart run for the default timeout.
   312  	client.Timeout = h.timeout
   313  
   314  	// Default helm behavior for Jackal is to wait for the resources to deploy, NoWait overrides that for special cases (such as data-injection).
   315  	client.Wait = !h.chart.NoWait
   316  
   317  	client.SkipCRDs = true
   318  
   319  	// Namespace must be specified.
   320  	client.Namespace = h.chart.Namespace
   321  
   322  	// Post-processing our manifests to apply vars and run jackal helm logic in cluster
   323  	client.PostRenderer = postRender
   324  
   325  	loadedChart, chartValues, err := h.loadChartData()
   326  	if err != nil {
   327  		return nil, fmt.Errorf("unable to load chart data: %w", err)
   328  	}
   329  
   330  	// Perform the loadedChart upgrade.
   331  	return client.Run(h.chart.ReleaseName, loadedChart, chartValues)
   332  }
   333  
   334  func (h *Helm) rollbackChart(name string, version int) error {
   335  	message.Debugf("helm.rollbackChart(%s)", name)
   336  	client := action.NewRollback(h.actionConfig)
   337  	client.CleanupOnFail = true
   338  	client.Force = true
   339  	client.Wait = true
   340  	client.Timeout = h.timeout
   341  	client.Version = version
   342  	return client.Run(name)
   343  }
   344  
   345  func (h *Helm) uninstallChart(name string) (*release.UninstallReleaseResponse, error) {
   346  	message.Debugf("helm.uninstallChart(%s)", name)
   347  	client := action.NewUninstall(h.actionConfig)
   348  	client.KeepHistory = false
   349  	client.Wait = true
   350  	client.Timeout = h.timeout
   351  	return client.Run(name)
   352  }
   353  
   354  func (h *Helm) loadChartData() (*chart.Chart, chartutil.Values, error) {
   355  	message.Debugf("helm.loadChartData()")
   356  	var (
   357  		loadedChart *chart.Chart
   358  		chartValues chartutil.Values
   359  		err         error
   360  	)
   361  
   362  	if h.chartOverride == nil {
   363  		// If there is no override, get the chart and values info.
   364  		loadedChart, err = h.loadChartFromTarball()
   365  		if err != nil {
   366  			return nil, nil, fmt.Errorf("unable to load chart tarball: %w", err)
   367  		}
   368  
   369  		chartValues, err = h.parseChartValues()
   370  		if err != nil {
   371  			return loadedChart, nil, fmt.Errorf("unable to parse chart values: %w", err)
   372  		}
   373  	} else {
   374  		// Otherwise, use the overrides instead.
   375  		loadedChart = h.chartOverride
   376  		chartValues = h.valuesOverrides
   377  	}
   378  
   379  	return loadedChart, chartValues, nil
   380  }
   381  
   382  func (h *Helm) migrateDeprecatedAPIs(latestRelease *release.Release) error {
   383  	// Get the Kubernetes version from the current cluster
   384  	kubeVersion, err := h.cluster.GetServerVersion()
   385  	if err != nil {
   386  		return err
   387  	}
   388  
   389  	kubeGitVersion, err := semver.NewVersion(kubeVersion)
   390  	if err != nil {
   391  		return err
   392  	}
   393  
   394  	// Use helm to re-split the manifest bytes (same call used by helm to pass this data to postRender)
   395  	_, resources, err := releaseutil.SortManifests(map[string]string{"manifest": latestRelease.Manifest}, nil, releaseutil.InstallOrder)
   396  
   397  	if err != nil {
   398  		return fmt.Errorf("error re-rendering helm output: %w", err)
   399  	}
   400  
   401  	modifiedManifest := ""
   402  	modified := false
   403  
   404  	// Loop over the resources from the lastRelease manifest to check for deprecations
   405  	for _, resource := range resources {
   406  		// parse to unstructured to have access to more data than just the name
   407  		rawData := &unstructured.Unstructured{}
   408  		if err := yaml.Unmarshal([]byte(resource.Content), rawData); err != nil {
   409  			return fmt.Errorf("failed to unmarshal manifest: %#v", err)
   410  		}
   411  
   412  		rawData, manifestModified, _ := h.cluster.HandleDeprecations(rawData, *kubeGitVersion)
   413  		manifestContent, err := yaml.Marshal(rawData)
   414  		if err != nil {
   415  			return fmt.Errorf("failed to marshal raw manifest after deprecation check: %#v", err)
   416  		}
   417  
   418  		// If this is not a bad object, place it back into the manifest
   419  		modifiedManifest += fmt.Sprintf("---\n# Source: %s\n%s\n", resource.Name, manifestContent)
   420  
   421  		if manifestModified {
   422  			modified = true
   423  		}
   424  	}
   425  
   426  	// If the release was modified in the above loop, save it back to the cluster
   427  	if modified {
   428  		message.Warnf("Jackal detected deprecated APIs for the '%s' helm release.  Attempting automatic upgrade.", latestRelease.Name)
   429  
   430  		// Update current release version to be superseded (same as the helm mapkubeapis plugin)
   431  		latestRelease.Info.Status = release.StatusSuperseded
   432  		if err := h.actionConfig.Releases.Update(latestRelease); err != nil {
   433  			return err
   434  		}
   435  
   436  		// Use a shallow copy of current release version to update the object with the modification
   437  		// and then store this new version (same as the helm mapkubeapis plugin)
   438  		var newRelease = latestRelease
   439  		newRelease.Manifest = modifiedManifest
   440  		newRelease.Info.Description = "Kubernetes deprecated API upgrade - DO NOT rollback from this version"
   441  		newRelease.Info.LastDeployed = h.actionConfig.Now()
   442  		newRelease.Version = latestRelease.Version + 1
   443  		newRelease.Info.Status = release.StatusDeployed
   444  		if err := h.actionConfig.Releases.Create(newRelease); err != nil {
   445  			return err
   446  		}
   447  	}
   448  
   449  	return nil
   450  }