diff --git a/go.mod b/go.mod index 82a0b1ab00..88f908dc5d 100644 --- a/go.mod +++ b/go.mod @@ -39,7 +39,6 @@ require ( github.com/opencontainers/image-spec v1.1.0 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.18.0 - github.com/prometheus/prometheus v0.47.2 github.com/pterm/pterm v0.12.79 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 github.com/sigstore/cosign/v2 v2.2.3 @@ -78,12 +77,10 @@ require ( github.com/go-logr/logr v1.4.2 // indirect github.com/gofrs/uuid v4.2.0+incompatible // indirect github.com/gorilla/handlers v1.5.1 // indirect - github.com/grafana/regexp v0.0.0-20221122212121-6b5c0a4cb7fd // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect github.com/hashicorp/golang-lru/arc/v2 v2.0.5 // indirect github.com/hashicorp/golang-lru/v2 v2.0.5 // indirect - github.com/jpillora/backoff v1.0.0 // indirect - github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect + github.com/onsi/gomega v1.32.0 // indirect github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 // indirect github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 // indirect github.com/redis/go-redis/v9 v9.3.0 // indirect @@ -433,7 +430,7 @@ require ( github.com/pkg/profile v1.7.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.5.0 // indirect - github.com/prometheus/common v0.45.0 + github.com/prometheus/common v0.45.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect github.com/protocolbuffers/txtpbfmt v0.0.0-20231025115547-084445ff1adf // indirect github.com/rakyll/hey v0.1.4 // indirect @@ -531,7 +528,7 @@ require ( gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - gopkg.in/yaml.v2 v2.4.0 + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/gorm v1.25.5 // indirect k8s.io/apiextensions-apiserver v0.30.0 // indirect diff --git a/go.sum b/go.sum index d301432f4a..2c9de98304 100644 --- a/go.sum +++ b/go.sum @@ -1022,8 +1022,6 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= -github.com/grafana/regexp v0.0.0-20221122212121-6b5c0a4cb7fd h1:PpuIBO5P3e9hpqBD0O/HjhShYuM6XE0i/lbE6J94kww= -github.com/grafana/regexp v0.0.0-20221122212121-6b5c0a4cb7fd/go.mod h1:M5qHK+eWfAv8VR/265dIuEpL3fNfeC21tXXp9itM24A= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 h1:pdN6V1QBWetyv/0+wjACpqVH+eVULgEjkurDLq3goeM= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= @@ -1144,8 +1142,6 @@ github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= -github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -1356,8 +1352,6 @@ github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1n github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= @@ -1475,8 +1469,6 @@ github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+Gx github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= -github.com/prometheus/prometheus v0.47.2 h1:jWcnuQHz1o1Wu3MZ6nMJDuTI0kU5yJp9pkxh8XEkNvI= -github.com/prometheus/prometheus v0.47.2/go.mod h1:J/bmOSjgH7lFxz2gZhrWEZs2i64vMS+HIuZfmYNhJ/M= github.com/protocolbuffers/txtpbfmt v0.0.0-20231025115547-084445ff1adf h1:014O62zIzQwvoD7Ekj3ePDF5bv9Xxy0w6AZk0qYbjUk= github.com/protocolbuffers/txtpbfmt v0.0.0-20231025115547-084445ff1adf/go.mod h1:jgxiZysxFPM+iWKwQwPR+y+Jvo54ARd4EisXxKYpB5c= github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI= diff --git a/site/src/content/docs/commands/zarf_dev_deploy.md b/site/src/content/docs/commands/zarf_dev_deploy.md index 29293c1d9a..41ee7f0b85 100644 --- a/site/src/content/docs/commands/zarf_dev_deploy.md +++ b/site/src/content/docs/commands/zarf_dev_deploy.md @@ -31,7 +31,7 @@ zarf dev deploy [flags] --registry-override stringToString Specify a map of domains to override on package create when pulling images (e.g. --registry-override docker.io=dockerio-reg.enterprise.intranet) (default []) --retries int Number of retries to perform for Zarf deploy operations like git/image pushes or Helm installs (default 3) --skip-webhooks [alpha] Skip waiting for external webhooks to execute as each package component is deployed - --timeout duration Timeout for Helm operations such as installs and rollbacks (default 15m0s) + --timeout duration Timeout for health checks and Helm operations such as installs and rollbacks (default 15m0s) ``` ### Options inherited from parent commands diff --git a/site/src/content/docs/commands/zarf_init.md b/site/src/content/docs/commands/zarf_init.md index 8fc88244c3..5702caa72b 100644 --- a/site/src/content/docs/commands/zarf_init.md +++ b/site/src/content/docs/commands/zarf_init.md @@ -78,7 +78,7 @@ $ zarf init --artifact-push-password={PASSWORD} --artifact-push-username={USERNA --set stringToString Specify deployment variables to set on the command line (KEY=value) (default []) --skip-webhooks [alpha] Skip waiting for external webhooks to execute as each package component is deployed --storage-class string Specify the storage class to use for the registry and git server. E.g. --storage-class=standard - --timeout duration Timeout for Helm operations such as installs and rollbacks (default 15m0s) + --timeout duration Timeout for health checks and Helm operations such as installs and rollbacks (default 15m0s) ``` ### Options inherited from parent commands diff --git a/site/src/content/docs/commands/zarf_package_deploy.md b/site/src/content/docs/commands/zarf_package_deploy.md index 07f2fa46de..d89b0f1bbc 100644 --- a/site/src/content/docs/commands/zarf_package_deploy.md +++ b/site/src/content/docs/commands/zarf_package_deploy.md @@ -30,7 +30,7 @@ zarf package deploy [ PACKAGE_SOURCE ] [flags] --set stringToString Specify deployment variables to set on the command line (KEY=value) (default []) --shasum string Shasum of the package to deploy. Required if deploying a remote package and "--insecure" is not provided --skip-webhooks [alpha] Skip waiting for external webhooks to execute as each package component is deployed - --timeout duration Timeout for Helm operations such as installs and rollbacks (default 15m0s) + --timeout duration Timeout for health checks and Helm operations such as installs and rollbacks (default 15m0s) ``` ### Options inherited from parent commands diff --git a/site/src/content/docs/ref/components.mdx b/site/src/content/docs/ref/components.mdx index 50be327f1b..419d43bc6d 100644 --- a/site/src/content/docs/ref/components.mdx +++ b/site/src/content/docs/ref/components.mdx @@ -267,6 +267,24 @@ When merging components together Zarf will adopt the following strategies depend +### Health Checks + + + +Health checks wait until the specified resources are fully reconciled, meaning that their desired and current states match. Internally, [kstatus](https://github.com/kubernetes-sigs/cli-utils/blob/master/pkg/kstatus/README.md#kstatus) is used to assess when reconciliation is complete. Health checks supports all Kubernetes resources that implement the [status](https://kubernetes.io/docs/concepts/overview/working-with-objects/#object-spec-and-status) field, including custom resource definitions. If the status field is not implemented on a resource, it will automatically pass the health check. + +```yaml + healthChecks: + - name: my-pod + namespace: my-namespace + apiVersion: v1 + kind: Pod + - name: my-stateful-set + namespace: my-namespace + apiVersion: apps/v1 + kind: StatefulSet +``` + ## Deploying Components When deploying a Zarf package, components are deployed in the order they are defined in the `zarf.yaml`. diff --git a/src/api/v1alpha1/component.go b/src/api/v1alpha1/component.go index 9739de8644..9827410b43 100644 --- a/src/api/v1alpha1/component.go +++ b/src/api/v1alpha1/component.go @@ -61,6 +61,21 @@ type ZarfComponent struct { // Custom commands to run at various stages of a package lifecycle. Actions ZarfComponentActions `json:"actions,omitempty"` + + // List of resources to health check after deployment + HealthChecks []NamespacedObjectKindReference `json:"healthChecks,omitempty"` +} + +// NamespacedObjectKindReference is a reference to a specific resource in a namespace using its kind and API version. +type NamespacedObjectKindReference struct { + // API Version of the resource + APIVersion string `json:"apiVersion"` + // Kind of the resource + Kind string `json:"kind"` + // Namespace of the resource + Namespace string `json:"namespace"` + // Name of the resource + Name string `json:"name"` } // RequiresCluster returns if the component requires a cluster connection to deploy. @@ -70,8 +85,9 @@ func (c ZarfComponent) RequiresCluster() bool { hasManifests := len(c.Manifests) > 0 hasRepos := len(c.Repos) > 0 hasDataInjections := len(c.DataInjections) > 0 + hasHealthChecks := len(c.HealthChecks) > 0 - if hasImages || hasCharts || hasManifests || hasRepos || hasDataInjections { + if hasImages || hasCharts || hasManifests || hasRepos || hasDataInjections || hasHealthChecks { return true } diff --git a/src/api/v1beta1/component.go b/src/api/v1beta1/component.go index 2d27777c54..aa8ec9bff4 100644 --- a/src/api/v1beta1/component.go +++ b/src/api/v1beta1/component.go @@ -49,6 +49,21 @@ type ZarfComponent struct { // Custom commands to run at various stages of a package lifecycle. Actions ZarfComponentActions `json:"actions,omitempty"` + + // List of resources to health check after deployment + HealthChecks []NamespacedObjectKindReference `json:"healthChecks,omitempty"` +} + +// NamespacedObjectKindReference is a reference to a specific resource in a namespace using its kind and API version. +type NamespacedObjectKindReference struct { + // API Version of the resource + APIVersion string `json:"apiVersion"` + // Kind of the resource + Kind string `json:"kind"` + // Namespace of the resource + Namespace string `json:"namespace"` + // Name of the resource + Name string `json:"name"` } // RequiresCluster returns if the component requires a cluster connection to deploy. diff --git a/src/cmd/common/table.go b/src/cmd/common/table.go new file mode 100644 index 0000000000..ee5f9ee190 --- /dev/null +++ b/src/cmd/common/table.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +package common + +import ( + "fmt" + "path/filepath" + + "github.com/defenseunicorns/pkg/helpers/v2" + "github.com/fatih/color" + + "github.com/zarf-dev/zarf/src/pkg/lint" + "github.com/zarf-dev/zarf/src/pkg/message" +) + +// PrintFindings prints the findings in the LintError as a table. +func PrintFindings(lintErr *lint.LintError) { + mapOfFindingsByPath := lint.GroupFindingsByPath(lintErr.Findings, lintErr.PackageName) + for _, findings := range mapOfFindingsByPath { + lintData := [][]string{} + for _, finding := range findings { + sevColor := color.FgWhite + switch finding.Severity { + case lint.SevErr: + sevColor = color.FgRed + case lint.SevWarn: + sevColor = color.FgYellow + } + + lintData = append(lintData, []string{ + colorWrap(string(finding.Severity), sevColor), + colorWrap(finding.YqPath, color.FgCyan), + finding.ItemizedDescription(), + }) + } + var packagePathFromUser string + if helpers.IsOCIURL(findings[0].PackagePathOverride) { + packagePathFromUser = findings[0].PackagePathOverride + } else { + packagePathFromUser = filepath.Join(lintErr.BaseDir, findings[0].PackagePathOverride) + } + message.Notef("Linting package %q at %s", findings[0].PackageNameOverride, packagePathFromUser) + message.Table([]string{"Type", "Path", "Message"}, lintData) + } +} + +func colorWrap(str string, attr color.Attribute) string { + if !message.ColorEnabled() || str == "" { + return str + } + return fmt.Sprintf("\x1b[%dm%s\x1b[0m", attr, str) +} diff --git a/src/cmd/dev.go b/src/cmd/dev.go index 322b82fb84..7077f5dccd 100644 --- a/src/cmd/dev.go +++ b/src/cmd/dev.go @@ -59,7 +59,12 @@ var devDeployCmd = &cobra.Command{ } defer pkgClient.ClearTempPaths() - if err := pkgClient.DevDeploy(cmd.Context()); err != nil { + err = pkgClient.DevDeploy(cmd.Context()) + var lintErr *lint.LintError + if errors.As(err, &lintErr) { + common.PrintFindings(lintErr) + } + if err != nil { return fmt.Errorf("failed to dev deploy: %w", err) } return nil @@ -235,7 +240,12 @@ var devFindImagesCmd = &cobra.Command{ } defer pkgClient.ClearTempPaths() - if _, err := pkgClient.FindImages(cmd.Context()); err != nil { + _, err = pkgClient.FindImages(cmd.Context()) + var lintErr *lint.LintError + if errors.As(err, &lintErr) { + common.PrintFindings(lintErr) + } + if err != nil { return fmt.Errorf("unable to find images: %w", err) } return nil @@ -282,7 +292,19 @@ var devLintCmd = &cobra.Command{ } defer pkgClient.ClearTempPaths() - return lint.Validate(cmd.Context(), pkgConfig.CreateOpts) + err = lint.Validate(cmd.Context(), pkgConfig.CreateOpts) + var lintErr *lint.LintError + if errors.As(err, &lintErr) { + common.PrintFindings(lintErr) + // Do not return an error if the findings are all warnings. + if lintErr.OnlyWarnings() { + return nil + } + } + if err != nil { + return err + } + return nil }, } diff --git a/src/cmd/package.go b/src/cmd/package.go index 8b1405e25c..a40439d53f 100644 --- a/src/cmd/package.go +++ b/src/cmd/package.go @@ -14,6 +14,7 @@ import ( "github.com/zarf-dev/zarf/src/cmd/common" "github.com/zarf-dev/zarf/src/config/lang" + "github.com/zarf-dev/zarf/src/pkg/lint" "github.com/zarf-dev/zarf/src/pkg/message" "github.com/zarf-dev/zarf/src/pkg/packager/sources" "github.com/zarf-dev/zarf/src/types" @@ -60,7 +61,12 @@ var packageCreateCmd = &cobra.Command{ } defer pkgClient.ClearTempPaths() - if err := pkgClient.Create(cmd.Context()); err != nil { + err = pkgClient.Create(cmd.Context()) + var lintErr *lint.LintError + if errors.As(err, &lintErr) { + common.PrintFindings(lintErr) + } + if err != nil { return fmt.Errorf("failed to create package: %w", err) } return nil diff --git a/src/config/lang/english.go b/src/config/lang/english.go index 50ce790c44..1afdfab83c 100644 --- a/src/config/lang/english.go +++ b/src/config/lang/english.go @@ -276,7 +276,7 @@ $ zarf package mirror-resources \ 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" - CmdPackageDeployFlagTimeout = "Timeout for Helm operations such as installs and rollbacks" + CmdPackageDeployFlagTimeout = "Timeout for health checks and Helm operations such as installs and rollbacks" CmdPackageDeployValidateArchitectureErr = "this package architecture is %s, but the target cluster only has the %s architecture(s). These architectures must be compatible when \"images\" are present" CmdPackageDeployValidateLastNonBreakingVersionWarn = "The version of this Zarf binary '%s' is less than the LastNonBreakingVersion of '%s'. You may need to upgrade your Zarf version to at least '%s' to deploy this package" 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." diff --git a/src/pkg/lint/findings.go b/src/pkg/lint/findings.go index a8ad9b5eac..92486f77d5 100644 --- a/src/pkg/lint/findings.go +++ b/src/pkg/lint/findings.go @@ -6,11 +6,15 @@ package lint import ( "fmt" - "path/filepath" +) + +// Severity is the type of finding. +type Severity string - "github.com/defenseunicorns/pkg/helpers/v2" - "github.com/fatih/color" - "github.com/zarf-dev/zarf/src/pkg/message" +// Severity definitions. +const ( + SevErr = "Error" + SevWarn = "Warning" ) // PackageFinding is a struct that contains a finding about something wrong with a package @@ -26,71 +30,18 @@ type PackageFinding struct { // PackagePathOverride shows the path to the package that the error originated from // If it is not set the base package will be used when displaying the error PackagePathOverride string - Severity Severity + // Severity of finding. + Severity Severity } -// Severity is the type of finding -type Severity int - -// different severities of package errors -const ( - SevErr Severity = iota + 1 - SevWarn -) - -func (f PackageFinding) itemizedDescription() string { +// ItemizedDescription returns a string with the description and item if finding contains one. +func (f PackageFinding) ItemizedDescription() string { if f.Item == "" { return f.Description } return fmt.Sprintf("%s - %s", f.Description, f.Item) } -func colorWrapSev(s Severity) string { - if s == SevErr { - return message.ColorWrap("Error", color.FgRed) - } else if s == SevWarn { - return message.ColorWrap("Warning", color.FgYellow) - } - return "unknown" -} - -func filterLowerSeverity(findings []PackageFinding, severity Severity) []PackageFinding { - findings = helpers.RemoveMatches(findings, func(finding PackageFinding) bool { - return finding.Severity > severity - }) - return findings -} - -// PrintFindings prints the findings of the given severity in a table -func PrintFindings(findings []PackageFinding, severity Severity, baseDir string, packageName string) { - findings = filterLowerSeverity(findings, severity) - if len(findings) == 0 { - return - } - mapOfFindingsByPath := GroupFindingsByPath(findings, packageName) - - header := []string{"Type", "Path", "Message"} - - for _, findings := range mapOfFindingsByPath { - lintData := [][]string{} - for _, finding := range findings { - lintData = append(lintData, []string{ - colorWrapSev(finding.Severity), - message.ColorWrap(finding.YqPath, color.FgCyan), - finding.itemizedDescription(), - }) - } - var packagePathFromUser string - if helpers.IsOCIURL(findings[0].PackagePathOverride) { - packagePathFromUser = findings[0].PackagePathOverride - } else { - packagePathFromUser = filepath.Join(baseDir, findings[0].PackagePathOverride) - } - message.Notef("Linting package %q at %s", findings[0].PackageNameOverride, packagePathFromUser) - message.Table(header, lintData) - } -} - // GroupFindingsByPath groups findings by their package path func GroupFindingsByPath(findings []PackageFinding, packageName string) map[string][]PackageFinding { for i := range findings { @@ -108,8 +59,3 @@ func GroupFindingsByPath(findings []PackageFinding, packageName string) map[stri } return mapOfFindingsByPath } - -// HasSevOrHigher returns true if the findings contain a severity equal to or greater than the given severity -func HasSevOrHigher(findings []PackageFinding, severity Severity) bool { - return len(filterLowerSeverity(findings, severity)) > 0 -} diff --git a/src/pkg/lint/findings_test.go b/src/pkg/lint/findings_test.go index f3c09673c8..b922927b1a 100644 --- a/src/pkg/lint/findings_test.go +++ b/src/pkg/lint/findings_test.go @@ -53,53 +53,3 @@ func TestGroupFindingsByPath(t *testing.T) { }) } } - -func TestHasSeverity(t *testing.T) { - t.Parallel() - tests := []struct { - name string - severity Severity - expected bool - findings []PackageFinding - }{ - { - name: "error severity present", - findings: []PackageFinding{ - { - Severity: SevErr, - }, - }, - severity: SevErr, - expected: true, - }, - { - name: "error severity not present", - findings: []PackageFinding{ - { - Severity: SevWarn, - }, - }, - severity: SevErr, - expected: false, - }, - { - name: "err and warning severity present", - findings: []PackageFinding{ - { - Severity: SevWarn, - }, - { - Severity: SevErr, - }, - }, - severity: SevErr, - expected: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - require.Equal(t, tt.expected, HasSevOrHigher(tt.findings, tt.severity)) - }) - } -} diff --git a/src/pkg/lint/lint.go b/src/pkg/lint/lint.go index 3c4be87eaf..344f3b9db0 100644 --- a/src/pkg/lint/lint.go +++ b/src/pkg/lint/lint.go @@ -6,7 +6,6 @@ package lint import ( "context" - "errors" "fmt" "os" @@ -14,12 +13,34 @@ import ( "github.com/zarf-dev/zarf/src/config" "github.com/zarf-dev/zarf/src/config/lang" "github.com/zarf-dev/zarf/src/pkg/layout" - "github.com/zarf-dev/zarf/src/pkg/message" "github.com/zarf-dev/zarf/src/pkg/packager/composer" "github.com/zarf-dev/zarf/src/pkg/utils" "github.com/zarf-dev/zarf/src/types" ) +// LintError represents an error containing lint findings. +// +//nolint:revive // ignore name +type LintError struct { + BaseDir string + PackageName string + Findings []PackageFinding +} + +func (e *LintError) Error() string { + return fmt.Sprintf("linting error found %d instance(s)", len(e.Findings)) +} + +// OnlyWarnings returns true if all findings have severity warning. +func (e *LintError) OnlyWarnings() bool { + for _, f := range e.Findings { + if f.Severity == SevErr { + return false + } + } + return true +} + // Validate lints the given Zarf package func Validate(ctx context.Context, createOpts types.ZarfCreateOptions) error { var findings []PackageFinding @@ -41,16 +62,14 @@ func Validate(ctx context.Context, createOpts types.ZarfCreateOptions) error { return err } findings = append(findings, schemaFindings...) - if len(findings) == 0 { - message.Successf("0 findings for %q", pkg.Metadata.Name) return nil } - PrintFindings(findings, SevWarn, createOpts.BaseDir, pkg.Metadata.Name) - if HasSevOrHigher(findings, SevErr) { - return errors.New("errors during lint") + return &LintError{ + BaseDir: createOpts.BaseDir, + PackageName: pkg.Metadata.Name, + Findings: findings, } - return nil } func lintComponents(ctx context.Context, pkg v1alpha1.ZarfPackage, createOpts types.ZarfCreateOptions) ([]PackageFinding, error) { diff --git a/src/pkg/lint/lint_test.go b/src/pkg/lint/lint_test.go index d499ba6e45..84ea5e4cd2 100644 --- a/src/pkg/lint/lint_test.go +++ b/src/pkg/lint/lint_test.go @@ -15,6 +15,33 @@ import ( "github.com/zarf-dev/zarf/src/types" ) +func TestLintError(t *testing.T) { + t.Parallel() + + lintErr := &LintError{ + Findings: []PackageFinding{ + { + Severity: SevWarn, + }, + }, + } + require.Equal(t, "linting error found 1 instance(s)", lintErr.Error()) + require.True(t, lintErr.OnlyWarnings()) + + lintErr = &LintError{ + Findings: []PackageFinding{ + { + Severity: SevWarn, + }, + { + Severity: SevErr, + }, + }, + } + require.Equal(t, "linting error found 2 instance(s)", lintErr.Error()) + require.False(t, lintErr.OnlyWarnings()) +} + func TestLintComponents(t *testing.T) { t.Run("Test composable components with bad path", func(t *testing.T) { t.Parallel() diff --git a/src/pkg/message/message.go b/src/pkg/message/message.go index e271cbf9d4..0eea8b326c 100644 --- a/src/pkg/message/message.go +++ b/src/pkg/message/message.go @@ -12,7 +12,6 @@ import ( "time" "github.com/defenseunicorns/pkg/helpers/v2" - "github.com/fatih/color" "github.com/pterm/pterm" ) @@ -276,16 +275,6 @@ func Table(header []string, data [][]string) { pterm.DefaultTable.WithHasHeader().WithData(table).Render() } -// ColorWrap changes a string to an ansi color code and appends the default color to the end -// preventing future characters from taking on the given color -// returns string as normal if color is disabled -func ColorWrap(str string, attr color.Attribute) string { - if !ColorEnabled() || str == "" { - return str - } - return fmt.Sprintf("\x1b[%dm%s\x1b[0m", attr, str) -} - func debugPrinter(offset int, a ...any) { printer := pterm.Debug.WithShowLineNumber(logLevel > 2).WithLineNumberOffset(offset) now := time.Now().Format(time.RFC3339) diff --git a/src/pkg/packager/composer/list.go b/src/pkg/packager/composer/list.go index 291fdb71db..b021355739 100644 --- a/src/pkg/packager/composer/list.go +++ b/src/pkg/packager/composer/list.go @@ -337,6 +337,7 @@ func (ic *ImportChain) Compose(ctx context.Context) (composed *v1alpha1.ZarfComp overrideDeprecated(composed, node.ZarfComponent) overrideResources(composed, node.ZarfComponent) overrideActions(composed, node.ZarfComponent) + composed.HealthChecks = append(composed.HealthChecks, node.ZarfComponent.HealthChecks...) bigbang.Compose(composed, node.ZarfComponent, node.relativeToHead) diff --git a/src/pkg/packager/composer/list_test.go b/src/pkg/packager/composer/list_test.go index 1703348b3d..9cbeab2a3b 100644 --- a/src/pkg/packager/composer/list_test.go +++ b/src/pkg/packager/composer/list_test.go @@ -82,6 +82,50 @@ func TestCompose(t *testing.T) { Name: "no-import", }, }, + { + name: "Health Checks", + ic: createChainFromSlice(t, []v1alpha1.ZarfComponent{ + { + Name: "base", + HealthChecks: []v1alpha1.NamespacedObjectKindReference{ + { + APIVersion: "v1", + Kind: "Pods", + Namespace: "base-ns", + Name: "base-pod", + }, + }, + }, + { + Name: "import-one", + HealthChecks: []v1alpha1.NamespacedObjectKindReference{ + { + APIVersion: "v1", + Kind: "Pods", + Namespace: "import-ns", + Name: "import-pod", + }, + }, + }, + }), + expectedComposed: v1alpha1.ZarfComponent{ + Name: "base", + HealthChecks: []v1alpha1.NamespacedObjectKindReference{ + { + APIVersion: "v1", + Kind: "Pods", + Namespace: "import-ns", + Name: "import-pod", + }, + { + APIVersion: "v1", + Kind: "Pods", + Namespace: "base-ns", + Name: "base-pod", + }, + }, + }, + }, { name: "Multiple Components", ic: createChainFromSlice(t, []v1alpha1.ZarfComponent{ diff --git a/src/pkg/packager/creator/creator_test.go b/src/pkg/packager/creator/creator_test.go index 95803cc0f4..6d87d09aac 100644 --- a/src/pkg/packager/creator/creator_test.go +++ b/src/pkg/packager/creator/creator_test.go @@ -35,7 +35,7 @@ func TestLoadPackageDefinition(t *testing.T) { { name: "invalid package definition", testDir: "invalid", - expectedErr: "found errors in schema", + expectedErr: "linting error found 1 instance(s)", creator: NewPackageCreator(types.ZarfCreateOptions{}, ""), }, { @@ -47,7 +47,7 @@ func TestLoadPackageDefinition(t *testing.T) { { name: "invalid package definition", testDir: "invalid", - expectedErr: "found errors in schema", + expectedErr: "linting error found 1 instance(s)", creator: NewSkeletonCreator(types.ZarfCreateOptions{}, types.ZarfPublishOptions{}), }, } diff --git a/src/pkg/packager/creator/utils.go b/src/pkg/packager/creator/utils.go index 1bb88750e0..3d5c5ef016 100644 --- a/src/pkg/packager/creator/utils.go +++ b/src/pkg/packager/creator/utils.go @@ -23,18 +23,18 @@ func Validate(pkg v1alpha1.ZarfPackage, baseDir string, setVariables map[string] if err := lint.ValidatePackage(pkg); err != nil { return fmt.Errorf("package validation failed: %w", err) } - findings, err := lint.ValidatePackageSchema(setVariables) if err != nil { return fmt.Errorf("unable to check schema: %w", err) } - - if lint.HasSevOrHigher(findings, lint.SevErr) { - lint.PrintFindings(findings, lint.SevErr, baseDir, pkg.Metadata.Name) - return fmt.Errorf("found errors in schema") + if len(findings) == 0 { + return nil + } + return &lint.LintError{ + BaseDir: baseDir, + PackageName: pkg.Metadata.Name, + Findings: findings, } - - return nil } // recordPackageMetadata records various package metadata during package create. diff --git a/src/pkg/packager/deploy.go b/src/pkg/packager/deploy.go index 517bff7d60..ee3796ac6c 100644 --- a/src/pkg/packager/deploy.go +++ b/src/pkg/packager/deploy.go @@ -19,11 +19,15 @@ import ( "golang.org/x/sync/errgroup" "github.com/avast/retry-go/v4" - "github.com/defenseunicorns/pkg/helpers/v2" + pkgkubernetes "github.com/defenseunicorns/pkg/kubernetes" corev1 "k8s.io/api/core/v1" kerrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/cli-utils/pkg/kstatus/watcher" + "sigs.k8s.io/cli-utils/pkg/object" + "github.com/defenseunicorns/pkg/helpers/v2" "github.com/zarf-dev/zarf/src/api/v1alpha1" "github.com/zarf-dev/zarf/src/config" "github.com/zarf-dev/zarf/src/internal/git" @@ -234,6 +238,30 @@ func (p *Packager) deployComponents(ctx context.Context) ([]types.DeployedCompon return deployedComponents, nil } +func runHealthChecks(ctx context.Context, watcher watcher.StatusWatcher, healthChecks []v1alpha1.NamespacedObjectKindReference) error { + objs := []object.ObjMetadata{} + for _, hc := range healthChecks { + gv, err := schema.ParseGroupVersion(hc.APIVersion) + if err != nil { + return err + } + obj := object.ObjMetadata{ + GroupKind: schema.GroupKind{ + Group: gv.Group, + Kind: hc.Kind, + }, + Namespace: hc.Namespace, + Name: hc.Name, + } + objs = append(objs, obj) + } + err := pkgkubernetes.WaitForReady(ctx, watcher, objs) + if err != nil { + return err + } + return nil +} + func (p *Packager) deployInitComponent(ctx context.Context, component v1alpha1.ZarfComponent) ([]types.InstalledChart, error) { hasExternalRegistry := p.cfg.InitOpts.RegistryInfo.Address != "" isSeedRegistry := component.Name == "zarf-seed-registry" @@ -370,6 +398,17 @@ func (p *Packager) deployComponent(ctx context.Context, component v1alpha1.ZarfC return nil, fmt.Errorf("unable to run component after action: %w", err) } + if len(component.HealthChecks) > 0 { + healthCheckContext, cancel := context.WithTimeout(ctx, p.cfg.DeployOpts.Timeout) + defer cancel() + spinner := message.NewProgressSpinner("Running health checks") + defer spinner.Stop() + if err = runHealthChecks(healthCheckContext, p.cluster.Watcher, component.HealthChecks); err != nil { + return nil, fmt.Errorf("health checks failed: %w", err) + } + spinner.Success() + } + err = g.Wait() if err != nil { return nil, err diff --git a/src/pkg/packager/deploy_test.go b/src/pkg/packager/deploy_test.go index a1f32aa346..f5dba049e1 100644 --- a/src/pkg/packager/deploy_test.go +++ b/src/pkg/packager/deploy_test.go @@ -4,12 +4,22 @@ package packager import ( + "context" "testing" + "time" "github.com/stretchr/testify/require" "github.com/zarf-dev/zarf/src/api/v1alpha1" "github.com/zarf-dev/zarf/src/pkg/packager/sources" "github.com/zarf-dev/zarf/src/types" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/yaml" + dynamicfake "k8s.io/client-go/dynamic/fake" + "k8s.io/kubectl/pkg/scheme" + "sigs.k8s.io/cli-utils/pkg/kstatus/watcher" + "sigs.k8s.io/cli-utils/pkg/testutil" ) func TestGenerateValuesOverrides(t *testing.T) { @@ -272,3 +282,82 @@ func TestServiceInfoFromServiceURL(t *testing.T) { }) } } + +var podCurrentYaml = ` +apiVersion: v1 +kind: Pod +metadata: + name: good-pod + namespace: ns +status: + conditions: + - type: Ready + status: "True" + phase: Running +` + +var podYaml = ` +apiVersion: v1 +kind: Pod +metadata: + name: in-progress-pod + namespace: ns +` + +func yamlToUnstructured(t *testing.T, yml string) *unstructured.Unstructured { + t.Helper() + m := make(map[string]interface{}) + err := yaml.Unmarshal([]byte(yml), &m) + require.NoError(t, err) + return &unstructured.Unstructured{Object: m} +} + +func TestRunHealthChecks(t *testing.T) { + t.Parallel() + tests := []struct { + name string + podYaml string + expectErr error + }{ + { + name: "Pod is running", + podYaml: podCurrentYaml, + expectErr: nil, + }, + { + name: "Pod is never ready", + podYaml: podYaml, + expectErr: context.DeadlineExceeded, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + v1.SchemeGroupVersion.WithKind("Pod"), + ) + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + pod := yamlToUnstructured(t, tt.podYaml) + statusWatcher := watcher.NewDefaultStatusWatcher(fakeClient, fakeMapper) + podGVR := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"} + require.NoError(t, fakeClient.Tracker().Create(podGVR, pod, pod.GetNamespace())) + objs := []v1alpha1.NamespacedObjectKindReference{ + { + APIVersion: pod.GetAPIVersion(), + Kind: pod.GetKind(), + Namespace: pod.GetNamespace(), + Name: pod.GetName(), + }, + } + err := runHealthChecks(ctx, statusWatcher, objs) + if tt.expectErr != nil { + require.ErrorIs(t, err, tt.expectErr) + return + } + require.NoError(t, err) + }) + } +} diff --git a/src/pkg/packager/prepare.go b/src/pkg/packager/prepare.go index 37b80f0ec4..1e43335898 100644 --- a/src/pkg/packager/prepare.go +++ b/src/pkg/packager/prepare.go @@ -245,7 +245,6 @@ func (p *Packager) findImages(ctx context.Context) (map[string][]string, error) // Break the manifest into separate resources yamls, err := utils.SplitYAML(contents) if err != nil { - fmt.Println("got this err") return nil, err } resources = append(resources, yamls...) diff --git a/src/test/e2e/36_health_check_test.go b/src/test/e2e/36_health_check_test.go new file mode 100644 index 0000000000..3ad84162bd --- /dev/null +++ b/src/test/e2e/36_health_check_test.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +// Package test provides e2e tests for Zarf. +package test + +import ( + "fmt" + + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHealthChecks(t *testing.T) { + t.Log("E2E: Health Checks") + + _, _, err := e2e.Zarf(t, "package", "create", "src/test/packages/36-health-checks", "-o=build", "--confirm") + require.NoError(t, err) + + path := fmt.Sprintf("build/zarf-package-health-checks-%s.tar.zst", e2e.Arch) + + _, _, err = e2e.Zarf(t, "package", "deploy", path, "--confirm") + require.NoError(t, err) + + defer func() { + _, _, err = e2e.Zarf(t, "package", "remove", "health-checks", "--confirm") + require.NoError(t, err) + }() + + stdOut, _, err := e2e.Kubectl(t, "get", "pod", "ready-pod", "-n", "health-checks", "-o", "jsonpath={.status.phase}") + require.NoError(t, err) + require.Equal(t, "Running", stdOut) +} diff --git a/src/test/packages/36-health-checks/ready-pod.yaml b/src/test/packages/36-health-checks/ready-pod.yaml new file mode 100644 index 0000000000..836ce373a6 --- /dev/null +++ b/src/test/packages/36-health-checks/ready-pod.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Pod +metadata: + name: ready-pod +spec: + # Extra security to ensure the pod isn't ready before the health checks run + initContainers: + - name: init-wait + image: ghcr.io/stefanprodan/podinfo:6.4.0 + command: ["sh", "-c", "sleep 3"] + containers: + - name: podinfo + image: ghcr.io/stefanprodan/podinfo:6.4.0 diff --git a/src/test/packages/36-health-checks/zarf.yaml b/src/test/packages/36-health-checks/zarf.yaml new file mode 100644 index 0000000000..a34f125cd0 --- /dev/null +++ b/src/test/packages/36-health-checks/zarf.yaml @@ -0,0 +1,21 @@ +kind: ZarfPackageConfig +metadata: + name: health-checks + description: Deploys a simple pod to test health checks + +components: + - name: health-checks + required: true + manifests: + - name: ready-pod + namespace: health-checks + noWait: true + files: + - ready-pod.yaml + images: + - ghcr.io/stefanprodan/podinfo:6.4.0 + healthChecks: + - name: ready-pod + namespace: health-checks + apiVersion: v1 + kind: Pod diff --git a/zarf.schema.json b/zarf.schema.json index b1fca50208..d5067e42ad 100644 --- a/zarf.schema.json +++ b/zarf.schema.json @@ -169,6 +169,38 @@ "^x-": {} } }, + "NamespacedObjectKindReference": { + "properties": { + "apiVersion": { + "type": "string", + "description": "API Version of the resource" + }, + "kind": { + "type": "string", + "description": "Kind of the resource" + }, + "namespace": { + "type": "string", + "description": "Namespace of the resource" + }, + "name": { + "type": "string", + "description": "Name of the resource" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "apiVersion", + "kind", + "namespace", + "name" + ], + "description": "NamespacedObjectKindReference is a reference to a specific resource in a namespace using its kind and API version.", + "patternProperties": { + "^x-": {} + } + }, "Shell": { "properties": { "windows": { @@ -512,6 +544,13 @@ "actions": { "$ref": "#/$defs/ZarfComponentActions", "description": "Custom commands to run at various stages of a package lifecycle." + }, + "healthChecks": { + "items": { + "$ref": "#/$defs/NamespacedObjectKindReference" + }, + "type": "array", + "description": "List of resources to health check after deployment" } }, "additionalProperties": false,