From a31fa38c702f8213564a05646ff5a484ecc82e11 Mon Sep 17 00:00:00 2001 From: Shas Date: Sun, 15 Feb 2026 19:05:50 -0500 Subject: [PATCH 1/6] feat: Let zarf packages define requirements Signed-off-by: Shas --- examples/REQUIREMENTS/REQUIREMENTS | 34 +++ .../docs/commands/zarf_package_deploy.md | 1 + src/cmd/package.go | 3 + src/internal/packager/requirements/agent.go | 201 +++++++++++++ src/internal/packager/requirements/cluster.go | 272 ++++++++++++++++++ .../requirements/package_requirements.go | 55 ++++ .../requirements/package_requirements_test.go | 159 ++++++++++ src/internal/packager/requirements/types.go | 60 ++++ src/pkg/packager/deploy.go | 9 + src/pkg/packager/layout/assemble.go | 32 +++ src/pkg/packager/layout/layout.go | 1 + 11 files changed, 827 insertions(+) create mode 100644 examples/REQUIREMENTS/REQUIREMENTS create mode 100644 src/internal/packager/requirements/agent.go create mode 100644 src/internal/packager/requirements/cluster.go create mode 100644 src/internal/packager/requirements/package_requirements.go create mode 100644 src/internal/packager/requirements/package_requirements_test.go create mode 100644 src/internal/packager/requirements/types.go diff --git a/examples/REQUIREMENTS/REQUIREMENTS b/examples/REQUIREMENTS/REQUIREMENTS new file mode 100644 index 0000000000..c6a93035f4 --- /dev/null +++ b/examples/REQUIREMENTS/REQUIREMENTS @@ -0,0 +1,34 @@ +# YAML file named REQUIREMENTS that specifies prereqs for package to have a clean deploy +# Tooling that must exist on the agent doing the `zarf package deploy` +agent: + tools: + - name: yq + version: ">= 4.40.5" + - name: custom_binary + version: ">= 3.14.0" + versionCommand: /path/to/custom_binary --flag-for-version + reason: "Used in our create onBefore scripts for X" + + env: + - name: HTTPS_PROXY + required: false + +# Things that must be present on the cluster being deployed into +# Can specify crds, specific resources, or other zarf packages +cluster: + crds: + - name: gateways.gateway.networking.k8s.io + version: ">= 1.0.0" + - name: certificates.cert-manager.io + resources: + - apiVersion: v1 + kind: Namespace + name: cert-manager + - apiVersion: apps/v1 + kind: Deployment + namespace: zarf + name: zarf-injector + packages: + - name: iffy-package + version: ">=1.4.0, !=1.6.2, !=1.6.3" + reason: "our package is incompatible with iffy-package 1.6.2–1.6.3" diff --git a/site/src/content/docs/commands/zarf_package_deploy.md b/site/src/content/docs/commands/zarf_package_deploy.md index f733208d8d..d9eace066f 100644 --- a/site/src/content/docs/commands/zarf_package_deploy.md +++ b/site/src/content/docs/commands/zarf_package_deploy.md @@ -33,6 +33,7 @@ zarf package deploy [ PACKAGE_SOURCE ] [flags] --set-values stringToString Specify deployment package values to set on the command line (key.path=value). (default []) --set-variables 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 https package. + --skip-requirements-check Ignore the package's REQUIREMENTS when deploying --timeout duration Timeout for health checks and Helm operations such as installs and rollbacks (default 15m0s) -v, --values strings [alpha] Values files to use for templating and Helm overrides. Multiple files can be passed in as a comma separated list, and the flag can be provided multiple times. --verify Verify the Zarf package signature diff --git a/src/cmd/package.go b/src/cmd/package.go index 97b9ea3a34..1fca11c902 100644 --- a/src/cmd/package.go +++ b/src/cmd/package.go @@ -253,6 +253,7 @@ type packageDeployOptions struct { verify bool skipSignatureValidation bool skipVersionCheck bool + SkipRequirementsCheck bool ociConcurrency int publicKeyPath string } @@ -291,6 +292,7 @@ func newPackageDeployCommand(v *viper.Viper) *cobra.Command { cmd.Flags().BoolVar(&o.skipSignatureValidation, "skip-signature-validation", false, lang.CmdPackageFlagSkipSignatureValidation) cmd.Flags().BoolVar(&o.verify, "verify", v.GetBool(VPkgVerify), lang.CmdPackageFlagVerify) cmd.Flags().BoolVar(&o.skipVersionCheck, "skip-version-check", false, "Ignore version requirements when deploying the package") + cmd.Flags().BoolVar(&o.SkipRequirementsCheck, "skip-requirements-check", false, "Ignore the package's REQUIREMENTS when deploying") _ = cmd.Flags().MarkHidden("skip-version-check") errSig := cmd.Flags().MarkDeprecated("skip-signature-validation", "Signature verification now occurs on every execution, but is not enforced by default. Use --verify to enforce validation. This flag will be removed in Zarf v1.0.0.") if errSig != nil { @@ -381,6 +383,7 @@ func (o *packageDeployOptions) run(cmd *cobra.Command, args []string) (err error RemoteOptions: defaultRemoteOptions(), IsInteractive: !o.confirm, SkipVersionCheck: o.skipVersionCheck, + SkipRequirementsCheck: o.SkipRequirementsCheck, } deployedComponents, err := deploy(ctx, pkgLayout, deployOpts, o.setVariables, o.optionalComponents) diff --git a/src/internal/packager/requirements/agent.go b/src/internal/packager/requirements/agent.go new file mode 100644 index 0000000000..38f2223fc7 --- /dev/null +++ b/src/internal/packager/requirements/agent.go @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +package requirements + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "regexp" + "strings" + + "github.com/Masterminds/semver/v3" +) + +type requirementsValidationError struct { + Failures []string +} + +func (e *requirementsValidationError) Error() string { + return "REQUIREMENTS validation failed:\n - " + strings.Join(e.Failures, "\n - ") +} + +func validateAgentRequirements(ctx context.Context, req agentRequirements) error { + var failures []string + + // env checks + for _, e := range req.Env { + if !e.Required { + continue + } + if _, ok := os.LookupEnv(e.Name); !ok { + msg := fmt.Sprintf("agent env var %q is required but not set", e.Name) + if e.Reason != "" { + msg += fmt.Sprintf(" (reason: %s)", e.Reason) + } + failures = append(failures, msg) + } + } + + // tool checks + for _, t := range req.Tools { + if err := validateTool(ctx, t); err != nil { + if t.Optional { + continue + } + failures = append(failures, err.Error()) + } + } + + if len(failures) > 0 { + return &requirementsValidationError{Failures: failures} + } + return nil +} + +func validateTool(ctx context.Context, t toolRequirement) error { + if strings.TrimSpace(t.Name) == "" { + return fmt.Errorf("agent tool requirement has empty name") + } + + path, err := exec.LookPath(t.Name) + if err != nil { + msg := fmt.Sprintf("agent tool %q is missing from PATH", t.Name) + if t.Reason != "" { + msg += fmt.Sprintf(" (reason: %s)", t.Reason) + } + return fmt.Errorf("%s", msg) + } + + // If no version constraint provided, presence is enough. + if strings.TrimSpace(t.Version) == "" { + return nil + } + + constraint, err := semver.NewConstraint(t.Version) + if err != nil { + return fmt.Errorf("invalid semver constraint for tool %q: %q: %w", t.Name, t.Version, err) + } + + cmdline := t.VersionCommand + if strings.TrimSpace(cmdline) == "" { + // common defaults + cmdline = t.Name + " --version" + } + + out, err := runShellish(ctx, cmdline) + if err != nil { + return fmt.Errorf("failed running version check for tool %q (%s): %w", t.Name, cmdline, err) + } + + ver, err := extractSemver(out, t.VersionRegex) + if err != nil { + return fmt.Errorf("unable to parse version for tool %q from output %q: %w", t.Name, strings.TrimSpace(out), err) + } + + if !constraint.Check(ver) { + msg := fmt.Sprintf("agent tool %q at %q does not satisfy constraint %q (resolved binary: %s)", + t.Name, ver.Original(), t.Version, path) + if t.Reason != "" { + msg += fmt.Sprintf(" (reason: %s)", t.Reason) + } + return fmt.Errorf("%s", msg) + } + + return nil +} + +// runShellish runs a command string in a conservative way: split on spaces unless quoted. +// Keeps it dependency-free (no bash required). +func runShellish(ctx context.Context, command string) (string, error) { + parts := splitArgs(command) + if len(parts) == 0 { + return "", fmt.Errorf("empty command") + } + cmd := exec.CommandContext(ctx, parts[0], parts[1:]...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + out := stdout.String() + if err != nil { + // include stderr for debugging + if s := strings.TrimSpace(stderr.String()); s != "" { + return out, fmt.Errorf("%w: %s", err, s) + } + return out, err + } + return out, nil +} + +// extractSemver pulls the first semver-like token from output (supports leading "v"). +// If regex is provided, it must contain either a named group "ver" or group 1. +func extractSemver(output string, versionRegex string) (*semver.Version, error) { + s := strings.TrimSpace(output) + if s == "" { + return nil, fmt.Errorf("empty output") + } + + if versionRegex != "" { + re, err := regexp.Compile(versionRegex) + if err != nil { + return nil, fmt.Errorf("invalid versionRegex: %w", err) + } + m := re.FindStringSubmatch(s) + if len(m) == 0 { + return nil, fmt.Errorf("regex did not match") + } + // Named group? + if idx := re.SubexpIndex("ver"); idx > 0 && idx < len(m) { + return semver.NewVersion(strings.TrimPrefix(m[idx], "v")) + } + if len(m) >= 2 { + return semver.NewVersion(strings.TrimPrefix(m[1], "v")) + } + return nil, fmt.Errorf("regex matched but no capture group found") + } + + // Default: find first semver token + // e.g. "yq (https://...) version v4.40.5" + re := regexp.MustCompile(`v?(\d+\.\d+\.\d+)([-+][0-9A-Za-z\.\-]+)?`) + m := re.FindString(s) + if m == "" { + return nil, fmt.Errorf("no semver token found") + } + return semver.NewVersion(strings.TrimPrefix(m, "v")) +} + +// splitArgs is a tiny quoted-arg splitter (handles "..." and '...'). +func splitArgs(in string) []string { + var out []string + var cur strings.Builder + var quote rune + flush := func() { + if cur.Len() > 0 { + out = append(out, cur.String()) + cur.Reset() + } + } + + for _, r := range strings.TrimSpace(in) { + switch { + case quote != 0: + if r == quote { + quote = 0 + } else { + cur.WriteRune(r) + } + case r == '"' || r == '\'': + quote = r + case r == ' ' || r == '\t' || r == '\n': + flush() + default: + cur.WriteRune(r) + } + } + flush() + return out +} diff --git a/src/internal/packager/requirements/cluster.go b/src/internal/packager/requirements/cluster.go new file mode 100644 index 0000000000..6a20b5084e --- /dev/null +++ b/src/internal/packager/requirements/cluster.go @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +package requirements + +import ( + "context" + "fmt" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/zarf-dev/zarf/src/pkg/cluster" + apiextclient "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" +) + +func validateClusterRequirements(ctx context.Context, c *cluster.Cluster, req clusterRequirements) error { + var failures []string + + // CRDs + if len(req.CRDs) > 0 { + ae, err := apiextclient.NewForConfig(c.RestConfig) + if err != nil { + return fmt.Errorf("failed to create apiextensions client: %w", err) + } + + for _, crdReq := range req.CRDs { + if err := validateCRD(ctx, ae, crdReq); err != nil { + if crdReq.Optional { + continue + } + failures = append(failures, err.Error()) + } + } + } + + // Generic resources + if len(req.Resources) > 0 { + dc, err := dynamic.NewForConfig(c.RestConfig) + if err != nil { + return fmt.Errorf("failed to create dynamic client: %w", err) + } + + httpClient, err := rest.HTTPClientFor(c.RestConfig) + if err != nil { + return fmt.Errorf("failed to create http client for rest mapper: %w", err) + } + rm, err := apiutil.NewDynamicRESTMapper(c.RestConfig, httpClient) + if err != nil { + return fmt.Errorf("failed to create rest mapper: %w", err) + } + + for _, r := range req.Resources { + if err := validateObjectExists(ctx, dc, rm, r); err != nil { + if r.Optional { + continue + } + failures = append(failures, err.Error()) + } + } + } + + // Zarf packages + for _, p := range req.Packages { + if err := validateDeployedPackage(ctx, c, p); err != nil { + if p.Optional { + continue + } + failures = append(failures, err.Error()) + } + } + if len(failures) > 0 { + return &requirementsValidationError{Failures: failures} + } + return nil +} + +func validateDeployedPackage(ctx context.Context, c *cluster.Cluster, r packageRequirement) error { + if strings.TrimSpace(r.Name) == "" { + return fmt.Errorf("cluster package requirement has empty name") + } + + // This is the same mechanism Zarf already uses elsewhere to read a deployed package from the cluster. + deployed, err := c.GetDeployedPackage(ctx, r.Name) + if err != nil { + msg := fmt.Sprintf("cluster package %q is not deployed", r.Name) + if r.Reason != "" { + msg += fmt.Sprintf(" (reason: %s)", r.Reason) + } + return fmt.Errorf("%s: %w", msg, err) + } + + // Presence-only requirement + if strings.TrimSpace(r.Version) == "" { + return nil + } + + // Expect deployed package metadata to contain a version string (metadata.version). + // If it's missing/unparseable, we can't validate the constraint. + deployedVersion := strings.TrimSpace(deployed.Data.Metadata.Version) // adjust field name to match actual type + if deployedVersion == "" { + return fmt.Errorf("cluster package %q is deployed but has no version metadata to validate constraint %q", + r.Name, r.Version) + } + + v, err := semver.NewVersion(strings.TrimPrefix(deployedVersion, "v")) + if err != nil { + return fmt.Errorf("cluster package %q has non-semver version %q (cannot validate constraint %q): %w", + r.Name, deployedVersion, r.Version, err) + } + + constraint, err := semver.NewConstraint(r.Version) + if err != nil { + return fmt.Errorf("invalid semver constraint for cluster package %q: %q: %w", r.Name, r.Version, err) + } + + if !constraint.Check(v) { + msg := fmt.Sprintf("cluster package %q at %q does not satisfy constraint %q", r.Name, v.Original(), r.Version) + if r.Reason != "" { + msg += fmt.Sprintf(" (reason: %s)", r.Reason) + } + return fmt.Errorf("%s", msg) + } + + return nil +} + +func validateCRD(ctx context.Context, ae apiextclient.Interface, r crdRequirement) error { + if strings.TrimSpace(r.Name) == "" { + return fmt.Errorf("cluster crd requirement has empty name") + } + + crd, err := ae.ApiextensionsV1().CustomResourceDefinitions().Get(ctx, r.Name, metav1.GetOptions{}) + if err != nil { + msg := fmt.Sprintf("cluster CRD %q is missing", r.Name) + if r.Reason != "" { + msg += fmt.Sprintf(" (reason: %s)", r.Reason) + } + return fmt.Errorf("%s: %w", msg, err) + } + + // Optional version constraint: check served versions list (names like "v1", "v1beta1"). + // If you want true semver, you'll need conventions; here we treat "v1" as "1.0.0". + if strings.TrimSpace(r.Version) == "" { + return nil + } + + constraint, err := semver.NewConstraint(r.Version) + if err != nil { + return fmt.Errorf("invalid semver constraint for CRD %q: %q: %w", r.Name, r.Version, err) + } + + var served []*semver.Version + for _, v := range crd.Spec.Versions { + if !v.Served { + continue + } + sv, err := k8sAPIVersionToSemver(v.Name) + if err == nil { + served = append(served, sv) + } + } + + for _, sv := range served { + if constraint.Check(sv) { + return nil + } + } + + msg := fmt.Sprintf("cluster CRD %q served versions do not satisfy constraint %q", r.Name, r.Version) + if r.Reason != "" { + msg += fmt.Sprintf(" (reason: %s)", r.Reason) + } + return fmt.Errorf("%s", msg) +} + +// validateObjectExists checks existence of a specific object by GVK+name(/namespace) +// using REST mapping + dynamic client. +func validateObjectExists( + ctx context.Context, + dc dynamic.Interface, + rm meta.RESTMapper, + sel k8sResourceSelector, +) error { + gv, err := schema.ParseGroupVersion(sel.APIVersion) + if err != nil { + return fmt.Errorf("invalid apiVersion %q for %s/%s: %w", sel.APIVersion, sel.Kind, sel.Name, err) + } + gvk := gv.WithKind(sel.Kind) + + mapping, err := rm.RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + return fmt.Errorf("unable to map GVK %s for %s/%s: %w", gvk.String(), sel.Kind, sel.Name, err) + } + + var ri dynamic.ResourceInterface + if mapping.Scope.Name() == meta.RESTScopeNameNamespace { + ns := sel.Namespace + if ns == "" { + return fmt.Errorf("resource %s/%s is namespaced but namespace is empty", sel.Kind, sel.Name) + } + ri = dc.Resource(mapping.Resource).Namespace(ns) + } else { + ri = dc.Resource(mapping.Resource) + } + + _, err = ri.Get(ctx, sel.Name, metav1.GetOptions{}) + if err != nil { + msg := fmt.Sprintf("cluster resource %s %q (apiVersion=%s) is missing", + sel.Kind, qualifiedName(sel.Namespace, sel.Name), sel.APIVersion) + if sel.Reason != "" { + msg += fmt.Sprintf(" (reason: %s)", sel.Reason) + } + return fmt.Errorf("%s: %w", msg, err) + } + + return nil +} + +func qualifiedName(ns, name string) string { + if ns == "" { + return name + } + return ns + "/" + name +} + +// k8sAPIVersionToSemver converts k8s-style api versions ("v1", "v2beta1") into +// a semver-ish version. This is intentionally conservative. +// - v1 => 1.0.0 +// - v2 => 2.0.0 +// - v1beta1 => 1.0.0-beta.1 +func k8sAPIVersionToSemver(v string) (*semver.Version, error) { + v = strings.TrimSpace(v) + if !strings.HasPrefix(v, "v") { + return nil, fmt.Errorf("version does not start with v: %q", v) + } + v = strings.TrimPrefix(v, "v") + + // v1 + if !strings.ContainsAny(v, "abcdefghijklmnopqrstuvwxyz") { + return semver.NewVersion(v + ".0.0") + } + + // v1beta1 / v1alpha2 + // split digits prefix + i := 0 + for i < len(v) && v[i] >= '0' && v[i] <= '9' { + i++ + } + if i == 0 { + return nil, fmt.Errorf("no major version digits in %q", v) + } + major := v[:i] + rest := v[i:] // e.g. "beta1" + // find trailing digits + j := len(rest) - 1 + for j >= 0 && rest[j] >= '0' && rest[j] <= '9' { + j-- + } + stage := rest[:j+1] // beta/alpha + num := rest[j+1:] // 1/2 + if stage == "" || num == "" { + return nil, fmt.Errorf("cannot parse stage/num from %q", v) + } + + return semver.NewVersion(fmt.Sprintf("%s.0.0-%s.%s", major, stage, num)) +} diff --git a/src/internal/packager/requirements/package_requirements.go b/src/internal/packager/requirements/package_requirements.go new file mode 100644 index 0000000000..e6d733b0bf --- /dev/null +++ b/src/internal/packager/requirements/package_requirements.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +package requirements + +import ( + "context" + "fmt" + "os" + "path/filepath" + + goyaml "github.com/goccy/go-yaml" + "github.com/zarf-dev/zarf/src/pkg/cluster" + "github.com/zarf-dev/zarf/src/pkg/logger" + "github.com/zarf-dev/zarf/src/pkg/packager/layout" +) + +// ValidatePackageRequirements reads /REQUIREMENTS and validates agent + cluster prerequisites. +func ValidatePackageRequirements(ctx context.Context, pkgLayout *layout.PackageLayout) error { + reqPath := filepath.Join(pkgLayout.DirPath(), layout.Requirements) + + b, err := os.ReadFile(reqPath) + if err != nil { + if os.IsNotExist(err) { + return nil // no REQUIREMENTS file => no-op + } + return fmt.Errorf("failed to read REQUIREMENTS: %w", err) + } + + var rf requirementsFile + if err := goyaml.Unmarshal(b, &rf); err != nil { + return fmt.Errorf("failed to parse REQUIREMENTS: %w", err) + } + + // Validate agent-side requirements first (fast, local). + if rf.Agent != nil { + if err := validateAgentRequirements(ctx, *rf.Agent); err != nil { + return err + } + } + + // Cluster-side requirements: connect once if needed. + if rf.Cluster != nil { + c, err := cluster.New(ctx) + if err != nil { + return fmt.Errorf("cluster requirements exist but unable to connect to cluster: %w", err) + } + if err := validateClusterRequirements(ctx, c, *rf.Cluster); err != nil { + return err + } + } + + logger.From(ctx).Debug("requirements validated", "file", reqPath) + return nil +} diff --git a/src/internal/packager/requirements/package_requirements_test.go b/src/internal/packager/requirements/package_requirements_test.go new file mode 100644 index 0000000000..5ed6fe2a71 --- /dev/null +++ b/src/internal/packager/requirements/package_requirements_test.go @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +package requirements + +import ( + "context" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zarf-dev/zarf/src/pkg/packager/layout" +) + +func TestValidatePackageRequirements_NoRequirementsFile(t *testing.T) { + t.Parallel() + + ctx := context.Background() + pkgLayout := newTempPackageLayout(ctx, t) + + // No REQUIREMENTS file present => no-op / no error + err := ValidatePackageRequirements(ctx, pkgLayout) + require.NoError(t, err) +} + +func TestValidatePackageRequirements_InvalidYAML(t *testing.T) { + t.Parallel() + + ctx := context.Background() + pkgLayout := newTempPackageLayout(ctx, t) + + writeFile(t, filepath.Join(pkgLayout.DirPath(), layout.Requirements), []byte("::: this is not yaml :::")) + + err := ValidatePackageRequirements(ctx, pkgLayout) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to parse REQUIREMENTS") +} + +func TestValidatePackageRequirements_AgentToolMissing(t *testing.T) { + t.Parallel() + + ctx := context.Background() + pkgLayout := newTempPackageLayout(ctx, t) + + writeFile(t, filepath.Join(pkgLayout.DirPath(), layout.Requirements), []byte(` +agent: + tools: + - name: definitely-not-a-real-binary-12345 + version: ">= 1.0.0" +`)) + + err := ValidatePackageRequirements(ctx, pkgLayout) + require.Error(t, err) + + var reqErr *requirementsValidationError + require.ErrorAs(t, err, &reqErr) + require.Contains(t, err.Error(), "agent tool") + require.Contains(t, err.Error(), "missing") +} + +func TestValidatePackageRequirements_AgentToolVersionMet(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("this test uses a *nix shell script; provide a .bat equivalent if needed") + } + + ctx := context.Background() + pkgLayout := newTempPackageLayout(ctx, t) + + toolDir := t.TempDir() + fakeYQ := filepath.Join(toolDir, "yq") + + // Fake yq that prints a parseable version. + writeExecutable(t, fakeYQ, []byte(`#!/bin/sh +echo "yq version v4.40.6" +`)) + + // Prepend toolDir to PATH so exec.LookPath("yq") finds our fake binary. + t.Setenv("PATH", toolDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + writeFile(t, filepath.Join(pkgLayout.DirPath(), layout.Requirements), []byte(` +agent: + tools: + - name: yq + version: ">= 4.40.5" +`)) + + err := ValidatePackageRequirements(ctx, pkgLayout) + require.NoError(t, err) +} + +func TestValidatePackageRequirements_AgentToolVersionNotMet(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("this test uses a *nix shell script; provide a .bat equivalent if needed") + } + + ctx := context.Background() + pkgLayout := newTempPackageLayout(ctx, t) + + toolDir := t.TempDir() + fakeYQ := filepath.Join(toolDir, "yq") + + writeExecutable(t, fakeYQ, []byte(`#!/bin/sh +echo "yq version v4.40.4" +`)) + + t.Setenv("PATH", toolDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + writeFile(t, filepath.Join(pkgLayout.DirPath(), layout.Requirements), []byte(` +agent: + tools: + - name: yq + version: ">= 4.40.5" +`)) + + err := ValidatePackageRequirements(ctx, pkgLayout) + + var reqErr *requirementsValidationError + require.ErrorAs(t, err, &reqErr) + require.Contains(t, err.Error(), "does not satisfy constraint") +} + +func newTempPackageLayout(ctx context.Context, t *testing.T) *layout.PackageLayout { + t.Helper() + + dir := t.TempDir() + + // Minimal package definition that pkgcfg.Parse accepts when LoadFromDir runs. + // (Keep it intentionally tiny to ensure unit tests are fast.) + writeFile(t, filepath.Join(dir, layout.ZarfYAML), []byte(` +kind: ZarfPackageConfig +metadata: + name: test + version: 0.0.0 +components: [] +`)) + + pkgLayout, err := layout.LoadFromDir(ctx, dir, layout.PackageLayoutOptions{ + VerificationStrategy: layout.VerifyNever, + }) + require.NoError(t, err) + + return pkgLayout +} + +func writeFile(t *testing.T, path string, b []byte) { + t.Helper() + require.NoError(t, os.WriteFile(path, b, 0o600)) +} + +func writeExecutable(t *testing.T, path string, b []byte) { + t.Helper() + require.NoError(t, os.WriteFile(path, b, 0o700)) +} diff --git a/src/internal/packager/requirements/types.go b/src/internal/packager/requirements/types.go new file mode 100644 index 0000000000..319597b4e9 --- /dev/null +++ b/src/internal/packager/requirements/types.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +package requirements + +type requirementsFile struct { + Agent *agentRequirements `json:"agent,omitempty" yaml:"agent,omitempty"` + Cluster *clusterRequirements `json:"cluster,omitempty" yaml:"cluster,omitempty"` +} + +type agentRequirements struct { + Tools []toolRequirement `json:"tools,omitempty" yaml:"tools,omitempty"` + Env []envRequirement `json:"env,omitempty" yaml:"env,omitempty"` +} + +type toolRequirement struct { + Name string `json:"name" yaml:"name"` + Version string `json:"version,omitempty" yaml:"version,omitempty"` // semver constraint, e.g. ">= 4.40.5" + // Optional enhancements (future-proofing): + VersionCommand string `json:"versionCommand,omitempty" yaml:"versionCommand,omitempty"` + VersionRegex string `json:"versionRegex,omitempty" yaml:"versionRegex,omitempty"` + Optional bool `json:"optional,omitempty" yaml:"optional,omitempty"` + Reason string `json:"reason,omitempty" yaml:"reason,omitempty"` +} + +type envRequirement struct { + Name string `json:"name" yaml:"name"` + Required bool `json:"required" yaml:"required"` + Reason string `json:"reason,omitempty" yaml:"reason,omitempty"` +} + +type clusterRequirements struct { + Packages []packageRequirement `json:"packages,omitempty" yaml:"packages,omitempty"` + CRDs []crdRequirement `json:"crds,omitempty" yaml:"crds,omitempty"` + Resources []k8sResourceSelector `json:"resources,omitempty" yaml:"resources,omitempty"` +} + +type crdRequirement struct { + Name string `json:"name" yaml:"name"` // e.g. "certificates.cert-manager.io" + Version string `json:"version,omitempty" yaml:"version,omitempty"` + Optional bool `json:"optional,omitempty" yaml:"optional,omitempty"` + Reason string `json:"reason,omitempty" yaml:"reason,omitempty"` +} + +type k8sResourceSelector struct { + APIVersion string `json:"apiVersion" yaml:"apiVersion"` + Kind string `json:"kind" yaml:"kind"` + Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"` + Name string `json:"name" yaml:"name"` + Optional bool `json:"optional,omitempty" yaml:"optional,omitempty"` + Reason string `json:"reason,omitempty" yaml:"reason,omitempty"` +} + +type packageRequirement struct { + Name string `json:"name" yaml:"name"` // package metadata.name + Version string `json:"version,omitempty" yaml:"version,omitempty"` // semver constraint, supports !=, ranges + Optional bool `json:"optional,omitempty" yaml:"optional,omitempty"` + Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"` // optional override; default zarf ns + Reason string `json:"reason,omitempty" yaml:"reason,omitempty"` +} diff --git a/src/pkg/packager/deploy.go b/src/pkg/packager/deploy.go index 820126ca4b..da89ef5e20 100644 --- a/src/pkg/packager/deploy.go +++ b/src/pkg/packager/deploy.go @@ -76,6 +76,8 @@ type DeployOptions struct { IsInteractive bool // SkipVersionCheck skips version requirement validation SkipVersionCheck bool + // SkipRequirementsCheck skips agent/cluster requirement validation from REQUIREMENTS file + SkipRequirementsCheck bool } // deployer tracks mutable fields across deployments. Because components can create a cluster and create state @@ -104,6 +106,13 @@ func Deploy(ctx context.Context, pkgLayout *layout.PackageLayout, opts DeployOpt } } + // Validate zarf pkg REQUIREMENTS before proceeding + if !opts.SkipRequirementsCheck { + if err := requirements.ValidatePackageRequirements(ctx, pkgLayout); err != nil { + return DeployResult{}, fmt.Errorf("%w If you want to force this zarf package, you may skip this check with --skip-requirements-check. Unexpected behavior or errors may occur", err) + } + } + if !feature.IsEnabled(feature.RegistryProxy) && opts.RegistryInfo.RegistryMode == state.RegistryModeProxy { return DeployResult{}, fmt.Errorf("the registry proxy feature gate is not enabled") } diff --git a/src/pkg/packager/layout/assemble.go b/src/pkg/packager/layout/assemble.go index b44e42e486..b2cedb9611 100644 --- a/src/pkg/packager/layout/assemble.go +++ b/src/pkg/packager/layout/assemble.go @@ -190,6 +190,10 @@ func AssemblePackage(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath if err = createDocumentationTar(pkg, packagePath, buildPath); err != nil { return nil, err } + // Include optional package root REQUIREMENTS file in the built package. + if err = copyRequirementsFile(packagePath, buildPath); err != nil { + return nil, err + } checksumContent, checksumSha, err := getChecksum(buildPath) if err != nil { @@ -264,6 +268,9 @@ func AssembleSkeleton(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath return nil, err } } + if err = copyRequirementsFile(packagePath, buildPath); err != nil { + return nil, err + } checksumContent, checksumSha, err := getChecksum(buildPath) if err != nil { @@ -1076,3 +1083,28 @@ func createDocumentationTar(pkg v1alpha1.ZarfPackage, packagePath, buildPath str return nil } + +func copyRequirementsFile(packagePath, buildPath string) error { + src := filepath.Join(packagePath, Requirements) + dst := filepath.Join(buildPath, Requirements) + + _, err := os.Stat(src) + if err != nil { + if os.IsNotExist(err) { + return nil // optional file + } + return fmt.Errorf("unable to stat %s: %w", src, err) + } + + // Copy into the package root so it ships inside the package artifact. + if err := helpers.CreatePathAndCopy(src, dst); err != nil { + return fmt.Errorf("unable to copy %s to package: %w", Requirements, err) + } + + // Ensure consistent perms (same as other root files). + if err := os.Chmod(dst, helpers.ReadWriteUser); err != nil { + return fmt.Errorf("unable to chmod %s: %w", dst, err) + } + + return nil +} diff --git a/src/pkg/packager/layout/layout.go b/src/pkg/packager/layout/layout.go index ab775e28e6..31d2c5221a 100644 --- a/src/pkg/packager/layout/layout.go +++ b/src/pkg/packager/layout/layout.go @@ -16,6 +16,7 @@ const ( Checksums = "checksums.txt" ValuesYAML = "values.yaml" ValuesSchema = "values.schema.json" + Requirements = "REQUIREMENTS" ImagesDir = "images" ComponentsDir = "components" From c18fb2048cc239541a93cbcae549be75e2fcaafe Mon Sep 17 00:00:00 2001 From: Shas Date: Sun, 15 Feb 2026 19:41:50 -0500 Subject: [PATCH 2/6] examples are expected to be zarf package create-able Signed-off-by: Shas --- examples/REQUIREMENTS/REQUIREMENTS | 69 +++++++++++-------- .../REQUIREMENTS/manifests/namespace.yaml | 4 ++ examples/REQUIREMENTS/zarf.yaml | 16 +++++ 3 files changed, 62 insertions(+), 27 deletions(-) create mode 100644 examples/REQUIREMENTS/manifests/namespace.yaml create mode 100644 examples/REQUIREMENTS/zarf.yaml diff --git a/examples/REQUIREMENTS/REQUIREMENTS b/examples/REQUIREMENTS/REQUIREMENTS index c6a93035f4..9317067635 100644 --- a/examples/REQUIREMENTS/REQUIREMENTS +++ b/examples/REQUIREMENTS/REQUIREMENTS @@ -1,34 +1,49 @@ # YAML file named REQUIREMENTS that specifies prereqs for package to have a clean deploy -# Tooling that must exist on the agent doing the `zarf package deploy` +# This is a very minimal & functional example +# agent - specify utils & vars that must exist on the agent doing the zarf package deploy agent: tools: - - name: yq - version: ">= 4.40.5" - - name: custom_binary - version: ">= 3.14.0" - versionCommand: /path/to/custom_binary --flag-for-version - reason: "Used in our create onBefore scripts for X" - - env: - - name: HTTPS_PROXY - required: false - -# Things that must be present on the cluster being deployed into -# Can specify crds, specific resources, or other zarf packages + - name: kubectl + version: ">=1.20.0" + reason: Required to apply manifests +# cluster - specify crds, k8s resources, or other zarf packages that must exist on the cluster being deployed into cluster: - crds: - - name: gateways.gateway.networking.k8s.io - version: ">= 1.0.0" - - name: certificates.cert-manager.io resources: - apiVersion: v1 kind: Namespace - name: cert-manager - - apiVersion: apps/v1 - kind: Deployment - namespace: zarf - name: zarf-injector - packages: - - name: iffy-package - version: ">=1.4.0, !=1.6.2, !=1.6.3" - reason: "our package is incompatible with iffy-package 1.6.2–1.6.3" + name: kube-system + +# While this is more elaborate +#agent: +# tools: +# - name: yq +# version: ">= 4.40.5" +# - name: custom_binary +# version: ">= 3.14.0" +# versionCommand: /path/to/custom_binary --flag-for-version +# reason: "Used in our create onBefore scripts for X" +# +# env: +# - name: HTTPS_PROXY +# required: false +# +# Things that must be present on the cluster being deployed into +# Can specify crds, specific resources, or other zarf packages +#cluster: +# crds: +# - name: gateways.gateway.networking.k8s.io +# version: ">= 1.0.0" +# - name: certificates.cert-manager.io +# resources: +# - apiVersion: v1 +# kind: Namespace +# name: cert-manager +# - apiVersion: apps/v1 +# kind: Deployment +# namespace: zarf +# name: zarf-injector +# packages: +# - name: iffy-package +# version: ">=1.4.0, !=1.6.2, !=1.6.3" +# reason: "our package is incompatible with iffy-package 1.6.2–1.6.3" +# diff --git a/examples/REQUIREMENTS/manifests/namespace.yaml b/examples/REQUIREMENTS/manifests/namespace.yaml new file mode 100644 index 0000000000..0d3346f8b7 --- /dev/null +++ b/examples/REQUIREMENTS/manifests/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: requirements-test diff --git a/examples/REQUIREMENTS/zarf.yaml b/examples/REQUIREMENTS/zarf.yaml new file mode 100644 index 0000000000..b3b75d61b3 --- /dev/null +++ b/examples/REQUIREMENTS/zarf.yaml @@ -0,0 +1,16 @@ +kind: ZarfPackageConfig +metadata: + name: requirements-test + description: Simple package to test REQUIREMENTS handling + version: 0.0.1 + architecture: amd64 + +components: + - name: create-namespace + description: Creates a test namespace + required: true + manifests: + - name: namespace + namespace: "" + files: + - manifests/namespace.yaml From 716e8a3cac6578e62881a4192852803511c224be Mon Sep 17 00:00:00 2001 From: Shas Date: Sat, 21 Feb 2026 10:06:02 -0500 Subject: [PATCH 3/6] turn REQUIREMENTS into requirements --- examples/REQUIREMENTS/{REQUIREMENTS => requirements.yaml} | 2 +- examples/REQUIREMENTS/zarf.yaml | 2 +- src/cmd/package.go | 2 +- src/internal/packager/requirements/package_requirements_test.go | 2 +- src/pkg/packager/layout/layout.go | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) rename examples/REQUIREMENTS/{REQUIREMENTS => requirements.yaml} (93%) diff --git a/examples/REQUIREMENTS/REQUIREMENTS b/examples/REQUIREMENTS/requirements.yaml similarity index 93% rename from examples/REQUIREMENTS/REQUIREMENTS rename to examples/REQUIREMENTS/requirements.yaml index 9317067635..3fa0eeeb52 100644 --- a/examples/REQUIREMENTS/REQUIREMENTS +++ b/examples/REQUIREMENTS/requirements.yaml @@ -1,4 +1,4 @@ -# YAML file named REQUIREMENTS that specifies prereqs for package to have a clean deploy +# YAML file that specifies prereqs for package to have a clean deploy # This is a very minimal & functional example # agent - specify utils & vars that must exist on the agent doing the zarf package deploy agent: diff --git a/examples/REQUIREMENTS/zarf.yaml b/examples/REQUIREMENTS/zarf.yaml index b3b75d61b3..d2e211ca1d 100644 --- a/examples/REQUIREMENTS/zarf.yaml +++ b/examples/REQUIREMENTS/zarf.yaml @@ -1,7 +1,7 @@ kind: ZarfPackageConfig metadata: name: requirements-test - description: Simple package to test REQUIREMENTS handling + description: Simple package to test requirements handling version: 0.0.1 architecture: amd64 diff --git a/src/cmd/package.go b/src/cmd/package.go index 1fca11c902..c16e8ed1a8 100644 --- a/src/cmd/package.go +++ b/src/cmd/package.go @@ -292,7 +292,7 @@ func newPackageDeployCommand(v *viper.Viper) *cobra.Command { cmd.Flags().BoolVar(&o.skipSignatureValidation, "skip-signature-validation", false, lang.CmdPackageFlagSkipSignatureValidation) cmd.Flags().BoolVar(&o.verify, "verify", v.GetBool(VPkgVerify), lang.CmdPackageFlagVerify) cmd.Flags().BoolVar(&o.skipVersionCheck, "skip-version-check", false, "Ignore version requirements when deploying the package") - cmd.Flags().BoolVar(&o.SkipRequirementsCheck, "skip-requirements-check", false, "Ignore the package's REQUIREMENTS when deploying") + cmd.Flags().BoolVar(&o.SkipRequirementsCheck, "skip-requirements-check", false, "Ignore the package's requirements.yaml when deploying") _ = cmd.Flags().MarkHidden("skip-version-check") errSig := cmd.Flags().MarkDeprecated("skip-signature-validation", "Signature verification now occurs on every execution, but is not enforced by default. Use --verify to enforce validation. This flag will be removed in Zarf v1.0.0.") if errSig != nil { diff --git a/src/internal/packager/requirements/package_requirements_test.go b/src/internal/packager/requirements/package_requirements_test.go index 5ed6fe2a71..3bde6c8244 100644 --- a/src/internal/packager/requirements/package_requirements_test.go +++ b/src/internal/packager/requirements/package_requirements_test.go @@ -35,7 +35,7 @@ func TestValidatePackageRequirements_InvalidYAML(t *testing.T) { err := ValidatePackageRequirements(ctx, pkgLayout) require.Error(t, err) - require.Contains(t, err.Error(), "failed to parse REQUIREMENTS") + require.Contains(t, err.Error(), "failed to parse requirements.yaml") } func TestValidatePackageRequirements_AgentToolMissing(t *testing.T) { diff --git a/src/pkg/packager/layout/layout.go b/src/pkg/packager/layout/layout.go index 31d2c5221a..06bdaf29cb 100644 --- a/src/pkg/packager/layout/layout.go +++ b/src/pkg/packager/layout/layout.go @@ -16,7 +16,7 @@ const ( Checksums = "checksums.txt" ValuesYAML = "values.yaml" ValuesSchema = "values.schema.json" - Requirements = "REQUIREMENTS" + Requirements = "requirements.yaml" ImagesDir = "images" ComponentsDir = "components" From 70fd387c72b28c592ed4bf5de9d64f9e9e7d5e6a Mon Sep 17 00:00:00 2001 From: Shas Date: Sat, 21 Feb 2026 10:31:46 -0500 Subject: [PATCH 4/6] Fix package for unit test --- .../packager/requirements/package_requirements.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/internal/packager/requirements/package_requirements.go b/src/internal/packager/requirements/package_requirements.go index e6d733b0bf..88af36f5a8 100644 --- a/src/internal/packager/requirements/package_requirements.go +++ b/src/internal/packager/requirements/package_requirements.go @@ -17,19 +17,25 @@ import ( // ValidatePackageRequirements reads /REQUIREMENTS and validates agent + cluster prerequisites. func ValidatePackageRequirements(ctx context.Context, pkgLayout *layout.PackageLayout) error { - reqPath := filepath.Join(pkgLayout.DirPath(), layout.Requirements) + return ValidatePackageRequirementsFromDir(ctx, pkgLayout.DirPath()) +} + +// ValidatePackageRequirementsFromDir reads /REQUIREMENTS and validates agent + cluster prerequisites. +// This is useful for unit tests and for commands that inspect package contents without requiring a full PackageLayout. +func ValidatePackageRequirementsFromDir(ctx context.Context, dir string) error { + reqPath := filepath.Join(dir, layout.Requirements) b, err := os.ReadFile(reqPath) if err != nil { if os.IsNotExist(err) { return nil // no REQUIREMENTS file => no-op } - return fmt.Errorf("failed to read REQUIREMENTS: %w", err) + return fmt.Errorf("failed to read requirements.yaml: %w", err) } var rf requirementsFile if err := goyaml.Unmarshal(b, &rf); err != nil { - return fmt.Errorf("failed to parse REQUIREMENTS: %w", err) + return fmt.Errorf("failed to parse requirements.yaml: %w", err) } // Validate agent-side requirements first (fast, local). From e6044d9a1e0a6cdad04ddd393fe173c0afa4bbb8 Mon Sep 17 00:00:00 2001 From: Shas Date: Sat, 21 Feb 2026 10:33:37 -0500 Subject: [PATCH 5/6] follow convention for skipRequirementsCheck --- src/cmd/package.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cmd/package.go b/src/cmd/package.go index c16e8ed1a8..e6faf90907 100644 --- a/src/cmd/package.go +++ b/src/cmd/package.go @@ -253,7 +253,7 @@ type packageDeployOptions struct { verify bool skipSignatureValidation bool skipVersionCheck bool - SkipRequirementsCheck bool + skipRequirementsCheck bool ociConcurrency int publicKeyPath string } @@ -292,7 +292,7 @@ func newPackageDeployCommand(v *viper.Viper) *cobra.Command { cmd.Flags().BoolVar(&o.skipSignatureValidation, "skip-signature-validation", false, lang.CmdPackageFlagSkipSignatureValidation) cmd.Flags().BoolVar(&o.verify, "verify", v.GetBool(VPkgVerify), lang.CmdPackageFlagVerify) cmd.Flags().BoolVar(&o.skipVersionCheck, "skip-version-check", false, "Ignore version requirements when deploying the package") - cmd.Flags().BoolVar(&o.SkipRequirementsCheck, "skip-requirements-check", false, "Ignore the package's requirements.yaml when deploying") + cmd.Flags().BoolVar(&o.skipRequirementsCheck, "skip-requirements-check", false, "Ignore the package's requirements.yaml when deploying") _ = cmd.Flags().MarkHidden("skip-version-check") errSig := cmd.Flags().MarkDeprecated("skip-signature-validation", "Signature verification now occurs on every execution, but is not enforced by default. Use --verify to enforce validation. This flag will be removed in Zarf v1.0.0.") if errSig != nil { @@ -383,7 +383,7 @@ func (o *packageDeployOptions) run(cmd *cobra.Command, args []string) (err error RemoteOptions: defaultRemoteOptions(), IsInteractive: !o.confirm, SkipVersionCheck: o.skipVersionCheck, - SkipRequirementsCheck: o.SkipRequirementsCheck, + SkipRequirementsCheck: o.skipRequirementsCheck, } deployedComponents, err := deploy(ctx, pkgLayout, deployOpts, o.setVariables, o.optionalComponents) From 2b32787bb17d5b311ba67aa5668fbdbbf8aa3ccc Mon Sep 17 00:00:00 2001 From: Shas Date: Sat, 21 Feb 2026 10:37:47 -0500 Subject: [PATCH 6/6] import apiextensions directly now --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 3684fe85c8..7598c85b37 100644 --- a/go.mod +++ b/go.mod @@ -629,7 +629,7 @@ require ( gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/gorm v1.31.1 // indirect - k8s.io/apiextensions-apiserver v0.35.0 // indirect + k8s.io/apiextensions-apiserver v0.35.0 k8s.io/apiserver v0.35.0 // indirect k8s.io/cli-runtime v0.35.1 k8s.io/component-helpers v0.35.1