From fcfd9baf0387af68a8218b424dce14b929e7d5be Mon Sep 17 00:00:00 2001 From: Wayne Starr Date: Mon, 18 Dec 2023 14:51:20 -0700 Subject: [PATCH] feat: add wildcard and deselection support to `--components` (#2175) ## Description This adds wildcard and `default` exclusion support to the `--components` field ## Related Issue Fixes #1794 Fixes #2051 Fixes #2035 ## 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 - [ ] 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_dev_deploy.md | 2 +- .../100-cli-commands/zarf_package_deploy.md | 2 +- .../zarf_package_mirror-resources.md | 4 +- .../100-cli-commands/zarf_package_remove.md | 2 +- .../2-zarf-components.md | 18 ++ docs/3-create-a-zarf-package/4-zarf-schema.md | 12 +- examples/git-data/zarf.yaml | 4 - src/config/lang/english.go | 21 +- src/internal/packager/helm/post-render.go | 3 +- src/internal/packager/validate/validate.go | 31 +- src/pkg/interactive/components.go | 86 ++++++ src/pkg/packager/components.go | 285 ++++++++---------- src/pkg/packager/deploy.go | 10 +- src/pkg/packager/dev.go | 2 +- src/pkg/packager/mirror.go | 15 +- src/pkg/packager/remove.go | 53 ++-- src/pkg/packager/sources/cluster.go | 4 +- src/test/e2e/00_use_cli_test.go | 17 ++ src/test/e2e/22_git_and_gitops_test.go | 4 +- src/test/packages/00-no-components/zarf.yaml | 9 + src/test/packages/28-helm-no-wait/zarf.yaml | 2 +- src/types/component.go | 2 +- src/types/package.go | 2 +- zarf.schema.json | 4 +- 24 files changed, 351 insertions(+), 243 deletions(-) create mode 100644 src/pkg/interactive/components.go create mode 100644 src/test/packages/00-no-components/zarf.yaml diff --git a/docs/2-the-zarf-cli/100-cli-commands/zarf_dev_deploy.md b/docs/2-the-zarf-cli/100-cli-commands/zarf_dev_deploy.md index 8b1700f426..36961236b3 100644 --- a/docs/2-the-zarf-cli/100-cli-commands/zarf_dev_deploy.md +++ b/docs/2-the-zarf-cli/100-cli-commands/zarf_dev_deploy.md @@ -14,7 +14,7 @@ zarf dev deploy [flags] ## Options ``` - --components string Comma-separated list of components to install. Adding this flag will skip the init prompts for which components to install + --components string Comma-separated list of components to deploy. Adding this flag will skip the prompts for selected components. Globbing component names with '*' and deselecting 'default' components with a leading '-' are also supported. --create-set stringToString Specify package variables to set on the command line (KEY=value) (default []) --deploy-set stringToString Specify deployment variables to set on the command line (KEY=value) (default []) -f, --flavor string The flavor of components to include in the resulting package (i.e. have a matching or empty "only.flavor" key) diff --git a/docs/2-the-zarf-cli/100-cli-commands/zarf_package_deploy.md b/docs/2-the-zarf-cli/100-cli-commands/zarf_package_deploy.md index 5dc213e8e1..960898294b 100644 --- a/docs/2-the-zarf-cli/100-cli-commands/zarf_package_deploy.md +++ b/docs/2-the-zarf-cli/100-cli-commands/zarf_package_deploy.md @@ -16,7 +16,7 @@ zarf package deploy [ PACKAGE_SOURCE ] [flags] ``` --adopt-existing-resources Adopts any pre-existing K8s resources into the Helm charts managed by Zarf. ONLY use when you have existing deployments you want Zarf to takeover. - --components string Comma-separated list of components to install. Adding this flag will skip the init prompts for which components to install + --components string Comma-separated list of components to deploy. Adding this flag will skip the prompts for selected components. Globbing component names with '*' and deselecting 'default' components with a leading '-' are also supported. --confirm Confirms package deployment without prompting. ONLY use with packages you trust. Skips prompts to review SBOM, configure variables, select optional components and review potential breaking changes. -h, --help help for deploy --set stringToString Specify deployment variables to set on the command line (KEY=value) (default []) diff --git a/docs/2-the-zarf-cli/100-cli-commands/zarf_package_mirror-resources.md b/docs/2-the-zarf-cli/100-cli-commands/zarf_package_mirror-resources.md index 32b7ffc4ae..2493836807 100644 --- a/docs/2-the-zarf-cli/100-cli-commands/zarf_package_mirror-resources.md +++ b/docs/2-the-zarf-cli/100-cli-commands/zarf_package_mirror-resources.md @@ -5,7 +5,7 @@ Mirrors a Zarf package's internal resources to specified image registries and gi ## Synopsis -Unpacks resources and dependencies from a Zarf package archive and mirrors them into the specified +Unpacks resources and dependencies from a Zarf package archive and mirrors them into the specified image registries and git repositories within the target environment ``` @@ -39,7 +39,7 @@ $ zarf package mirror-resources \ ## Options ``` - --components string Comma-separated list of components to mirror. This list will be respected regardless of a component's 'required' status. + --components string Comma-separated list of components to mirror. This list will be respected regardless of a component's 'required' or 'default' status. Globbing component names with '*' and deselecting components with a leading '-' are also supported. --confirm Confirms package deployment without prompting. ONLY use with packages you trust. Skips prompts to review SBOM, configure variables, select optional components and review potential breaking changes. --git-push-password string Password for the push-user to access the git server --git-push-username string Username to access to the git server Zarf is configured to use. User must be able to create repositories via 'git push' (default "zarf-git-user") diff --git a/docs/2-the-zarf-cli/100-cli-commands/zarf_package_remove.md b/docs/2-the-zarf-cli/100-cli-commands/zarf_package_remove.md index 022294b49a..3e4a20ae08 100644 --- a/docs/2-the-zarf-cli/100-cli-commands/zarf_package_remove.md +++ b/docs/2-the-zarf-cli/100-cli-commands/zarf_package_remove.md @@ -10,7 +10,7 @@ zarf package remove { PACKAGE_SOURCE | PACKAGE_NAME } --confirm [flags] ## Options ``` - --components string Comma-separated list of components to uninstall + --components string Comma-separated list of components to remove. This list will be respected regardless of a component's 'required' or 'default' status. Globbing component names with '*' and deselecting components with a leading '-' are also supported. --confirm REQUIRED. Confirm the removal action to prevent accidental deletions -h, --help help for remove ``` diff --git a/docs/3-create-a-zarf-package/2-zarf-components.md b/docs/3-create-a-zarf-package/2-zarf-components.md index d10acf2368..1ccd87dcc1 100644 --- a/docs/3-create-a-zarf-package/2-zarf-components.md +++ b/docs/3-create-a-zarf-package/2-zarf-components.md @@ -204,3 +204,21 @@ $ zarf package deploy ./path/to/package.tar.zst --confirm # deploy optional-component-1 and optional-component-2 components whether they are required or not $ zarf package deploy ./path/to/package.tar.zst --components=optional-component-1,optional-component-2 ``` + +:::tip + +You can deploy components in a package using globbing as well. The following would deploy all components regardless of optional status: + +```bash +# deploy optional-component-1 and optional-component-2 components whether they are required or not +$ zarf package deploy ./path/to/package.tar.zst --components=* +``` + +If you have any `default` components in a package definition you can also exclude those from the CLI with a leading dash (`-`) (similar to how you can exclude search terms in a search engine). + +```bash +# deploy optional-component-1 but exclude default-component-1 +$ zarf package deploy ./path/to/package.tar.zst --components=optional-component-1,-default-component-1 +``` + +::: diff --git a/docs/3-create-a-zarf-package/4-zarf-schema.md b/docs/3-create-a-zarf-package/4-zarf-schema.md index 6f6ae55236..6dfe8b5153 100644 --- a/docs/3-create-a-zarf-package/4-zarf-schema.md +++ b/docs/3-create-a-zarf-package/4-zarf-schema.md @@ -63,9 +63,9 @@ Must be one of: | -------- | -------- | | **Type** | `string` | -| Restrictions | | -| --------------------------------- | --------------------------------------------------------------------------------- | -| **Must match regular expression** | ```^[a-z0-9\-]+$``` [Test](https://regex101.com/?regex=%5E%5Ba-z0-9%5C-%5D%2B%24) | +| Restrictions | | +| --------------------------------- | ----------------------------------------------------------------------------------------------------- | +| **Must match regular expression** | ```^[a-z0-9\-]*[a-z0-9]$``` [Test](https://regex101.com/?regex=%5E%5Ba-z0-9%5C-%5D%2A%5Ba-z0-9%5D%24) | @@ -554,9 +554,9 @@ must respect the following conditions | -------- | -------- | | **Type** | `string` | -| Restrictions | | -| --------------------------------- | --------------------------------------------------------------------------------- | -| **Must match regular expression** | ```^[a-z0-9\-]+$``` [Test](https://regex101.com/?regex=%5E%5Ba-z0-9%5C-%5D%2B%24) | +| Restrictions | | +| --------------------------------- | ----------------------------------------------------------------------------------------------------- | +| **Must match regular expression** | ```^[a-z0-9\-]*[a-z0-9]$``` [Test](https://regex101.com/?regex=%5E%5Ba-z0-9%5C-%5D%2A%5Ba-z0-9%5D%24) | diff --git a/examples/git-data/zarf.yaml b/examples/git-data/zarf.yaml index ca61b0d252..f5262a93f1 100644 --- a/examples/git-data/zarf.yaml +++ b/examples/git-data/zarf.yaml @@ -6,7 +6,6 @@ metadata: components: - name: full-repo - required: true repos: # The following performs a full Git Repo Mirror with `go-git` (internal to Zarf) - https://github.com/defenseunicorns/zarf-public-test.git @@ -14,7 +13,6 @@ components: - https://dev.azure.com/defenseunicorns/zarf-public-test/_git/zarf-public-test - name: specific-tag - required: true repos: # The following performs a tag Git Repo Mirror with `go-git` (internal to Zarf) - https://github.com/defenseunicorns/zarf-public-test.git@v0.0.1 @@ -24,7 +22,6 @@ components: - https://dev.azure.com/defenseunicorns/zarf-public-test/_git/zarf-public-test@v0.0.1 - name: specific-branch - required: true repos: # The following performs a branch Git Repo Mirror with `go-git` (internal to Zarf) - https://github.com/defenseunicorns/zarf-public-test.git@refs/heads/dragons @@ -32,7 +29,6 @@ components: - https://dev.azure.com/defenseunicorns/zarf-public-test/_git/zarf-public-test@refs/heads/dragons - name: specific-hash - required: true repos: # The following performs a SHA Git Repo Mirror with `go-git` (internal to Zarf) - https://github.com/defenseunicorns/zarf-public-test.git@01a23218923f24194133b5eb11268cf8d73ff1bb diff --git a/src/config/lang/english.go b/src/config/lang/english.go index 60f4d7617a..6ca03f5a28 100644 --- a/src/config/lang/english.go +++ b/src/config/lang/english.go @@ -237,7 +237,7 @@ $ zarf init --artifact-push-password={PASSWORD} --artifact-push-username={USERNA "Kubernetes clusters are accessed via credentials in your current kubecontext defined in '~/.kube/config'" CmdPackageMirrorShort = "Mirrors a Zarf package's internal resources to specified image registries and git repositories" - CmdPackageMirrorLong = "Unpacks resources and dependencies from a Zarf package archive and mirrors them into the specified \n" + + CmdPackageMirrorLong = "Unpacks resources and dependencies from a Zarf package archive and mirrors them into the specified\n" + "image registries and git repositories within the target environment" CmdPackageMirrorExample = ` # Mirror resources to internal Zarf resources @@ -286,7 +286,7 @@ $ zarf package mirror-resources \ CmdPackageDeployFlagConfirm = "Confirms package deployment without prompting. ONLY use with packages you trust. Skips prompts to review SBOM, configure variables, select optional components and review potential breaking changes." CmdPackageDeployFlagAdoptExistingResources = "Adopts any pre-existing K8s resources into the Helm charts managed by Zarf. ONLY use when you have existing deployments you want Zarf to takeover." CmdPackageDeployFlagSet = "Specify deployment variables to set on the command line (KEY=value)" - CmdPackageDeployFlagComponents = "Comma-separated list of components to install. Adding this flag will skip the init prompts for which components to install" + CmdPackageDeployFlagComponents = "Comma-separated list of components to deploy. Adding this flag will skip the prompts for selected components. Globbing component names with '*' and deselecting 'default' components with a leading '-' are also supported." CmdPackageDeployFlagShasum = "Shasum of the package to deploy. Required if deploying a remote package and \"--insecure\" is not provided" CmdPackageDeployFlagSget = "[Deprecated] Path to public sget key file for remote packages signed via cosign. This flag will be removed in v1.0.0 please use the --key flag instead." CmdPackageDeployFlagSkipWebhooks = "[alpha] Skip waiting for external webhooks to execute as each package component is deployed" @@ -296,7 +296,7 @@ $ zarf package mirror-resources \ CmdPackageDeployInvalidCLIVersionWarn = "CLIVersion is set to '%s' which can cause issues with package creation and deployment. To avoid such issues, please set the value to the valid semantic version for this version of Zarf." CmdPackageDeployErr = "Failed to deploy package: %s" - CmdPackageMirrorFlagComponents = "Comma-separated list of components to mirror. This list will be respected regardless of a component's 'required' status." + CmdPackageMirrorFlagComponents = "Comma-separated list of components to mirror. This list will be respected regardless of a component's 'required' or 'default' status. Globbing component names with '*' and deselecting components with a leading '-' are also supported." CmdPackageMirrorFlagNoChecksum = "Turns off the addition of a checksum to image tags (as would be used by the Zarf Agent) while mirroring images." CmdPackageInspectFlagSbom = "View SBOM contents while inspecting the package" @@ -305,7 +305,7 @@ $ zarf package mirror-resources \ CmdPackageRemoveShort = "Removes a Zarf package that has been deployed already (runs offline)" CmdPackageRemoveFlagConfirm = "REQUIRED. Confirm the removal action to prevent accidental deletions" - CmdPackageRemoveFlagComponents = "Comma-separated list of components to uninstall" + CmdPackageRemoveFlagComponents = "Comma-separated list of components to remove. This list will be respected regardless of a component's 'required' or 'default' status. Globbing component names with '*' and deselecting components with a leading '-' are also supported." CmdPackageRemoveTarballErr = "Invalid tarball path provided" CmdPackageRemoveExtractErr = "Unable to extract the package contents" CmdPackageRemoveErr = "Unable to remove the package with an error of: %s" @@ -609,6 +609,14 @@ const ( PkgCreateErrDifferentialSameVersion = "unable to create a differential package with the same version as the package you are using as a reference; the package version must be incremented" ) +// src/internal/packager/deploy. +const ( + PkgDeployErrMultipleComponentsSameGroup = "You cannot specify multiple components (%q, %q) within the same group (%q) when using the --components flag." + PkgDeployErrNoDefaultOrSelection = "You must make a selection from %q with the --components flag as there is no default in their group." + PkgDeployErrNoCompatibleComponentsForSelection = "No compatible components found that matched %q. Please check spelling and try again." + PkgDeployErrComponentSelectionCanceled = "Component selection canceled: %s" +) + // src/internal/packager/validate. const ( PkgValidateTemplateDeprecation = "Package template %q is using the deprecated syntax ###ZARF_PKG_VAR_%s###. This will be removed in Zarf v1.0.0. Please update to ###ZARF_PKG_TMPL_%s###." @@ -624,11 +632,14 @@ const ( PkgValidateErrChartNamespaceMissing = "chart %q must include a namespace" PkgValidateErrChartURLOrPath = "chart %q must have either a url or localPath" PkgValidateErrChartVersion = "chart %q must include a chart version" + PkgValidateErrComponentName = "component name %q must be all lowercase and contain no special characters except '-' and cannot start with a '-'" PkgValidateErrComponentNameNotUnique = "component name %q is not unique" PkgValidateErrComponent = "invalid component %q: %w" PkgValidateErrComponentReqDefault = "component %q cannot be both required and default" PkgValidateErrComponentReqGrouped = "component %q cannot be both required and grouped" PkgValidateErrComponentYOLO = "component %q incompatible with the online-only package flag (metadata.yolo): %w" + PkgValidateErrGroupMultipleDefaults = "group %q has multiple defaults (%q, %q)" + PkgValidateErrGroupOneComponent = "group %q only has one component (%q)" PkgValidateErrConstant = "invalid package constant: %w" PkgValidateErrImportDefinition = "invalid imported definition for %s: %s" PkgValidateErrInitNoYOLO = "sorry, you can't YOLO an init package" @@ -640,7 +651,7 @@ const ( PkgValidateErrName = "invalid package name: %w" PkgValidateErrPkgConstantName = "constant name %q must be all uppercase and contain no special characters except _" PkgValidateErrPkgConstantPattern = "provided value for constant %q does not match pattern %q" - PkgValidateErrPkgName = "package name %q must be all lowercase and contain no special characters except -" + PkgValidateErrPkgName = "package name %q must be all lowercase and contain no special characters except '-' and cannot start with a '-'" PkgValidateErrVariable = "invalid package variable: %w" PkgValidateErrYOLONoArch = "cluster architecture not allowed" PkgValidateErrYOLONoDistro = "cluster distros not allowed" diff --git a/src/internal/packager/helm/post-render.go b/src/internal/packager/helm/post-render.go index ad5ff58315..7d89098ad3 100644 --- a/src/internal/packager/helm/post-render.go +++ b/src/internal/packager/helm/post-render.go @@ -55,11 +55,10 @@ func (h *Helm) newRenderer() (*renderer, error) { func (r *renderer) Run(renderedManifests *bytes.Buffer) (*bytes.Buffer, error) { // This is very low cost and consistent for how we replace elsewhere, also good for debugging - tempDir, err := utils.MakeTempDir(config.CommonOptions.TempDirectory) + tempDir, err := utils.MakeTempDir(r.chartPath) if err != nil { return nil, fmt.Errorf("unable to create tmpdir: %w", err) } - defer os.RemoveAll(tempDir) path := filepath.Join(tempDir, "chart.yaml") // Write the context to a file for processing diff --git a/src/internal/packager/validate/validate.go b/src/internal/packager/validate/validate.go index 9a1d57db1a..121797694b 100644 --- a/src/internal/packager/validate/validate.go +++ b/src/internal/packager/validate/validate.go @@ -18,9 +18,9 @@ import ( ) var ( - // IsLowercaseNumberHyphen is a regex for lowercase, numbers and hyphens. - // https://regex101.com/r/FLdG9G/1 - IsLowercaseNumberHyphen = regexp.MustCompile(`^[a-z0-9\-]+$`).MatchString + // IsLowercaseNumberHyphenNoStartHyphen is a regex for lowercase, numbers and hyphens that cannot start with a hyphen. + // https://regex101.com/r/FLdG9G/2 + IsLowercaseNumberHyphenNoStartHyphen = regexp.MustCompile(`^[a-z0-9][a-z0-9\-]*$`).MatchString // IsUppercaseNumberUnderscore is a regex for uppercase, numbers and underscores. // https://regex101.com/r/tfsEuZ/1 IsUppercaseNumberUnderscore = regexp.MustCompile(`^[A-Z0-9_]+$`).MatchString @@ -49,6 +49,8 @@ func Run(pkg types.ZarfPackage) error { } uniqueComponentNames := make(map[string]bool) + groupDefault := make(map[string]string) + groupedComponents := make(map[string][]string) for _, component := range pkg.Components { // ensure component name is unique @@ -60,6 +62,23 @@ func Run(pkg types.ZarfPackage) error { if err := validateComponent(pkg, component); err != nil { return fmt.Errorf(lang.PkgValidateErrComponent, component.Name, err) } + + // ensure groups don't have multiple defaults or only one component + if component.Group != "" { + if component.Default { + if _, ok := groupDefault[component.Group]; ok { + return fmt.Errorf(lang.PkgValidateErrGroupMultipleDefaults, component.Group, groupDefault[component.Group], component.Name) + } + groupDefault[component.Group] = component.Name + } + groupedComponents[component.Group] = append(groupedComponents[component.Group], component.Name) + } + } + + for groupKey, componentNames := range groupedComponents { + if len(componentNames) == 1 { + return fmt.Errorf(lang.PkgValidateErrGroupOneComponent, groupKey, componentNames[0]) + } } return nil @@ -111,6 +130,10 @@ func oneIfNotEmpty(testString string) int { } func validateComponent(pkg types.ZarfPackage, component types.ZarfComponent) error { + if !IsLowercaseNumberHyphenNoStartHyphen(component.Name) { + return fmt.Errorf(lang.PkgValidateErrComponentName, component.Name) + } + if component.Required { if component.Default { return fmt.Errorf(lang.PkgValidateErrComponentReqDefault, component.Name) @@ -254,7 +277,7 @@ func validateYOLO(component types.ZarfComponent) error { } func validatePackageName(subject string) error { - if !IsLowercaseNumberHyphen(subject) { + if !IsLowercaseNumberHyphenNoStartHyphen(subject) { return fmt.Errorf(lang.PkgValidateErrPkgName, subject) } diff --git a/src/pkg/interactive/components.go b/src/pkg/interactive/components.go new file mode 100644 index 0000000000..bb8f244f75 --- /dev/null +++ b/src/pkg/interactive/components.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +// Package interactive contains functions for interacting with the user via STDIN. +package interactive + +import ( + "fmt" + "strings" + + "github.com/AlecAivazis/survey/v2" + "github.com/defenseunicorns/zarf/src/config" + "github.com/defenseunicorns/zarf/src/config/lang" + "github.com/defenseunicorns/zarf/src/pkg/message" + "github.com/defenseunicorns/zarf/src/pkg/utils" + "github.com/defenseunicorns/zarf/src/types" + "github.com/pterm/pterm" +) + +// SelectOptionalComponent prompts to confirm optional components +func SelectOptionalComponent(component types.ZarfComponent) (confirmComponent bool) { + // Confirm flag passed, just use defaults + if config.CommonOptions.Confirm { + return component.Default + } + + message.HorizontalRule() + + displayComponent := component + displayComponent.Description = "" + utils.ColorPrintYAML(displayComponent, nil, false) + if component.Description != "" { + message.Question(component.Description) + } + + prompt := &survey.Confirm{ + Message: fmt.Sprintf("Deploy the %s component?", component.Name), + Default: component.Default, + } + if err := survey.AskOne(prompt, &confirmComponent); err != nil { + message.Fatalf(nil, lang.PkgDeployErrComponentSelectionCanceled, err.Error()) + } + + return confirmComponent +} + +// SelectChoiceGroup prompts to select component groups +func SelectChoiceGroup(componentGroup []types.ZarfComponent) types.ZarfComponent { + // Confirm flag passed, just use defaults + if config.CommonOptions.Confirm { + var componentNames []string + for _, component := range componentGroup { + // If the component is default, then return it + if component.Default { + return component + } + // Add each component name to the list + componentNames = append(componentNames, component.Name) + } + // If no default component was found, give up + message.Fatalf(nil, lang.PkgDeployErrNoDefaultOrSelection, strings.Join(componentNames, ",")) + } + + message.HorizontalRule() + + var chosen int + var options []string + + for _, component := range componentGroup { + text := fmt.Sprintf("Name: %s\n Description: %s\n", component.Name, component.Description) + options = append(options, text) + } + + prompt := &survey.Select{ + Message: "Select a component to deploy:", + Options: options, + } + + pterm.Println() + + if err := survey.AskOne(prompt, &chosen); err != nil { + message.Fatalf(nil, lang.PkgDeployErrComponentSelectionCanceled, err.Error()) + } + + return componentGroup[chosen] +} diff --git a/src/pkg/packager/components.go b/src/pkg/packager/components.go index 45b9cf5ae3..d393eb3e59 100644 --- a/src/pkg/packager/components.go +++ b/src/pkg/packager/components.go @@ -5,211 +5,182 @@ package packager import ( - "fmt" + "path" + "slices" + "strings" - "github.com/AlecAivazis/survey/v2" - "github.com/defenseunicorns/zarf/src/config" - "github.com/defenseunicorns/zarf/src/pkg/k8s" + "github.com/defenseunicorns/zarf/src/config/lang" + "github.com/defenseunicorns/zarf/src/pkg/interactive" "github.com/defenseunicorns/zarf/src/pkg/message" - "github.com/defenseunicorns/zarf/src/pkg/utils" "github.com/defenseunicorns/zarf/src/pkg/utils/helpers" "github.com/defenseunicorns/zarf/src/types" - "github.com/pterm/pterm" ) -func (p *Packager) getValidComponents() []types.ZarfComponent { - var validComponentsList []types.ZarfComponent - var orderedKeys []string - var choiceComponents []string +type selectState int - componentGroups := make(map[string][]types.ZarfComponent) +const ( + unknown selectState = iota + included + excluded +) - // The component list is comma-delimited list - requestedNames := helpers.StringToSlice(p.cfg.PkgOpts.OptionalComponents) +func (p *Packager) getSelectedComponents() []types.ZarfComponent { + var selectedComponents []types.ZarfComponent + groupedComponents := map[string][]types.ZarfComponent{} + orderedComponentGroups := []string{} - // Break up components into choice groups + // Group the components by Name and Group while maintaining order for _, component := range p.cfg.Pkg.Components { - matchFn := func(a, b string) bool { return a == b } - key := component.Group - // If not a choice group, then use the component name as the key - if key == "" { - key = component.Name - } else { - // Otherwise, add the component name to the choice group list for later validation - choiceComponents = helpers.MergeSlices(choiceComponents, []string{component.Name}, matchFn) + groupKey := component.Name + if component.Group != "" { + groupKey = component.Group } - // Preserve component order - orderedKeys = helpers.MergeSlices(orderedKeys, []string{key}, matchFn) + if !slices.Contains(orderedComponentGroups, groupKey) { + orderedComponentGroups = append(orderedComponentGroups, groupKey) + } - // Append the component to the list of components in the group - componentGroups[key] = append(componentGroups[key], component) + groupedComponents[groupKey] = append(groupedComponents[groupKey], component) } - // Loop through each component group in original order and handle required, requested or user confirmation - for _, key := range orderedKeys { - - componentGroup := componentGroups[key] + // Split the --components list as a comma-delimited list + requestedComponents := helpers.StringToSlice(p.cfg.PkgOpts.OptionalComponents) + isPartial := len(requestedComponents) > 0 && requestedComponents[0] != "" + + if isPartial { + matchedRequests := map[string]bool{} + + // NOTE: This does not use forIncludedComponents as it takes group, default and required status into account. + for _, groupKey := range orderedComponentGroups { + var groupDefault *types.ZarfComponent + var groupSelected *types.ZarfComponent + + for _, component := range groupedComponents[groupKey] { + // Ensure we have a local version of the component to point to (otherwise the pointer might change on us) + component := component + + selectState, matchedRequest := includedOrExcluded(component, requestedComponents) + + if !component.Required { + if selectState == excluded { + // If the component was explicitly excluded, record the match and continue + matchedRequests[matchedRequest] = true + continue + } else if selectState == unknown && component.Default && groupDefault == nil { + // If the component is default but not included or excluded, remember the default + groupDefault = &component + } + } else { + // Force the selectState to included for Required components + selectState = included + } - // Choice groups are handled differently for user confirmation - userChoicePrompt := len(componentGroup) > 1 + if selectState == included { + // If the component was explicitly included, record the match + matchedRequests[matchedRequest] = true - // Loop through the components in the group - for _, component := range componentGroup { - // First check if the component is required or requested via CLI flag - requested := p.isRequiredOrRequested(component, requestedNames) + // Then check for already selected groups + if groupSelected != nil { + message.Fatalf(nil, lang.PkgDeployErrMultipleComponentsSameGroup, groupSelected.Name, component.Name, component.Group) + } - // If the user has not requested this component via CLI flag, then prompt them if not a choice group - if !requested && !userChoicePrompt { - requested = p.confirmOptionalComponent(component) + // Then append to the final list + selectedComponents = append(selectedComponents, component) + groupSelected = &component + } } - if requested { - // Mark deployment as appliance mode if this is an init config and the k3s component is enabled - if component.Name == k8s.DistroIsK3s && p.isInitConfig() { - p.cfg.InitOpts.ApplianceMode = true + // If nothing was selected from a group, handle the default + if groupSelected == nil && groupDefault != nil { + selectedComponents = append(selectedComponents, *groupDefault) + } else if len(groupedComponents[groupKey]) > 1 && groupSelected == nil && groupDefault == nil { + // If no default component was found, give up + componentNames := []string{} + for _, component := range groupedComponents[groupKey] { + componentNames = append(componentNames, component.Name) } - // Add the component to the list of valid components - validComponentsList = append(validComponentsList, component) - // Ensure that the component is not requested again if in a choice group - userChoicePrompt = false - // Exit the inner loop on a match since groups should only have one requested component - break + message.Fatalf(nil, lang.PkgDeployErrNoDefaultOrSelection, strings.Join(componentNames, ",")) } } - // If the user has requested a choice group, then prompt them - if userChoicePrompt { - selectedComponent := p.confirmChoiceGroup(componentGroup) - validComponentsList = append(validComponentsList, selectedComponent) - } - } - - // Ensure all user requested components are valid - if err := p.validateRequests(validComponentsList, requestedNames, choiceComponents); err != nil { - message.Fatalf(err, "Invalid component argument, %s", err) - } - - return validComponentsList -} - -// Match on the first requested component that is not in the list of valid components and return the component name. -func (p *Packager) validateRequests(validComponentsList []types.ZarfComponent, requestedComponentNames, choiceComponents []string) error { - // Loop through each requested component names - for _, componentName := range requestedComponentNames { - found := false - // Match on the first requested component that is a valid component - for _, component := range validComponentsList { - if component.Name == componentName { - found = true - break + // Check that we have matched against all requests + for _, requestedComponent := range requestedComponents { + if _, ok := matchedRequests[requestedComponent]; !ok { + message.Fatalf(nil, lang.PkgDeployErrNoCompatibleComponentsForSelection, requestedComponent) } } - - // If the requested component was not found, then return an error - if !found { - // If the requested component is in a choice group, then warn the user they must choose only one - for _, component := range choiceComponents { - if component == componentName { - return fmt.Errorf("component %s is part of a group of components and only one may be chosen", componentName) + } else { + for _, groupKey := range orderedComponentGroups { + if len(groupedComponents[groupKey]) > 1 { + component := interactive.SelectChoiceGroup(groupedComponents[groupKey]) + selectedComponents = append(selectedComponents, component) + } else { + component := groupedComponents[groupKey][0] + + if component.Required { + selectedComponents = append(selectedComponents, component) + } else if selected := interactive.SelectOptionalComponent(component); selected { + selectedComponents = append(selectedComponents, component) } } - // Otherwise, return an error a general error - return fmt.Errorf("unable to find component %s", componentName) } } - return nil + return selectedComponents } -func (p *Packager) isRequiredOrRequested(component types.ZarfComponent, requestedComponentNames []string) bool { - // If the component is required, then just return true - if component.Required { - return true - } - - // Otherwise,check if this is one of the components that has been requested - if len(requestedComponentNames) > 0 || config.CommonOptions.Confirm { - for _, requestedComponent := range requestedComponentNames { - // If the component name matches one of the requested components, then return true - if requestedComponent == component.Name { - return true - } - } - } +func (p *Packager) forIncludedComponents(onIncluded func(types.ZarfComponent) error) error { + requestedComponents := helpers.StringToSlice(p.cfg.PkgOpts.OptionalComponents) + isPartial := len(requestedComponents) > 0 && requestedComponents[0] != "" - // All other cases, return false - return false -} + for _, component := range p.cfg.Pkg.Components { + selectState := unknown -// Confirm optional component. -func (p *Packager) confirmOptionalComponent(component types.ZarfComponent) (confirmComponent bool) { - // Confirm flag passed, just use defaults - if config.CommonOptions.Confirm { - return component.Default - } + if isPartial { + selectState, _ = includedOrExcluded(component, requestedComponents) - message.HorizontalRule() + if selectState == excluded { + continue + } + } else { + selectState = included + } - displayComponent := component - displayComponent.Description = "" - utils.ColorPrintYAML(displayComponent, nil, false) - if component.Description != "" { - message.Question(component.Description) + if selectState == included { + if err := onIncluded(component); err != nil { + return err + } + } } - // Since no requested components were provided, prompt the user - prompt := &survey.Confirm{ - Message: fmt.Sprintf("Deploy the %s component?", component.Name), - Default: component.Default, - } - if err := survey.AskOne(prompt, &confirmComponent); err != nil { - message.Fatalf(nil, "Confirm selection canceled: %s", err.Error()) - } - return confirmComponent + return nil } -func (p *Packager) confirmChoiceGroup(componentGroup []types.ZarfComponent) types.ZarfComponent { - // Confirm flag passed, just use defaults - if config.CommonOptions.Confirm { - var componentNames []string - for _, component := range componentGroup { - // If the component is default, then return it - if component.Default { - return component +func includedOrExcluded(component types.ZarfComponent, requestedComponentNames []string) (selectState, string) { + // Check if the component has a leading dash indicating it should be excluded - this is done first so that exclusions precede inclusions + for _, requestedComponent := range requestedComponentNames { + if strings.HasPrefix(requestedComponent, "-") { + // If the component glob matches one of the requested components, then return true + // This supports globbing with "path" in order to have the same behavior across OSes (if we ever allow namespaced components with /) + if matched, _ := path.Match(strings.TrimPrefix(requestedComponent, "-"), component.Name); matched { + return excluded, requestedComponent } - // Add each component name to the list - componentNames = append(componentNames, component.Name) } - // If no default component was found, give up - message.Fatalf(nil, "You must specify at least one component from the group %#v when using the --confirm flag.", componentNames) - } - - message.HorizontalRule() - - var chosen int - var options []string - - for _, component := range componentGroup { - text := fmt.Sprintf("Name: %s\n Description: %s\n", component.Name, component.Description) - options = append(options, text) } - - prompt := &survey.Select{ - Message: "Select a component to deploy:", - Options: options, - } - - pterm.Println() - - if err := survey.AskOne(prompt, &chosen); err != nil { - message.Fatalf(nil, "Component selection canceled: %s", err.Error()) + // Check if the component matches a glob pattern and should be included + for _, requestedComponent := range requestedComponentNames { + // If the component glob matches one of the requested components, then return true + // This supports globbing with "path" in order to have the same behavior across OSes (if we ever allow namespaced components with /) + if matched, _ := path.Match(requestedComponent, component.Name); matched { + return included, requestedComponent + } } - return componentGroup[chosen] + // All other cases we don't know if we should include or exclude yet + return unknown, "" } -func (p *Packager) requiresCluster(component types.ZarfComponent) bool { +func requiresCluster(component types.ZarfComponent) bool { hasImages := len(component.Images) > 0 hasCharts := len(component.Charts) > 0 hasManifests := len(component.Manifests) > 0 diff --git a/src/pkg/packager/deploy.go b/src/pkg/packager/deploy.go index 607a48c350..f76d20962a 100644 --- a/src/pkg/packager/deploy.go +++ b/src/pkg/packager/deploy.go @@ -93,7 +93,7 @@ func (p *Packager) Deploy() (err error) { // deployComponents loops through a list of ZarfComponents and deploys them. func (p *Packager) deployComponents() (deployedComponents []types.DeployedComponent, err error) { - componentsToDeploy := p.getValidComponents() + componentsToDeploy := p.getSelectedComponents() // Generate a value template if p.valueTemplate, err = template.Generate(p.cfg); err != nil { @@ -115,7 +115,7 @@ func (p *Packager) deployComponents() (deployedComponents []types.DeployedCompon } // If this component requires a cluster, connect to one - if p.requiresCluster(component) { + if requiresCluster(component) { timeout := cluster.DefaultTimeout if p.isInitConfig() { timeout = 5 * time.Minute @@ -201,7 +201,7 @@ func (p *Packager) deployInitComponent(component types.ZarfComponent) (charts [] isAgent := component.Name == "zarf-agent" // Always init the state before the first component that requires the cluster (on most deployments, the zarf-seed-registry) - if p.requiresCluster(component) && p.cfg.State == nil { + if requiresCluster(component) && p.cfg.State == nil { err = p.cluster.InitZarfState(p.cfg.InitOpts) if err != nil { return charts, fmt.Errorf("unable to initialize Zarf state: %w", err) @@ -214,7 +214,7 @@ func (p *Packager) deployInitComponent(component types.ZarfComponent) (charts [] } if isRegistry { - // If we are deploying the registry then mark the HPA as "modifed" to set it to Min later + // If we are deploying the registry then mark the HPA as "modified" to set it to Min later p.hpaModified = true } @@ -265,7 +265,7 @@ func (p *Packager) deployComponent(component types.ZarfComponent, noImgChecksum } } - if !p.valueTemplate.Ready() && p.requiresCluster(component) { + if !p.valueTemplate.Ready() && requiresCluster(component) { // Setup the state in the config and get the valuesTemplate p.valueTemplate, err = p.setupStateValuesTemplate() if err != nil { diff --git a/src/pkg/packager/dev.go b/src/pkg/packager/dev.go index a48d3fdc31..b4e1744d46 100644 --- a/src/pkg/packager/dev.go +++ b/src/pkg/packager/dev.go @@ -40,7 +40,7 @@ func (p *Packager) DevDeploy() error { // the user's selection and the component's `required` field // This is also different from regular package creation, where we still assemble and package up // all components and their dependencies, regardless of whether they are required or not - p.cfg.Pkg.Components = p.getValidComponents() + p.cfg.Pkg.Components = p.getSelectedComponents() if err := validate.Run(p.cfg.Pkg); err != nil { return fmt.Errorf("unable to validate package: %w", err) diff --git a/src/pkg/packager/mirror.go b/src/pkg/packager/mirror.go index 293663a97c..689ddcfbaa 100644 --- a/src/pkg/packager/mirror.go +++ b/src/pkg/packager/mirror.go @@ -8,11 +8,8 @@ import ( "fmt" "strings" - "slices" - "github.com/defenseunicorns/zarf/src/config" "github.com/defenseunicorns/zarf/src/pkg/message" - "github.com/defenseunicorns/zarf/src/pkg/utils/helpers" "github.com/defenseunicorns/zarf/src/types" ) @@ -45,17 +42,9 @@ func (p *Packager) Mirror() (err error) { // Filter out components that are not compatible with this system if we have loaded from a tarball p.filterComponents() - requestedComponentNames := helpers.StringToSlice(p.cfg.PkgOpts.OptionalComponents) - for _, component := range p.cfg.Pkg.Components { - if len(requestedComponentNames) == 0 || slices.Contains(requestedComponentNames, component.Name) { - if err := p.mirrorComponent(component); err != nil { - return err - } - } - } - - return nil + // Run mirror for each requested component + return p.forIncludedComponents(p.mirrorComponent) } // mirrorComponent mirrors a Zarf Component. diff --git a/src/pkg/packager/remove.go b/src/pkg/packager/remove.go index 89b851eaf0..2aaee0c3af 100644 --- a/src/pkg/packager/remove.go +++ b/src/pkg/packager/remove.go @@ -24,17 +24,13 @@ import ( // Remove removes a package that was already deployed onto a cluster, uninstalling all installed helm charts. func (p *Packager) Remove() (err error) { - _, requiresCluster := p.source.(*sources.ClusterSource) - if requiresCluster { + _, isClusterSource := p.source.(*sources.ClusterSource) + if isClusterSource { p.cluster = p.source.(*sources.ClusterSource).Cluster } spinner := message.NewProgressSpinner("Removing Zarf package %s", p.cfg.PkgOpts.PackageSource) defer spinner.Stop() - // If components were provided; just remove the things we were asked to remove - requestedComponents := helpers.StringToSlice(p.cfg.PkgOpts.OptionalComponents) - partialRemove := len(requestedComponents) > 0 && requestedComponents[0] != "" - var packageName string // we do not want to allow removal of signed packages without a signature if there are remove actions @@ -48,26 +44,25 @@ func (p *Packager) Remove() (err error) { p.filterComponents() packageName = p.cfg.Pkg.Metadata.Name - // If we have package components check them for images, charts, manifests, etc - for _, component := range p.cfg.Pkg.Components { - // Flip requested based on if this is a partial removal - requested := !partialRemove + // Build a list of components to remove and determine if we need a cluster connection + componentsToRemove := []string{} + packageRequiresCluster := false - if slices.Contains(requestedComponents, component.Name) { - requested = true - } + // If components were provided; just remove the things we were asked to remove + p.forIncludedComponents(func(component types.ZarfComponent) error { + componentsToRemove = append(componentsToRemove, component.Name) - if requested { - if p.requiresCluster(component) { - requiresCluster = true - } + if requiresCluster(component) { + packageRequiresCluster = true } - } - // Get the secret for the deployed package + return nil + }) + + // Get or build the secret for the deployed package deployedPackage := &types.DeployedPackage{} - if requiresCluster { + if packageRequiresCluster { err = p.connectToCluster(cluster.DefaultTimeout) if err != nil { return err @@ -80,25 +75,19 @@ func (p *Packager) Remove() (err error) { // If we do not need the cluster, create a deployed components object based on the info we have deployedPackage.Name = packageName deployedPackage.Data = p.cfg.Pkg - if partialRemove { - for _, r := range requestedComponents { - deployedPackage.DeployedComponents = append(deployedPackage.DeployedComponents, types.DeployedComponent{Name: r}) - } - } else { - for _, c := range p.cfg.Pkg.Components { - deployedPackage.DeployedComponents = append(deployedPackage.DeployedComponents, types.DeployedComponent{Name: c.Name}) - } + for _, r := range componentsToRemove { + deployedPackage.DeployedComponents = append(deployedPackage.DeployedComponents, types.DeployedComponent{Name: r}) } } - for _, c := range helpers.Reverse(deployedPackage.DeployedComponents) { + for _, dc := range helpers.Reverse(deployedPackage.DeployedComponents) { // Only remove the component if it was requested or if we are removing the whole package - if partialRemove && !slices.Contains(requestedComponents, c.Name) { + if !slices.Contains(componentsToRemove, dc.Name) { continue } - if deployedPackage, err = p.removeComponent(deployedPackage, c, spinner); err != nil { - return fmt.Errorf("unable to remove the component '%s': %w", c.Name, err) + if deployedPackage, err = p.removeComponent(deployedPackage, dc, spinner); err != nil { + return fmt.Errorf("unable to remove the component '%s': %w", dc.Name, err) } } diff --git a/src/pkg/packager/sources/cluster.go b/src/pkg/packager/sources/cluster.go index a623b85b27..4718092474 100644 --- a/src/pkg/packager/sources/cluster.go +++ b/src/pkg/packager/sources/cluster.go @@ -16,13 +16,13 @@ import ( ) var ( - // veryify that ClusterSource implements PackageSource + // verify that ClusterSource implements PackageSource _ PackageSource = (*ClusterSource)(nil) ) // NewClusterSource creates a new cluster source. func NewClusterSource(pkgOpts *types.ZarfPackageOptions) (PackageSource, error) { - if !validate.IsLowercaseNumberHyphen(pkgOpts.PackageSource) { + if !validate.IsLowercaseNumberHyphenNoStartHyphen(pkgOpts.PackageSource) { return nil, fmt.Errorf("invalid package name %q", pkgOpts.PackageSource) } cluster, err := cluster.NewClusterWithWait(cluster.DefaultTimeout) diff --git a/src/test/e2e/00_use_cli_test.go b/src/test/e2e/00_use_cli_test.go index 82dd20465c..2eaf6a6ed8 100644 --- a/src/test/e2e/00_use_cli_test.go +++ b/src/test/e2e/00_use_cli_test.go @@ -108,6 +108,23 @@ func TestUseCLI(t *testing.T) { require.Error(t, err) }) + t.Run("zarf deploy should return a warning when no components are deployed", func(t *testing.T) { + t.Parallel() + _, _, err := e2e.Zarf("package", "create", "src/test/packages/00-no-components", "-o=build", "--confirm") + require.NoError(t, err) + path := fmt.Sprintf("build/zarf-package-no-components-%s.tar.zst", e2e.Arch) + + // Test that excluding all components with a leading dash results in a warning + _, stdErr, err := e2e.Zarf("package", "deploy", path, "--components=-deselect-me", "--confirm") + require.NoError(t, err) + require.Contains(t, stdErr, "No components were selected for deployment") + + // Test that excluding still works even if a wildcard is given + _, stdErr, err = e2e.Zarf("package", "deploy", path, "--components=*,-deselect-me", "--confirm") + require.NoError(t, err) + require.NotContains(t, stdErr, "DESELECT-ME COMPONENT") + }) + t.Run("changing log level", func(t *testing.T) { t.Parallel() // Test that changing the log level actually applies the requested level diff --git a/src/test/e2e/22_git_and_gitops_test.go b/src/test/e2e/22_git_and_gitops_test.go index e09280a5b4..6f0e7efc55 100644 --- a/src/test/e2e/22_git_and_gitops_test.go +++ b/src/test/e2e/22_git_and_gitops_test.go @@ -31,8 +31,8 @@ func TestGit(t *testing.T) { path := fmt.Sprintf("build/zarf-package-git-data-test-%s-1.0.0.tar.zst", e2e.Arch) defer e2e.CleanFiles(path) - // Deploy the git data example - stdOut, stdErr, err = e2e.Zarf("package", "deploy", path, "--confirm") + // Deploy the git data example (with component globbing to test that as well) + stdOut, stdErr, err = e2e.Zarf("package", "deploy", path, "--components=full-repo,specific-*", "--confirm") require.NoError(t, err, stdOut, stdErr) c, err := cluster.NewCluster() diff --git a/src/test/packages/00-no-components/zarf.yaml b/src/test/packages/00-no-components/zarf.yaml new file mode 100644 index 0000000000..78d89b650f --- /dev/null +++ b/src/test/packages/00-no-components/zarf.yaml @@ -0,0 +1,9 @@ +kind: ZarfPackageConfig +metadata: + name: no-components + +components: +- name: deselect-me + default: true + +- name: optional diff --git a/src/test/packages/28-helm-no-wait/zarf.yaml b/src/test/packages/28-helm-no-wait/zarf.yaml index 86eac186fd..177fe6792a 100644 --- a/src/test/packages/28-helm-no-wait/zarf.yaml +++ b/src/test/packages/28-helm-no-wait/zarf.yaml @@ -4,7 +4,7 @@ metadata: description: Deploys a pod which never becomes ready components: - - name: zarf-helm-no-wait + - name: helm-no-wait required: true manifests: - name: never-ready diff --git a/src/types/component.go b/src/types/component.go index 36fa1d529c..f63e6ab2a7 100644 --- a/src/types/component.go +++ b/src/types/component.go @@ -13,7 +13,7 @@ import ( // ZarfComponent is the primary functional grouping of assets to deploy by Zarf. type ZarfComponent struct { // Name is the unique identifier for this component - Name string `json:"name" jsonschema:"description=The name of the component,pattern=^[a-z0-9\\-]+$"` + Name string `json:"name" jsonschema:"description=The name of the component,pattern=^[a-z0-9\\-]*[a-z0-9]$"` // Description is a message given to a user when deciding to enable this component or not Description string `json:"description,omitempty" jsonschema:"description=Message to include during package deploy describing the purpose of this component"` diff --git a/src/types/package.go b/src/types/package.go index 6b44fe8a16..bb906f4fc9 100644 --- a/src/types/package.go +++ b/src/types/package.go @@ -26,7 +26,7 @@ type ZarfPackage struct { // ZarfMetadata lists information about the current ZarfPackage. type ZarfMetadata struct { - Name string `json:"name" jsonschema:"description=Name to identify this Zarf package,pattern=^[a-z0-9\\-]+$"` + Name string `json:"name" jsonschema:"description=Name to identify this Zarf package,pattern=^[a-z0-9\\-]*[a-z0-9]$"` Description string `json:"description,omitempty" jsonschema:"description=Additional information about this package"` Version string `json:"version,omitempty" jsonschema:"description=Generic string set by a package author to track the package version (Note: ZarfInitConfigs will always be versioned to the CLIVersion they were created with)"` URL string `json:"url,omitempty" jsonschema:"description=Link to package information when online"` diff --git a/zarf.schema.json b/zarf.schema.json index d8d1b5c3ac..dee40d33aa 100644 --- a/zarf.schema.json +++ b/zarf.schema.json @@ -207,7 +207,7 @@ ], "properties": { "name": { - "pattern": "^[a-z0-9\\-]+$", + "pattern": "^[a-z0-9\\-]*[a-z0-9]$", "type": "string", "description": "The name of the component" }, @@ -832,7 +832,7 @@ ], "properties": { "name": { - "pattern": "^[a-z0-9\\-]+$", + "pattern": "^[a-z0-9\\-]*[a-z0-9]$", "type": "string", "description": "Name to identify this Zarf package" },