diff --git a/src/cmd/package.go b/src/cmd/package.go index d168ed80dd..566e4487b6 100644 --- a/src/cmd/package.go +++ b/src/cmd/package.go @@ -270,19 +270,17 @@ var packageRemoveCmd = &cobra.Command{ if err != nil { return err } - pkgConfig.PkgOpts.PackageSource = packageSource - src, err := identifyAndFallbackToClusterSource() - if err != nil { - return err + cluster, _ := cluster.NewCluster() + removeOpt := packager2.RemoveOptions{ + Source: packageSource, + Cluster: cluster, + OptionalComponents: pkgConfig.PkgOpts.OptionalComponents, + SkipSignatureValidation: pkgConfig.PkgOpts.SkipSignatureValidation, } - pkgClient, err := packager.New(&pkgConfig, packager.WithSource(src)) + err = packager2.Remove(cmd.Context(), removeOpt) if err != nil { return err } - defer pkgClient.ClearTempPaths() - if err := pkgClient.Remove(cmd.Context()); err != nil { - return fmt.Errorf("unable to remove the package with an error of: %w", err) - } return nil }, ValidArgsFunction: getPackageCompletionArgs, @@ -382,7 +380,9 @@ func choosePackage(args []string) (string, error) { return path, nil } -// TODO: This code does not seem to do what it was intended. +// NOTE: If the source is identified nil is returned because packager will create the source if it is nil. +// If it can't be identified the cluster source is used causing packager to ignore the configured package source. +// Use of cluster package source is limited to a few functions which is why this is not the default behavior. func identifyAndFallbackToClusterSource() (sources.PackageSource, error) { identifiedSrc := sources.Identify(pkgConfig.PkgOpts.PackageSource) if identifiedSrc == "" { diff --git a/src/internal/packager2/load.go b/src/internal/packager2/load.go index b20eea6195..49dc7dea5b 100644 --- a/src/internal/packager2/load.go +++ b/src/internal/packager2/load.go @@ -19,7 +19,9 @@ import ( "github.com/defenseunicorns/pkg/helpers/v2" "github.com/mholt/archiver/v3" + "github.com/zarf-dev/zarf/src/api/v1alpha1" "github.com/zarf-dev/zarf/src/config" + "github.com/zarf-dev/zarf/src/pkg/cluster" "github.com/zarf-dev/zarf/src/pkg/layout" "github.com/zarf-dev/zarf/src/pkg/packager/filters" "github.com/zarf-dev/zarf/src/pkg/packager/sources" @@ -162,6 +164,7 @@ func LoadPackage(ctx context.Context, opt LoadOptions) (*layout.PackagePaths, er return pkgPaths, nil } +// identifySource returns the source type for the given source. func identifySource(src string) (string, error) { parsed, err := url.Parse(src) if err == nil && parsed.Scheme != "" && parsed.Host != "" { @@ -223,3 +226,33 @@ func assembleSplitTar(src, tarPath string) error { } return nil } + +func packageFromSourceOrCluster(ctx context.Context, cluster *cluster.Cluster, src string, skipSignatureValidation bool) (v1alpha1.ZarfPackage, error) { + _, err := identifySource(src) + if err != nil { + if cluster == nil { + return v1alpha1.ZarfPackage{}, fmt.Errorf("cannot get Zarf package from Kubernetes without configuration") + } + depPkg, err := cluster.GetDeployedPackage(ctx, src) + if err != nil { + return v1alpha1.ZarfPackage{}, err + } + return depPkg.Data, nil + } + + loadOpt := LoadOptions{ + Source: src, + SkipSignatureValidation: skipSignatureValidation, + Filter: filters.Empty(), + } + pkgPaths, err := LoadPackage(ctx, loadOpt) + if err != nil { + return v1alpha1.ZarfPackage{}, err + } + defer os.RemoveAll(pkgPaths.Base) + pkg, _, err := pkgPaths.ReadZarfYAML() + if err != nil { + return v1alpha1.ZarfPackage{}, err + } + return pkg, nil +} diff --git a/src/internal/packager2/load_test.go b/src/internal/packager2/load_test.go index b9b6cf37c2..208f68ba65 100644 --- a/src/internal/packager2/load_test.go +++ b/src/internal/packager2/load_test.go @@ -8,7 +8,9 @@ import ( "testing" "github.com/stretchr/testify/require" + "k8s.io/client-go/kubernetes/fake" + "github.com/zarf-dev/zarf/src/pkg/cluster" "github.com/zarf-dev/zarf/src/pkg/packager/filters" "github.com/zarf-dev/zarf/src/test/testutil" ) @@ -134,3 +136,25 @@ func TestIdentifySource(t *testing.T) { }) } } + +func TestPackageFromSourceOrCluster(t *testing.T) { + t.Parallel() + + ctx := testutil.TestContext(t) + + _, err := packageFromSourceOrCluster(ctx, nil, "test", false) + require.EqualError(t, err, "cannot get Zarf package from Kubernetes without configuration") + + pkg, err := packageFromSourceOrCluster(ctx, nil, "./testdata/zarf-package-test-amd64-0.0.1.tar.zst", false) + require.NoError(t, err) + require.Equal(t, "test", pkg.Metadata.Name) + + c := &cluster.Cluster{ + Clientset: fake.NewSimpleClientset(), + } + _, err = c.RecordPackageDeployment(ctx, pkg, nil, 1) + require.NoError(t, err) + pkg, err = packageFromSourceOrCluster(ctx, c, "test", false) + require.NoError(t, err) + require.Equal(t, "test", pkg.Metadata.Name) +} diff --git a/src/internal/packager2/remove.go b/src/internal/packager2/remove.go new file mode 100644 index 0000000000..eebde990a1 --- /dev/null +++ b/src/internal/packager2/remove.go @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +package packager2 + +import ( + "context" + "errors" + "fmt" + "runtime" + "slices" + + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/storage/driver" + + "github.com/zarf-dev/zarf/src/api/v1alpha1" + "github.com/zarf-dev/zarf/src/config" + "github.com/zarf-dev/zarf/src/pkg/cluster" + "github.com/zarf-dev/zarf/src/pkg/message" + "github.com/zarf-dev/zarf/src/pkg/packager/actions" + "github.com/zarf-dev/zarf/src/pkg/packager/filters" + "github.com/zarf-dev/zarf/src/types" +) + +// RemoveOptions are the options for Remove. +type RemoveOptions struct { + Source string + Cluster *cluster.Cluster + OptionalComponents string + SkipSignatureValidation bool +} + +// Remove removes a package that was already deployed onto a cluster, uninstalling all installed helm charts. +func Remove(ctx context.Context, opt RemoveOptions) error { + pkg, err := packageFromSourceOrCluster(ctx, opt.Cluster, opt.Source, opt.SkipSignatureValidation) + if err != nil { + return nil + } + + // If components were provided; just remove the things we were asked to remove + filter := filters.Combine( + filters.ByLocalOS(runtime.GOOS), + filters.BySelectState(opt.OptionalComponents), + ) + components, err := filter.Apply(pkg) + if err != nil { + return err + } + // Check that cluster is configured if required. + requiresCluster := false + componentIdx := map[string]v1alpha1.ZarfComponent{} + for _, component := range components { + componentIdx[component.Name] = component + if component.RequiresCluster() { + if opt.Cluster == nil { + return fmt.Errorf("component %s requires cluster access but none was configured", component.Name) + } + requiresCluster = true + } + } + + // Get or build the secret for the deployed package + depPkg := &types.DeployedPackage{} + if requiresCluster { + depPkg, err = opt.Cluster.GetDeployedPackage(ctx, pkg.Metadata.Name) + if err != nil { + return fmt.Errorf("unable to load the secret for the package we are attempting to remove: %s", err.Error()) + } + } else { + // If we do not need the cluster, create a deployed components object based on the info we have + depPkg.Name = pkg.Metadata.Name + depPkg.Data = pkg + for _, component := range components { + depPkg.DeployedComponents = append(depPkg.DeployedComponents, types.DeployedComponent{Name: component.Name}) + } + } + + reverseDepComps := depPkg.DeployedComponents + slices.Reverse(reverseDepComps) + for i, depComp := range reverseDepComps { + // Only remove the component if it was requested or if we are removing the whole package. + comp, ok := componentIdx[depComp.Name] + if !ok { + continue + } + + err := func() error { + err := actions.Run(ctx, comp.Actions.OnRemove.Defaults, comp.Actions.OnRemove.Before, nil) + if err != nil { + return fmt.Errorf("unable to run the before action: %w", err) + } + + reverseInstalledCharts := depComp.InstalledCharts + slices.Reverse(reverseInstalledCharts) + if opt.Cluster != nil { + for _, chart := range reverseInstalledCharts { + settings := cli.New() + actionConfig := &action.Configuration{} + // TODO (phillebaba): Get credentials from cluster instead of reading again. + err := actionConfig.Init(settings.RESTClientGetter(), chart.Namespace, "", nil) + if err != nil { + return err + } + client := action.NewUninstall(actionConfig) + client.KeepHistory = false + client.Wait = true + client.Timeout = config.ZarfDefaultTimeout + _, err = client.Run(chart.ChartName) + if err != nil && !errors.Is(err, driver.ErrReleaseNotFound) { + return fmt.Errorf("unable to uninstall the helm chart %s in the namespace %s: %w", chart.ChartName, chart.Namespace, err) + } + if errors.Is(err, driver.ErrReleaseNotFound) { + message.Warnf("Helm release for helm chart '%s' in the namespace '%s' was not found. Was it already removed?", chart.ChartName, chart.Namespace) + } + + // Pop the removed helm chart from the installed charts slice. + depPkg.DeployedComponents[len(depPkg.DeployedComponents)-i].InstalledCharts = depComp.InstalledCharts[:len(depComp.InstalledCharts)-1] + err = opt.Cluster.UpdateDeployedPackage(ctx, *depPkg) + if err != nil { + // We warn and ignore errors because we may have removed the cluster that this package was inside of + message.Warnf("Unable to update the secret for package %s, this may be normal if the cluster was removed: %s", depPkg.Name, err.Error()) + } + } + } + + err = actions.Run(ctx, comp.Actions.OnRemove.Defaults, comp.Actions.OnRemove.After, nil) + if err != nil { + return fmt.Errorf("unable to run the after action: %w", err) + } + err = actions.Run(ctx, comp.Actions.OnRemove.Defaults, comp.Actions.OnRemove.OnSuccess, nil) + if err != nil { + return fmt.Errorf("unable to run the success action: %w", err) + } + + // Pop the removed component from deploy components slice. + if opt.Cluster != nil { + depPkg.DeployedComponents = depPkg.DeployedComponents[:len(depPkg.DeployedComponents)-1] + err = opt.Cluster.UpdateDeployedPackage(ctx, *depPkg) + if err != nil { + // We warn and ignore errors because we may have removed the cluster that this package was inside of + message.Warnf("Unable to update the secret for package %s, this may be normal if the cluster was removed: %s", depPkg.Name, err.Error()) + } + } + return nil + }() + if err != nil { + err := actions.Run(ctx, comp.Actions.OnRemove.Defaults, comp.Actions.OnRemove.OnFailure, nil) + if err != nil { + return fmt.Errorf("unable to run the failure action: %w", err) + } + return nil + } + } + + // All the installed components were deleted, therefore this package is no longer actually deployed + if opt.Cluster != nil && len(depPkg.DeployedComponents) == 0 { + err := opt.Cluster.DeleteDeployedPackage(ctx, depPkg.Name) + if err != nil { + message.Warnf("Unable to delete the secret for package %s, tis may be normal if the cluster was removed: %s", depPkg.Name, err.Error()) + } + } + + return nil +} diff --git a/src/pkg/cluster/zarf.go b/src/pkg/cluster/zarf.go index b38b55d783..860db12a3e 100644 --- a/src/pkg/cluster/zarf.go +++ b/src/pkg/cluster/zarf.go @@ -74,6 +74,65 @@ func (c *Cluster) GetDeployedPackage(ctx context.Context, packageName string) (* return deployedPackage, nil } +// UpdateDeployedPackage updates the deployed package metadata. +func (c *Cluster) UpdateDeployedPackage(ctx context.Context, depPkg types.DeployedPackage) error { + secretName := config.ZarfPackagePrefix + depPkg.Name + packageSecretData, err := json.Marshal(depPkg) + if err != nil { + return err + } + packageSecret := &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + APIVersion: corev1.SchemeGroupVersion.String(), + Kind: "Secret", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: ZarfNamespaceName, + Labels: map[string]string{ + ZarfManagedByLabel: "zarf", + ZarfPackageInfoLabel: depPkg.Name, + }, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "data": packageSecretData, + }, + } + err = func() error { + _, err := c.Clientset.CoreV1().Secrets(packageSecret.Namespace).Get(ctx, packageSecret.Name, metav1.GetOptions{}) + if err != nil && !kerrors.IsNotFound(err) { + return err + } + if kerrors.IsNotFound(err) { + _, err = c.Clientset.CoreV1().Secrets(packageSecret.Namespace).Create(ctx, packageSecret, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("unable to create the zarf state secret: %w", err) + } + return nil + } + _, err = c.Clientset.CoreV1().Secrets(packageSecret.Namespace).Update(ctx, packageSecret, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("unable to update the zarf state secret: %w", err) + } + return nil + }() + if err != nil { + return err + } + return nil +} + +// DeleteDeployedPackage removes the metadata for the deployed package. +func (c *Cluster) DeleteDeployedPackage(ctx context.Context, packageName string) error { + secretName := config.ZarfPackagePrefix + packageName + err := c.Clientset.CoreV1().Secrets(ZarfNamespaceName).Delete(ctx, secretName, metav1.DeleteOptions{}) + if err != nil { + return err + } + return nil +} + // StripZarfLabelsAndSecretsFromNamespaces removes metadata and secrets from existing namespaces no longer manged by Zarf. func (c *Cluster) StripZarfLabelsAndSecretsFromNamespaces(ctx context.Context) { spinner := message.NewProgressSpinner("Removing zarf metadata & secrets from existing namespaces not managed by Zarf")