From b5cf624a596903f3b45829c01e318a4046ba3b9e Mon Sep 17 00:00:00 2001 From: Wayne Starr Date: Fri, 11 Aug 2023 16:43:50 -0500 Subject: [PATCH] Introduce `zarf tools registry prune` (#1966) ## Description This introduces a new Zarf tools command to prune images in a registry that do not relate to any currently deployed Zarf components. ## Related Issue Fixes #1960 Fixes #1945 ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) - [X] New feature (non-breaking change which adds functionality) - [ ] Other (security config, docs update, etc) ## Checklist before merging - [x] Test, docs, adr added or updated as needed - [X] [Contributor Guide Steps](https://github.com/defenseunicorns/zarf/blob/main/CONTRIBUTING.md#developer-workflow) followed --- .../100-cli-commands/zarf_tools_registry.md | 3 + .../zarf_tools_registry_delete.md | 39 +++++ .../zarf_tools_registry_digest.md | 41 +++++ .../zarf_tools_registry_prune.md | 28 ++++ packages/zarf-registry/chart/values.yaml | 1 + src/cmd/tools/crane.go | 150 +++++++++++++++++- src/config/lang/english.go | 33 +++- src/internal/cluster/zarf.go | 20 +-- src/pkg/k8s/secrets.go | 9 +- src/pkg/packager/deploy.go | 2 +- src/test/e2e/21_connect_test.go | 17 ++ 11 files changed, 327 insertions(+), 16 deletions(-) create mode 100644 docs/2-the-zarf-cli/100-cli-commands/zarf_tools_registry_delete.md create mode 100644 docs/2-the-zarf-cli/100-cli-commands/zarf_tools_registry_digest.md create mode 100644 docs/2-the-zarf-cli/100-cli-commands/zarf_tools_registry_prune.md diff --git a/docs/2-the-zarf-cli/100-cli-commands/zarf_tools_registry.md b/docs/2-the-zarf-cli/100-cli-commands/zarf_tools_registry.md index 9a8752b98c..9233af1a26 100644 --- a/docs/2-the-zarf-cli/100-cli-commands/zarf_tools_registry.md +++ b/docs/2-the-zarf-cli/100-cli-commands/zarf_tools_registry.md @@ -18,7 +18,10 @@ Tools for working with container registries using go-containertools * [zarf tools](zarf_tools.md) - Collection of additional tools to make airgap easier * [zarf tools registry catalog](zarf_tools_registry_catalog.md) - List the repos in a registry * [zarf tools registry copy](zarf_tools_registry_copy.md) - Efficiently copy a remote image from src to dst while retaining the digest value +* [zarf tools registry delete](zarf_tools_registry_delete.md) - Delete an image reference from its registry +* [zarf tools registry digest](zarf_tools_registry_digest.md) - Get the digest of an image * [zarf tools registry login](zarf_tools_registry_login.md) - Log in to a registry * [zarf tools registry ls](zarf_tools_registry_ls.md) - List the tags in a repo +* [zarf tools registry prune](zarf_tools_registry_prune.md) - Prunes images from the registry that are not currently being used by any Zarf packages. * [zarf tools registry pull](zarf_tools_registry_pull.md) - Pull remote images by reference and store their contents locally * [zarf tools registry push](zarf_tools_registry_push.md) - Push local image contents to a remote registry diff --git a/docs/2-the-zarf-cli/100-cli-commands/zarf_tools_registry_delete.md b/docs/2-the-zarf-cli/100-cli-commands/zarf_tools_registry_delete.md new file mode 100644 index 0000000000..8dd6179a01 --- /dev/null +++ b/docs/2-the-zarf-cli/100-cli-commands/zarf_tools_registry_delete.md @@ -0,0 +1,39 @@ +# zarf tools registry delete + + +Delete an image reference from its registry + +``` +zarf tools registry delete IMAGE [flags] +``` + +## Examples + +``` + +# delete an image digest from an internal repo in Zarf +$ zarf tools registry delete 127.0.0.1:31999/stefanprodan/podinfo@sha256:57a654ace69ec02ba8973093b6a786faa15640575fbf0dbb603db55aca2ccec8 + +# delete an image digest from a repo hosted at reg.example.com +$ zarf tools registry delete reg.example.com/stefanprodan/podinfo@sha256:57a654ace69ec02ba8973093b6a786faa15640575fbf0dbb603db55aca2ccec8 + +``` + +## Options + +``` + -h, --help help for delete +``` + +## Options inherited from parent commands + +``` + --allow-nondistributable-artifacts Allow pushing non-distributable (foreign) layers + --insecure Allow image references to be fetched without TLS + --platform string Specifies the platform in the form os/arch[/variant][:osversion] (e.g. linux/amd64). (default "all") + -v, --verbose Enable debug logs +``` + +## SEE ALSO + +* [zarf tools registry](zarf_tools_registry.md) - Tools for working with container registries using go-containertools diff --git a/docs/2-the-zarf-cli/100-cli-commands/zarf_tools_registry_digest.md b/docs/2-the-zarf-cli/100-cli-commands/zarf_tools_registry_digest.md new file mode 100644 index 0000000000..f2e82135ef --- /dev/null +++ b/docs/2-the-zarf-cli/100-cli-commands/zarf_tools_registry_digest.md @@ -0,0 +1,41 @@ +# zarf tools registry digest + + +Get the digest of an image + +``` +zarf tools registry digest IMAGE [flags] +``` + +## Examples + +``` + +# return an image digest for an internal repo in Zarf +$ zarf tools registry digest 127.0.0.1:31999/stefanprodan/podinfo:6.4.0 + +# return an image digest from a repo hosted at reg.example.com +$ zarf tools registry digest reg.example.com/stefanprodan/podinfo:6.4.0 + +``` + +## Options + +``` + --full-ref (Optional) if true, print the full image reference by digest + -h, --help help for digest + --tarball string (Optional) path to tarball containing the image +``` + +## Options inherited from parent commands + +``` + --allow-nondistributable-artifacts Allow pushing non-distributable (foreign) layers + --insecure Allow image references to be fetched without TLS + --platform string Specifies the platform in the form os/arch[/variant][:osversion] (e.g. linux/amd64). (default "all") + -v, --verbose Enable debug logs +``` + +## SEE ALSO + +* [zarf tools registry](zarf_tools_registry.md) - Tools for working with container registries using go-containertools diff --git a/docs/2-the-zarf-cli/100-cli-commands/zarf_tools_registry_prune.md b/docs/2-the-zarf-cli/100-cli-commands/zarf_tools_registry_prune.md new file mode 100644 index 0000000000..695e5a7e25 --- /dev/null +++ b/docs/2-the-zarf-cli/100-cli-commands/zarf_tools_registry_prune.md @@ -0,0 +1,28 @@ +# zarf tools registry prune + + +Prunes images from the registry that are not currently being used by any Zarf packages. + +``` +zarf tools registry prune [flags] +``` + +## Options + +``` + --confirm Confirm the image prune action to prevent accidental deletions + -h, --help help for prune +``` + +## Options inherited from parent commands + +``` + --allow-nondistributable-artifacts Allow pushing non-distributable (foreign) layers + --insecure Allow image references to be fetched without TLS + --platform string Specifies the platform in the form os/arch[/variant][:osversion] (e.g. linux/amd64). (default "all") + -v, --verbose Enable debug logs +``` + +## SEE ALSO + +* [zarf tools registry](zarf_tools_registry.md) - Tools for working with container registries using go-containertools diff --git a/packages/zarf-registry/chart/values.yaml b/packages/zarf-registry/chart/values.yaml index f66786e59b..24e402e59e 100644 --- a/packages/zarf-registry/chart/values.yaml +++ b/packages/zarf-registry/chart/values.yaml @@ -17,6 +17,7 @@ persistence: accessMode: "ReadWriteOnce" enabled: true size: 20Gi + deleteEnabled: true secrets: htpasswd: "" diff --git a/src/cmd/tools/crane.go b/src/cmd/tools/crane.go index d7461eef7f..779b3e5655 100644 --- a/src/cmd/tools/crane.go +++ b/src/cmd/tools/crane.go @@ -9,10 +9,12 @@ import ( "os" "strings" + "github.com/AlecAivazis/survey/v2" "github.com/defenseunicorns/zarf/src/config" "github.com/defenseunicorns/zarf/src/config/lang" "github.com/defenseunicorns/zarf/src/internal/cluster" "github.com/defenseunicorns/zarf/src/pkg/message" + "github.com/defenseunicorns/zarf/src/pkg/transform" "github.com/defenseunicorns/zarf/src/pkg/utils/exec" craneCmd "github.com/google/go-containerregistry/cmd/crane/cmd" "github.com/google/go-containerregistry/pkg/crane" @@ -64,6 +66,16 @@ func init() { }, } + pruneCmd := &cobra.Command{ + Use: "prune", + Aliases: []string{"p"}, + Short: lang.CmdToolsRegistryPruneShort, + RunE: pruneImages, + } + + // Always require confirm flag (no viper) + pruneCmd.Flags().BoolVar(&config.CommonOptions.Confirm, "confirm", false, lang.CmdToolsRegistryPruneFlagConfirm) + craneLogin := craneCmd.NewCmdAuthLogin() craneLogin.Example = "" @@ -76,6 +88,9 @@ func init() { registryCmd.AddCommand(zarfCraneInternalWrapper(craneCmd.NewCmdList, &craneOptions, lang.CmdToolsRegistryListExample, 0)) registryCmd.AddCommand(zarfCraneInternalWrapper(craneCmd.NewCmdPush, &craneOptions, lang.CmdToolsRegistryPushExample, 1)) registryCmd.AddCommand(zarfCraneInternalWrapper(craneCmd.NewCmdPull, &craneOptions, lang.CmdToolsRegistryPullExample, 0)) + registryCmd.AddCommand(zarfCraneInternalWrapper(craneCmd.NewCmdDelete, &craneOptions, lang.CmdToolsRegistryDeleteExample, 0)) + registryCmd.AddCommand(zarfCraneInternalWrapper(craneCmd.NewCmdDigest, &craneOptions, lang.CmdToolsRegistryDigestExample, 0)) + registryCmd.AddCommand(pruneCmd) registryCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, lang.CmdToolsRegistryFlagVerbose) registryCmd.PersistentFlags().BoolVar(&insecure, "insecure", false, lang.CmdToolsRegistryFlagInsecure) @@ -175,7 +190,7 @@ func zarfCraneInternalWrapper(commandToWrap func(*[]crane.Option) *cobra.Command } // Add the correct authentication to the crane command options - authOption := config.GetCraneAuthOption(zarfState.RegistryInfo.PullUsername, zarfState.RegistryInfo.PullPassword) + authOption := config.GetCraneAuthOption(zarfState.RegistryInfo.PushUsername, zarfState.RegistryInfo.PushPassword) *cranePlatformOptions = append(*cranePlatformOptions, authOption) return originalListFn(cmd, args) @@ -183,3 +198,136 @@ func zarfCraneInternalWrapper(commandToWrap func(*[]crane.Option) *cobra.Command return wrappedCommand } + +func pruneImages(_ *cobra.Command, _ []string) error { + // Try to connect to a Zarf initialized cluster + zarfCluster, err := cluster.NewCluster() + if err != nil { + return err + } + + // Load the state + zarfState, err := zarfCluster.LoadZarfState() + if err != nil { + return err + } + + // Load the currently deployed packages + zarfPackages, errs := zarfCluster.GetDeployedZarfPackages() + if len(errs) > 0 { + return lang.ErrUnableToGetPackages + } + + // Set up a tunnel to the registry if applicable + registryAddress := zarfState.RegistryInfo.Address + if zarfState.RegistryInfo.InternalRegistry { + // Open a tunnel to the Zarf registry + tunnelReg, err := cluster.NewZarfTunnel() + if err != nil { + return err + } + err = tunnelReg.Connect(cluster.ZarfRegistry, false) + if err != nil { + return err + } + registryAddress = tunnelReg.Endpoint() + } + + authOption := config.GetCraneAuthOption(zarfState.RegistryInfo.PushUsername, zarfState.RegistryInfo.PushPassword) + + // Determine which image digests are currently used by Zarf packages + pkgImages := map[string]bool{} + for _, pkg := range zarfPackages { + deployedComponents := map[string]bool{} + for _, depComponent := range pkg.DeployedComponents { + deployedComponents[depComponent.Name] = true + } + + for _, component := range pkg.Data.Components { + if _, ok := deployedComponents[component.Name]; ok { + for _, image := range component.Images { + // We use the no checksum image since it will always exist and will share the same digest with other tags + transformedImageNoCheck, err := transform.ImageTransformHostWithoutChecksum(registryAddress, image) + if err != nil { + return err + } + + digest, err := crane.Digest(transformedImageNoCheck, authOption) + if err != nil { + return err + } + pkgImages[digest] = true + } + } + } + } + + // Find which images and tags are in the registry currently + imageCatalog, err := crane.Catalog(registryAddress, authOption) + if err != nil { + return err + } + imageRefToDigest := map[string]string{} + for _, image := range imageCatalog { + imageRef := fmt.Sprintf("%s/%s", registryAddress, image) + tags, err := crane.ListTags(imageRef, authOption) + if err != nil { + return err + } + for _, tag := range tags { + taggedImageRef := fmt.Sprintf("%s:%s", imageRef, tag) + digest, err := crane.Digest(taggedImageRef, authOption) + if err != nil { + return err + } + imageRefToDigest[taggedImageRef] = digest + } + } + + // Figure out which images are in the registry but not needed by packages + imageDigestsToPrune := map[string]bool{} + for imageRef, digest := range imageRefToDigest { + if _, ok := pkgImages[digest]; !ok { + ref, err := transform.ParseImageRef(imageRef) + if err != nil { + return err + } + imageRef = fmt.Sprintf("%s@%s", ref.Name, digest) + imageDigestsToPrune[imageRef] = true + } + } + + if len(imageDigestsToPrune) > 0 { + message.Note(lang.CmdToolsRegistryPruneImageList) + + for imageRef := range imageDigestsToPrune { + message.Info(imageRef) + } + + confirm := config.CommonOptions.Confirm + + if confirm { + message.Note(lang.CmdConfirmProvided) + } else { + prompt := &survey.Confirm{ + Message: lang.CmdConfirmContinue, + } + if err := survey.AskOne(prompt, &confirm); err != nil { + message.Fatalf(nil, lang.ErrConfirmCancel, err) + } + } + if confirm { + // Delete the image references that are to be pruned + for imageRef := range imageDigestsToPrune { + err = crane.Delete(imageRef, authOption) + if err != nil { + return err + } + } + } + } else { + message.Note(lang.CmdToolsRegistryPruneNoImages) + } + + return nil +} diff --git a/src/config/lang/english.go b/src/config/lang/english.go index 772f9daaad..88c48337d6 100644 --- a/src/config/lang/english.go +++ b/src/config/lang/english.go @@ -27,10 +27,15 @@ const ( ErrCreatingDir = "failed to create directory %s: %s" ErrRemoveFile = "failed to remove file %s: %s" ErrUnarchive = "failed to unarchive %s: %s" + ErrConfirmCancel = "confirm selection canceled: %s" ) // Zarf CLI commands. const ( + // common command language + CmdConfirmProvided = "Confirm flag specified, continuing without prompting." + CmdConfirmContinue = "Continue with these changes?" + // root zarf command RootCmdShort = "DevSecOps for Airgap" RootCmdLong = "Zarf eliminates the complexity of air gap software delivery for Kubernetes clusters and cloud native workloads\n" + @@ -358,6 +363,27 @@ const ( $ zarf tools registry pull reg.example.com/stefanprodan/podinfo:6.4.0 image.tar ` + CmdToolsRegistryDeleteExample = ` +# delete an image digest from an internal repo in Zarf +$ zarf tools registry delete 127.0.0.1:31999/stefanprodan/podinfo@sha256:57a654ace69ec02ba8973093b6a786faa15640575fbf0dbb603db55aca2ccec8 + +# delete an image digest from a repo hosted at reg.example.com +$ zarf tools registry delete reg.example.com/stefanprodan/podinfo@sha256:57a654ace69ec02ba8973093b6a786faa15640575fbf0dbb603db55aca2ccec8 +` + + CmdToolsRegistryDigestExample = ` +# return an image digest for an internal repo in Zarf +$ zarf tools registry digest 127.0.0.1:31999/stefanprodan/podinfo:6.4.0 + +# return an image digest from a repo hosted at reg.example.com +$ zarf tools registry digest reg.example.com/stefanprodan/podinfo:6.4.0 +` + + CmdToolsRegistryPruneShort = "Prunes images from the registry that are not currently being used by any Zarf packages." + CmdToolsRegistryPruneFlagConfirm = "Confirm the image prune action to prevent accidental deletions" + CmdToolsRegistryPruneImageList = "The following image digests will be pruned from the registry:" + CmdToolsRegistryPruneNoImages = "There are no images to prune" + CmdToolsRegistryInvalidPlatformErr = "Invalid platform '%s': %s" CmdToolsRegistryFlagVerbose = "Enable debug logs" CmdToolsRegistryFlagInsecure = "Allow image references to be fetched without TLS" @@ -514,9 +540,10 @@ const ( // Collection of reusable error messages. var ( - ErrInitNotFound = errors.New("this command requires a zarf-init package, but one was not found on the local system. Re-run the last command again without '--confirm' to download the package") - ErrUnableToCheckArch = errors.New("unable to get the configured cluster's architecture") - ErrInterrupt = errors.New("execution cancelled due to an interrupt") + ErrInitNotFound = errors.New("this command requires a zarf-init package, but one was not found on the local system. Re-run the last command again without '--confirm' to download the package") + ErrUnableToCheckArch = errors.New("unable to get the configured cluster's architecture") + ErrInterrupt = errors.New("execution cancelled due to an interrupt") + ErrUnableToGetPackages = errors.New("unable to load the Zarf Package data from the cluster") ) // Collection of reusable warn messages. diff --git a/src/internal/cluster/zarf.go b/src/internal/cluster/zarf.go index 13859d7e65..a8df1c8c34 100644 --- a/src/internal/cluster/zarf.go +++ b/src/internal/cluster/zarf.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "github.com/defenseunicorns/zarf/src/config" "github.com/defenseunicorns/zarf/src/pkg/message" @@ -31,18 +32,19 @@ func (c *Cluster) GetDeployedZarfPackages() ([]types.DeployedPackage, []error) { // Process the k8s secret into our internal structs for _, secret := range secrets.Items { - var deployedPackage types.DeployedPackage - err := json.Unmarshal(secret.Data["data"], &deployedPackage) - // add the error to the error list - if err != nil { - errorList = append(errorList, fmt.Errorf("unable to unmarshal the secret %s/%s", secret.Namespace, secret.Name)) - } else { - deployedPackages = append(deployedPackages, deployedPackage) + if strings.HasPrefix(secret.Name, config.ZarfPackagePrefix) { + var deployedPackage types.DeployedPackage + err := json.Unmarshal(secret.Data["data"], &deployedPackage) + // add the error to the error list + if err != nil { + errorList = append(errorList, fmt.Errorf("unable to unmarshal the secret %s/%s", secret.Namespace, secret.Name)) + } else { + deployedPackages = append(deployedPackages, deployedPackage) + } } - } - // TODO: If we move this function out of `internal` we should return a more standard singular error. + // TODO: If we move this function out of `internal` we should return a more standard singular error. return deployedPackages, errorList } diff --git a/src/pkg/k8s/secrets.go b/src/pkg/k8s/secrets.go index a97fd928bd..3e51c80316 100644 --- a/src/pkg/k8s/secrets.go +++ b/src/pkg/k8s/secrets.go @@ -9,6 +9,7 @@ import ( "crypto/tls" "fmt" + "github.com/defenseunicorns/zarf/src/pkg/utils/helpers" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -27,7 +28,7 @@ func (k *K8s) GetSecretsWithLabel(namespace, labelSelector string) (*corev1.Secr // GenerateSecret returns a Kubernetes secret object without applying it to the cluster. func (k *K8s) GenerateSecret(namespace, name string, secretType corev1.SecretType) *corev1.Secret { - return &corev1.Secret{ + secret := &corev1.Secret{ TypeMeta: metav1.TypeMeta{ APIVersion: corev1.SchemeGroupVersion.String(), Kind: "Secret", @@ -35,11 +36,15 @@ func (k *K8s) GenerateSecret(namespace, name string, secretType corev1.SecretTyp ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, - Labels: k.Labels, }, Type: secretType, Data: map[string][]byte{}, } + + // Merge in common labels so that later modifications to the secret can't mutate them + secret.ObjectMeta.Labels = helpers.MergeMap[string](k.Labels, secret.ObjectMeta.Labels) + + return secret } // GenerateTLSSecret returns a Kubernetes secret object without applying it to the cluster. diff --git a/src/pkg/packager/deploy.go b/src/pkg/packager/deploy.go index 2147aac8ad..a5f99a22fd 100644 --- a/src/pkg/packager/deploy.go +++ b/src/pkg/packager/deploy.go @@ -156,7 +156,7 @@ func (p *Packager) deployComponents() (deployedComponents []types.DeployedCompon if p.cluster != nil { err = p.cluster.RecordPackageDeployment(p.cfg.Pkg, deployedComponents, connectStrings) if err != nil { - message.Warnf("Unable to record package deployment for component %s: this will affect features like `zarf package remove`: %s", component.Name, err.Error()) + message.Debugf("Unable to record package deployment for component %s: this will affect features like `zarf package remove`: %s", component.Name, err.Error()) } } diff --git a/src/test/e2e/21_connect_test.go b/src/test/e2e/21_connect_test.go index 320c687afc..adbc2cf763 100644 --- a/src/test/e2e/21_connect_test.go +++ b/src/test/e2e/21_connect_test.go @@ -59,6 +59,23 @@ func TestConnect(t *testing.T) { stdOut, stdErr, err = e2e.Zarf("package", "remove", "init", "--components=logging", "--confirm") require.NoError(t, err, stdOut, stdErr) + + // Prune the images from Grafana and ensure that they are gone + stdOut, stdErr, err = e2e.Zarf("tools", "registry", "prune", "--confirm") + require.NoError(t, err, stdOut, stdErr) + + stdOut, stdErr, err = e2e.Zarf("tools", "registry", "ls", "127.0.0.1:31337/library/registry") + require.NoError(t, err, stdOut, stdErr) + require.Contains(t, stdOut, "2.8.2") + stdOut, stdErr, err = e2e.Zarf("tools", "registry", "ls", "127.0.0.1:31337/grafana/promtail") + require.NoError(t, err, stdOut, stdErr) + require.Equal(t, stdOut, "") + stdOut, stdErr, err = e2e.Zarf("tools", "registry", "ls", "127.0.0.1:31337/grafana/grafana") + require.NoError(t, err, stdOut, stdErr) + require.Equal(t, stdOut, "") + stdOut, stdErr, err = e2e.Zarf("tools", "registry", "ls", "127.0.0.1:31337/grafana/loki") + require.NoError(t, err, stdOut, stdErr) + require.Equal(t, stdOut, "") } func TestMetrics(t *testing.T) {