Repository: zarf-dev/zarf (Go)

Issue:
Zarf package remove fails to remove unsuccessful helm releases
### Environment
Device and OS: macOS
App version: 0.60.0
Kubernetes distro being used: k3s in k3d, k8s 1.31.4
Other:

### Steps to reproduce
1. `zarf package deploy <package>` on package containing helm release that create resources which will fail to become healthy in the cluster
2. `zarf package remove <package> --confirm`

### Expected result
Helm releases are removed 

### Actual Result
Helm releases are not removed

### Visual Proof (screenshots, videos, text, etc)

### Severity/Priority
Medium(?)

### Additional Context




Discussion: Documenting for posterity:

zarf [returns](https://github.com/zarf-dev/zarf/blob/main/src/pkg/packager/deploy.go#L515) `nil` when the chart fails to install 

This cascades to the component , zarf [return](https://github.com/zarf-dev/zarf/blob/main/src/pkg/packager/deploy.go#L425)  `nil`  for the `[]InstalledChart`. 

this results in a [deployErr](https://github.com/zarf-dev/zarf/blob/main/src/pkg/packager/deploy.go#L212) where zarf records the status for the component as [failed](https://github.com/zarf-dev/zarf/blob/main/src/pkg/packager/deploy.go#L225) and return nil. 

This means that - when a component chart installation fails - we don't record the release as being installed by the component. At this point the release is orphaned and requires helm intervention directly. Zarf can fix this in a few ways. More digging to follow.




Fix (patch):
diff --git a/src/internal/packager/helm/chart.go b/src/internal/packager/helm/chart.go
index 1b2f3be771..734718934e 100644
--- a/src/internal/packager/helm/chart.go
+++ b/src/internal/packager/helm/chart.go
@@ -77,12 +77,12 @@ func InstallOrUpgradeChart(ctx context.Context, zarfChart v1alpha1.ZarfChart, ch
 	// Setup K8s connection.
 	actionConfig, err := createActionConfig(ctx, zarfChart.Namespace)
 	if err != nil {
-		return nil, "", fmt.Errorf("unable to initialize the K8s client: %w", err)
+		return nil, zarfChart.ReleaseName, fmt.Errorf("unable to initialize the K8s client: %w", err)
 	}
 
 	postRender, err := newRenderer(ctx, zarfChart, opts.AdoptExistingResources, opts.Cluster, opts.AirgapMode, opts.State, actionConfig, opts.VariableConfig)
 	if err != nil {
-		return nil, "", fmt.Errorf("unable to create helm renderer: %w", err)
+		return nil, zarfChart.ReleaseName, fmt.Errorf("unable to create helm renderer: %w", err)
 	}
 
 	histClient := action.NewHistory(actionConfig)
@@ -126,7 +126,7 @@ func InstallOrUpgradeChart(ctx context.Context, zarfChart v1alpha1.ZarfChart, ch
 
 		releases, err := histClient.Run(zarfChart.ReleaseName)
 		if err != nil {
-			return nil, "", errors.Join(err, installErr)
+			return nil, zarfChart.ReleaseName, errors.Join(err, installErr)
 		}
 		previouslyDeployedVersion := 0
 
@@ -139,21 +139,21 @@ func InstallOrUpgradeChart(ctx context.Context, zarfChart v1alpha1.ZarfChart, ch
 
 		// No prior releases means this was an initial install.
 		if previouslyDeployedVersion == 0 {
-			return nil, "", installErr
+			return nil, zarfChart.ReleaseName, installErr
 		}
 
 		// Attempt to rollback on a failed upgrade.
 		l.Info("performing Helm rollback", "chart", zarfChart.Name)
 		err = rollbackChart(zarfChart.ReleaseName, previouslyDeployedVersion, actionConfig, opts.Timeout)
 		if err != nil {
-			return nil, "", fmt.Errorf("%w: unable to rollback: %w", installErr, err)
+			return nil, zarfChart.ReleaseName, fmt.Errorf("%w: unable to rollback: %w", installErr, err)
 		}
-		return nil, "", installErr
+		return nil, zarfChart.ReleaseName, installErr
 	}
 
 	resourceList, err := actionConfig.KubeClient.Build(bytes.NewBufferString(release.Manifest), true)
 	if err != nil {
-		return nil, "", fmt.Errorf("unable to build the resource list: %w", err)
+		return nil, zarfChart.ReleaseName, fmt.Errorf("unable to build the resource list: %w", err)
 	}
 
 	runtimeObjs := []runtime.Object{}
@@ -164,7 +164,7 @@ func InstallOrUpgradeChart(ctx context.Context, zarfChart v1alpha1.ZarfChart, ch
 		// Ensure we don't go past the timeout by using a context initialized with the helm timeout
 		l.Info("running health checks", "chart", zarfChart.Name)
 		if err := healthchecks.WaitForReadyRuntime(helmCtx, opts.Cluster.Watcher, runtimeObjs); err != nil {
-			return nil, "", err
+			return nil, zarfChart.ReleaseName, err
 		}
 	}
 	l.Debug("done processing Helm chart", "name", zarfChart.Name, "duration", time.Since(start))
diff --git a/src/pkg/packager/deploy.go b/src/pkg/packager/deploy.go
index 2e1668c3a1..f5ee96f80d 100644
--- a/src/pkg/packager/deploy.go
+++ b/src/pkg/packager/deploy.go
@@ -221,18 +221,30 @@ func (d *deployer) deployComponents(ctx context.Context, pkgLayout *layout.Packa
 		}
 
 		if deployErr != nil {
-			onFailure()
-			deployedComponents[idx].Status = state.ComponentStatusFailed
-			if d.isConnectedToCluster() {
-				if _, err := d.c.RecordPackageDeployment(ctx, pkgLayout.Pkg, deployedComponents, packageGeneration, state.WithPackageNamespaceOverride(opts.NamespaceOverride)); err != nil {
-					l.Debug("unable to record package deployment", "component", component.Name, "error", err.Error())
+			cleanup := func(ctx context.Context) {
+				onFailure()
+				l.Debug("component deployment failed", "component", component.Name, "error", deployErr.Error())
+				deployedComponents[idx].Status = state.ComponentStatusFailed
+				deployedComponents[idx].InstalledCharts = state.MergeInstalledChartsForComponent(deployedComponents[idx].InstalledCharts, charts, true)
+				if d.isConnectedToCluster() {
+					if _, err := d.c.RecordPackageDeployment(ctx, pkgLayout.Pkg, deployedComponents, packageGeneration, state.WithPackageNamespaceOverride(opts.NamespaceOverride)); err != nil {
+						l.Debug("unable to record package deployment", "component", component.Name, "error", err.Error())
+					}
 				}
 			}
-			return nil, fmt.Errorf("unable to deploy component %q: %w", component.Name, deployErr)
+			select {
+			case <-ctx.Done():
+				// Use background context here in order to ensure the cleanup logic can run when the context is cancelled
+				cleanup(context.Background())
+				return nil, fmt.Errorf("context cancelled while deploying component %q: %w", component.Name, deployErr)
+			default:
+				cleanup(ctx)
+				return nil, fmt.Errorf("unable to deploy component %q: %w", component.Name, deployErr)
+			}
 		}
 
 		// Update the package secret to indicate that we successfully deployed this component
-		deployedComponents[idx].InstalledCharts = charts
+		deployedComponents[idx].InstalledCharts = state.MergeInstalledChartsForComponent(deployedComponents[idx].InstalledCharts, charts, false)
 		deployedComponents[idx].Status = state.ComponentStatusSucceeded
 		if d.isConnectedToCluster() {
 			if _, err := d.c.RecordPackageDeployment(ctx, pkgLayout.Pkg, deployedComponents, packageGeneration, state.WithPackageNamespaceOverride(opts.NamespaceOverride)); err != nil {
@@ -421,22 +433,22 @@ func (d *deployer) deployComponent(ctx context.Context, pkgLayout *layout.Packag
 	charts := []state.InstalledChart{}
 	if hasCharts {
 		helmCharts, err := d.installCharts(ctx, pkgLayout, component, opts)
+		charts = append(charts, helmCharts...)
 		if err != nil {
-			return nil, err
+			return charts, err
 		}
-		charts = append(charts, helmCharts...)
 	}
 
 	if hasManifests {
 		chartsFromManifests, err := d.installManifests(ctx, pkgLayout, component, opts)
+		charts = append(charts, chartsFromManifests...)
 		if err != nil {
-			return nil, err
+			return charts, err
 		}
-		charts = append(charts, chartsFromManifests...)
 	}
 
 	if err := actions.Run(ctx, cwd, onDeploy.Defaults, onDeploy.After, d.vc); err != nil {
-		return nil, fmt.Errorf("unable to run component after action: %w", err)
+		return charts, fmt.Errorf("unable to run component after action: %w", err)
 	}
 
 	if len(component.HealthChecks) > 0 {
@@ -444,12 +456,12 @@ func (d *deployer) deployComponent(ctx context.Context, pkgLayout *layout.Packag
 		defer cancel()
 		l.Info("running health checks")
 		if err := healthchecks.Run(healthCheckContext, d.c.Watcher, component.HealthChecks); err != nil {
-			return nil, fmt.Errorf("health checks failed: %w", err)
+			return charts, fmt.Errorf("health checks failed: %w", err)
 		}
 	}
 
 	if err := g.Wait(); err != nil {
-		return nil, err
+		return charts, err
 	}
 	l.Debug("done deploying component", "name", component.Name, "duration", time.Since(start))
 	return charts, nil
@@ -485,7 +497,7 @@ func (d *deployer) installCharts(ctx context.Context, pkgLayout *layout.PackageL
 		for idx := range chart.ValuesFiles {
 			valueFilePath := helm.StandardValuesName(valuesDir, chart, idx)
 			if err := d.vc.ReplaceTextTemplate(valueFilePath); err != nil {
-				return nil, err
+				return installedCharts, err
 			}
 		}
 
@@ -493,7 +505,7 @@ func (d *deployer) installCharts(ctx context.Context, pkgLayout *layout.PackageL
 		// Values overrides are to be applied in order of Helm Chart Defaults -> Zarf `valuesFiles` -> Zarf `variables` -> DeployOpts overrides
 		valuesOverrides, err := generateValuesOverrides(chart, component.Name, d.vc, opts.ValuesOverridesMap)
 		if err != nil {
-			return nil, err
+			return installedCharts, err
 		}
 
 		helmOpts := helm.InstallUpgradeOptions{
@@ -507,14 +519,15 @@ func (d *deployer) installCharts(ctx context.Context, pkgLayout *layout.PackageL
 		}
 		helmChart, values, err := helm.LoadChartData(chart, chartDir, valuesDir, valuesOverrides)
 		if err != nil {
-			return nil, fmt.Errorf("failed to load chart data: %w", err)
+			return installedCharts, fmt.Errorf("failed to load chart data: %w", err)
 		}
 
 		connectStrings, installedChartName, err := helm.InstallOrUpgradeChart(ctx, chart, helmChart, values, helmOpts)
 		if err != nil {
-			return nil, err
+			installedCharts = append(installedCharts, state.InstalledChart{Namespace: chart.Namespace, ChartName: installedChartName, ConnectStrings: connectStrings, Status: state.ChartStatusFailed})
+			return installedCharts, err
 		}
-		installedCharts = append(installedCharts, state.InstalledChart{Namespace: chart.Namespace, ChartName: installedChartName, ConnectStrings: connectStrings})
+		installedCharts = append(installedCharts, state.InstalledChart{Namespace: chart.Namespace, ChartName: installedChartName, ConnectStrings: connectStrings, Status: state.ChartStatusSucceeded})
 	}
 
 	return installedCharts, nil
@@ -540,7 +553,7 @@ func (d *deployer) installManifests(ctx context.Context, pkgLayout *layout.Packa
 				// The path is likely invalid because of how we compose OCI components, add an index suffix to the filename
 				manifest.Files[idx] = fmt.Sprintf("%s-%d.yaml", manifest.Name, idx)
 				if helpers.InvalidPath(filepath.Join(manifestDir, manifest.Files[idx])) {
-					return nil, fmt.Errorf("unable to find manifest file %s", manifest.Files[idx])
+					return installedCharts, fmt.Errorf("unable to find manifest file %s", manifest.Files[idx])
 				}
 			}
 		}
@@ -558,7 +571,7 @@ func (d *deployer) installManifests(ctx context.Context, pkgLayout *layout.Packa
 		// Create a helmChart and helm cfg from a given Zarf Manifest.
 		chart, helmChart, err := helm.ChartFromZarfManifest(manifest, manifestDir, pkgLayout.Pkg.Metadata.Name, component.Name)
 		if err != nil {
-			return nil, err
+			return installedCharts, err
 		}
 		helmOpts := helm.InstallUpgradeOptions{
 			AdoptExistingResources: opts.AdoptExistingResources,
@@ -573,9 +586,10 @@ func (d *deployer) installManifests(ctx context.Context, pkgLayout *layout.Packa
 		// Install the chart.
 		connectStrings, installedChartName, err := helm.InstallOrUpgradeChart(ctx, chart, helmChart, nil, helmOpts)
 		if err != nil {
-			return nil, err
+			installedCharts = append(installedCharts, state.InstalledChart{Namespace: manifest.Namespace, ChartName: installedChartName, ConnectStrings: connectStrings, Status: state.ChartStatusFailed})
+			return installedCharts, err
 		}
-		installedCharts = append(installedCharts, state.InstalledChart{Namespace: manifest.Namespace, ChartName: installedChartName, ConnectStrings: connectStrings})
+		installedCharts = append(installedCharts, state.InstalledChart{Namespace: manifest.Namespace, ChartName: installedChartName, ConnectStrings: connectStrings, Status: state.ChartStatusSucceeded})
 	}
 
 	return installedCharts, nil
diff --git a/src/pkg/state/state.go b/src/pkg/state/state.go
index 0de32846d3..f9a9f9eb5e 100644
--- a/src/pkg/state/state.go
+++ b/src/pkg/state/state.go
@@ -49,6 +49,12 @@ const (
 	ComponentStatusRemoving  ComponentStatus = "Removing"
 )
 
+// All status options for a Zarf component chart
+const (
+	ChartStatusSucceeded ChartStatus = "Succeeded"
+	ChartStatusFailed    ChartStatus = "Failed"
+)
+
 // Values during setup of the initial zarf state
 const (
 	ZarfGeneratedPasswordLen               = 24
@@ -412,9 +418,58 @@ type DeployedComponent struct {
 	ObservedGeneration int              `json:"observedGeneration"`
 }
 
+// ChartStatus is the status of a Helm Chart release
+type ChartStatus string
+
 // InstalledChart contains information about a Helm Chart that has been deployed to a cluster.
 type InstalledChart struct {
 	Namespace      string         `json:"namespace"`
 	ChartName      string         `json:"chartName"`
 	ConnectStrings ConnectStrings `json:"connectStrings,omitempty"`
+	Status         ChartStatus    `json:"status"`
+}
+
+// MergeInstalledChartsForComponent merges the provided existing charts with the provided installed charts.
+func MergeInstalledChartsForComponent(existingCharts, installedCharts []InstalledChart, partial bool) []InstalledChart {
+	key := func(chart InstalledChart) string {
+		return fmt.Sprintf("%s/%s", chart.Namespace, chart.ChartName)
+	}
+
+	lookup := make(map[string]InstalledChart, 0)
+	for _, chart := range existingCharts {
+		lookup[key(chart)] = chart
+	}
+
+	// Track which keys are still present in newCharts
+	seen := make(map[string]struct{}, len(installedCharts)+len(existingCharts))
+
+	for _, chart := range installedCharts {
+		k := key(chart)
+		seen[k] = struct{}{}
+
+		if _, ok := lookup[k]; ok {
+			existingChart := lookup[k]
+			existingChart.ConnectStrings = chart.ConnectStrings
+			existingChart.Status = chart.Status
+			lookup[k] = existingChart
+		} else {
+			lookup[k] = chart
+		}
+	}
+
+	// retain existing charts that are no longer present if not a partial
+	if !partial {
+		for k, chart := range lookup {
+			if _, ok := seen[k]; !ok {
+				lookup[k] = chart
+			}
+		}
+	}
+
+	merged := make([]InstalledChart, 0, len(lookup))
+	for _, chart := range lookup {
+		merged = append(merged, chart)
+	}
+
+	return merged
 }