From 67d1d7e1bed29b4973021c81e160ffea78506a1c Mon Sep 17 00:00:00 2001 From: Horiodino Date: Tue, 17 Jun 2025 13:33:10 +0530 Subject: [PATCH] reporting all the validation errors in the template Signed-off-by: Horiodino updatec test-cases Signed-off-by: Horiodino using err.join Signed-off-by: Horiodino --- pkg/limayaml/validate.go | 244 ++++++++++++++++++---------------- pkg/limayaml/validate_test.go | 32 ++++- 2 files changed, 159 insertions(+), 117 deletions(-) diff --git a/pkg/limayaml/validate.go b/pkg/limayaml/validate.go index 750383e2c51..55af72d88c3 100644 --- a/pkg/limayaml/validate.go +++ b/pkg/limayaml/validate.go @@ -30,54 +30,58 @@ import ( ) func validateFileObject(f File, fieldName string) error { + var errs error if !strings.Contains(f.Location, "://") { if _, err := localpathutil.Expand(f.Location); err != nil { - return fmt.Errorf("field `%s.location` refers to an invalid local file path: %q: %w", fieldName, f.Location, err) + errs = errors.Join(errs, fmt.Errorf("field `%s.location` refers to an invalid local file path: %q: %w", fieldName, f.Location, err)) } // f.Location does NOT need to be accessible, so we do NOT check os.Stat(f.Location) } if !slices.Contains(ArchTypes, f.Arch) { - return fmt.Errorf("field `arch` must be one of %v; got %q", ArchTypes, f.Arch) + errs = errors.Join(errs, fmt.Errorf("field `arch` must be one of %v; got %q", ArchTypes, f.Arch)) } if f.Digest != "" { if !f.Digest.Algorithm().Available() { - return fmt.Errorf("field `%s.digest` refers to an unavailable digest algorithm", fieldName) + errs = errors.Join(errs, fmt.Errorf("field `%s.digest` refers to an unavailable digest algorithm", fieldName)) } if err := f.Digest.Validate(); err != nil { - return fmt.Errorf("field `%s.digest` is invalid: %s: %w", fieldName, f.Digest.String(), err) + errs = errors.Join(errs, fmt.Errorf("field `%s.digest` is invalid: %s: %w", fieldName, f.Digest.String(), err)) } } - return nil + return errs } func Validate(y *LimaYAML, warn bool) error { + var errs error + if len(y.Base) > 0 { - return errors.New("field `base` must be empty for YAML validation") + errs = errors.Join(errs, errors.New("field `base` must be empty for YAML validation")) } + if y.MinimumLimaVersion != nil { if _, err := versionutil.Parse(*y.MinimumLimaVersion); err != nil { - return fmt.Errorf("field `minimumLimaVersion` must be a semvar value, got %q: %w", *y.MinimumLimaVersion, err) + errs = errors.Join(errs, fmt.Errorf("field `minimumLimaVersion` must be a semvar value, got %q: %w", *y.MinimumLimaVersion, err)) } limaVersion, err := versionutil.Parse(version.Version) if err != nil { - return fmt.Errorf("can't parse builtin Lima version %q: %w", version.Version, err) + errs = errors.Join(errs, fmt.Errorf("can't parse builtin Lima version %q: %w", version.Version, err)) } if versionutil.GreaterThan(*y.MinimumLimaVersion, limaVersion.String()) { - return fmt.Errorf("template requires Lima version %q; this is only %q", *y.MinimumLimaVersion, limaVersion.String()) + errs = errors.Join(errs, fmt.Errorf("template requires Lima version %q; this is only %q", *y.MinimumLimaVersion, limaVersion.String())) } } if y.VMOpts.QEMU.MinimumVersion != nil { if _, err := semver.NewVersion(*y.VMOpts.QEMU.MinimumVersion); err != nil { - return fmt.Errorf("field `vmOpts.qemu.minimumVersion` must be a semvar value, got %q: %w", *y.VMOpts.QEMU.MinimumVersion, err) + errs = errors.Join(errs, fmt.Errorf("field `vmOpts.qemu.minimumVersion` must be a semvar value, got %q: %w", *y.VMOpts.QEMU.MinimumVersion, err)) } } switch *y.OS { case LINUX: default: - return fmt.Errorf("field `os` must be %q; got %q", LINUX, *y.OS) + errs = errors.Join(errs, fmt.Errorf("field `os` must be %q; got %q", LINUX, *y.OS)) } if !slices.Contains(ArchTypes, *y.Arch) { - return fmt.Errorf("field `arch` must be one of %v; got %q", ArchTypes, *y.Arch) + errs = errors.Join(errs, fmt.Errorf("field `arch` must be one of %v; got %q", ArchTypes, *y.Arch)) } switch *y.VMType { case QEMU: @@ -86,114 +90,114 @@ func Validate(y *LimaYAML, warn bool) error { // NOP case VZ: if !IsNativeArch(*y.Arch) { - return fmt.Errorf("field `arch` must be %q for VZ; got %q", NewArch(runtime.GOARCH), *y.Arch) + errs = errors.Join(errs, fmt.Errorf("field `arch` must be %q for VZ; got %q", NewArch(runtime.GOARCH), *y.Arch)) } default: - return fmt.Errorf("field `vmType` must be %q, %q, %q; got %q", QEMU, VZ, WSL2, *y.VMType) + errs = errors.Join(errs, fmt.Errorf("field `vmType` must be %q, %q, %q; got %q", QEMU, VZ, WSL2, *y.VMType)) } if len(y.Images) == 0 { - return errors.New("field `images` must be set") + errs = errors.Join(errs, errors.New("field `images` must be set")) } for i, f := range y.Images { - if err := validateFileObject(f.File, fmt.Sprintf("images[%d]", i)); err != nil { - return err + err := validateFileObject(f.File, fmt.Sprintf("images[%d]", i)) + if err != nil { + errs = errors.Join(errs, err) } if f.Kernel != nil { - if err := validateFileObject(f.Kernel.File, fmt.Sprintf("images[%d].kernel", i)); err != nil { - return err + err := validateFileObject(f.Kernel.File, fmt.Sprintf("images[%d].kernel", i)) + if err != nil { + errs = errors.Join(errs, err) } if f.Kernel.Arch != f.Arch { - return fmt.Errorf("images[%d].kernel has unexpected architecture %q, must be %q", i, f.Kernel.Arch, f.Arch) + errs = errors.Join(errs, fmt.Errorf("images[%d].kernel has unexpected architecture %q, must be %q", i, f.Kernel.Arch, f.Arch)) } } if f.Initrd != nil { - if err := validateFileObject(*f.Initrd, fmt.Sprintf("images[%d].initrd", i)); err != nil { - return err - } - if f.Kernel == nil { - return errors.New("initrd requires the kernel to be specified") + err := validateFileObject(*f.Initrd, fmt.Sprintf("images[%d].initrd", i)) + if err != nil { + errs = errors.Join(errs, err) } if f.Initrd.Arch != f.Arch { - return fmt.Errorf("images[%d].initrd has unexpected architecture %q, must be %q", i, f.Initrd.Arch, f.Arch) + errs = errors.Join(errs, fmt.Errorf("images[%d].initrd has unexpected architecture %q, must be %q", i, f.Initrd.Arch, f.Arch)) } } } for arch := range y.CPUType { if !slices.Contains(ArchTypes, arch) { - return fmt.Errorf("field `cpuType` uses unsupported arch %q", arch) + errs = errors.Join(errs, fmt.Errorf("field `cpuType` uses unsupported arch %q", arch)) } } if *y.CPUs == 0 { - return errors.New("field `cpus` must be set") + errs = errors.Join(errs, errors.New("field `cpus` must be set")) } if _, err := units.RAMInBytes(*y.Memory); err != nil { - return fmt.Errorf("field `memory` has an invalid value: %w", err) + errs = errors.Join(errs, fmt.Errorf("field `memory` has an invalid value: %w", err)) } if _, err := units.RAMInBytes(*y.Disk); err != nil { - return fmt.Errorf("field `memory` has an invalid value: %w", err) + errs = errors.Join(errs, fmt.Errorf("field `disk` has an invalid value: %w", err)) } for i, disk := range y.AdditionalDisks { if err := identifiers.Validate(disk.Name); err != nil { - return fmt.Errorf("field `additionalDisks[%d].name is invalid`: %w", i, err) + errs = errors.Join(errs, fmt.Errorf("field `additionalDisks[%d].name is invalid`: %w", i, err)) } } for i, f := range y.Mounts { if !filepath.IsAbs(f.Location) && !strings.HasPrefix(f.Location, "~") { - return fmt.Errorf("field `mounts[%d].location` must be an absolute path, got %q", - i, f.Location) + errs = errors.Join(errs, fmt.Errorf("field `mounts[%d].location` must be an absolute path, got %q", + i, f.Location)) } // f.Location has already been expanded in FillDefaults(), but that function cannot return errors. loc, err := localpathutil.Expand(f.Location) if err != nil { - return fmt.Errorf("field `mounts[%d].location` refers to an unexpandable path: %q: %w", i, f.Location, err) + errs = errors.Join(errs, fmt.Errorf("field `mounts[%d].location` refers to an unexpandable path: %q: %w", i, f.Location, err)) } st, err := os.Stat(loc) if err != nil { if !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("field `mounts[%d].location` refers to an inaccessible path: %q: %w", i, f.Location, err) + errs = errors.Join(errs, fmt.Errorf("field `mounts[%d].location` refers to an inaccessible path: %q: %w", i, f.Location, err)) } } else if !st.IsDir() { - return fmt.Errorf("field `mounts[%d].location` refers to a non-directory path: %q: %w", i, f.Location, err) + errs = errors.Join(errs, fmt.Errorf("field `mounts[%d].location` refers to a non-directory path: %q: %w", i, f.Location, err)) } switch *f.MountPoint { case "/", "/bin", "/dev", "/etc", "/home", "/opt", "/sbin", "/tmp", "/usr", "/var": - return fmt.Errorf("field `mounts[%d].mountPoint` must not be a system path such as /etc or /usr", i) + errs = errors.Join(errs, fmt.Errorf("field `mounts[%d].mountPoint` must not be a system path such as /etc or /usr", i)) // home directory defined in "cidata.iso:/user-data" case *y.User.Home: - return fmt.Errorf("field `mounts[%d].mountPoint` is the reserved internal home directory %q", i, *y.User.Home) + errs = errors.Join(errs, fmt.Errorf("field `mounts[%d].mountPoint` is the reserved internal home directory %q", i, *y.User.Home)) } // There is no tilde-expansion for guest filenames if strings.HasPrefix(*f.MountPoint, "~") { - return fmt.Errorf("field `mounts[%d].mountPoint` must not start with \"~\"", i) + errs = errors.Join(errs, fmt.Errorf("field `mounts[%d].mountPoint` must not start with \"~\"", i)) } if _, err := units.RAMInBytes(*f.NineP.Msize); err != nil { - return fmt.Errorf("field `msize` has an invalid value: %w", err) + errs = errors.Join(errs, fmt.Errorf("field `msize` has an invalid value: %w", err)) } } if *y.SSH.LocalPort != 0 { if err := validatePort("ssh.localPort", *y.SSH.LocalPort); err != nil { - return err + errs = errors.Join(errs, err) } } switch *y.MountType { case REVSSHFS, NINEP, VIRTIOFS, WSLMount: default: - return fmt.Errorf("field `mountType` must be %q or %q or %q, or %q, got %q", REVSSHFS, NINEP, VIRTIOFS, WSLMount, *y.MountType) + errs = errors.Join(errs, fmt.Errorf("field `mountType` must be %q or %q or %q, or %q, got %q", REVSSHFS, NINEP, VIRTIOFS, WSLMount, *y.MountType)) } if slices.Contains(y.MountTypesUnsupported, *y.MountType) { - return fmt.Errorf("field `mountType` must not be one of %v (`mountTypesUnsupported`), got %q", y.MountTypesUnsupported, *y.MountType) + errs = errors.Join(errs, fmt.Errorf("field `mountType` must not be one of %v (`mountTypesUnsupported`), got %q", y.MountTypesUnsupported, *y.MountType)) } if warn && runtime.GOOS != "linux" { @@ -209,66 +213,69 @@ func Validate(y *LimaYAML, warn bool) error { for i, p := range y.Provision { if p.File != nil { if p.File.URL != "" { - return fmt.Errorf("field `provision[%d].file.url` must be empty during validation (script should already be embedded)", i) + errs = errors.Join(errs, fmt.Errorf("field `provision[%d].file.url` must be empty during validation (script should already be embedded)", i)) } if p.File.Digest != nil { - return fmt.Errorf("field `provision[%d].file.digest` support is not yet implemented", i) + errs = errors.Join(errs, fmt.Errorf("field `provision[%d].file.digest` support is not yet implemented", i)) } } switch p.Mode { case ProvisionModeSystem, ProvisionModeUser, ProvisionModeBoot, ProvisionModeData, ProvisionModeDependency, ProvisionModeAnsible: default: - return fmt.Errorf("field `provision[%d].mode` must one of %q, %q, %q, %q, %q, or %q", - i, ProvisionModeSystem, ProvisionModeUser, ProvisionModeBoot, ProvisionModeDependency, ProvisionModeAnsible, ProvisionModeAnsible) + errs = errors.Join(errs, fmt.Errorf("field `provision[%d].mode` must one of %q, %q, %q, %q, %q, or %q", + i, ProvisionModeSystem, ProvisionModeUser, ProvisionModeBoot, ProvisionModeDependency, ProvisionModeAnsible, ProvisionModeAnsible)) } if p.Mode != ProvisionModeDependency && p.SkipDefaultDependencyResolution != nil { - return fmt.Errorf("field `provision[%d].mode` cannot set skipDefaultDependencyResolution, only valid on scripts of type %q", - i, ProvisionModeDependency) + errs = errors.Join(errs, fmt.Errorf("field `provision[%d].mode` cannot set skipDefaultDependencyResolution, only valid on scripts of type %q", + i, ProvisionModeDependency)) } + + // This can lead to fatal Panic if p.Path is nil, better to return an error here if p.Mode == ProvisionModeData { if p.Path == nil { - return fmt.Errorf("field `provision[%d].path` must not be empty when mode is %q", i, ProvisionModeData) + errs = errors.Join(errs, fmt.Errorf("field `provision[%d].path` must not be empty when mode is %q", i, ProvisionModeData)) + return errs } if !path.IsAbs(*p.Path) { - return fmt.Errorf("field `provision[%d].path` must be an absolute path", i) + errs = errors.Join(errs, fmt.Errorf("field `provision[%d].path` must be an absolute path", i)) } if p.Content == nil { - return fmt.Errorf("field `provision[%d].content` must not be empty when mode is %q", i, ProvisionModeData) + errs = errors.Join(errs, fmt.Errorf("field `provision[%d].content` must not be empty when mode is %q", i, ProvisionModeData)) } // FillDefaults makes sure that p.Permissions is not nil if _, err := strconv.ParseInt(*p.Permissions, 8, 64); err != nil { - return fmt.Errorf("field `provision[%d].permissions` must be an octal number: %w", i, err) + errs = errors.Join(errs, fmt.Errorf("field `provision[%d].permissions` must be an octal number: %w", i, err)) } } else { if p.Script == "" && p.Mode != ProvisionModeAnsible { - return fmt.Errorf("field `provision[%d].script` must not be empty", i) + errs = errors.Join(errs, fmt.Errorf("field `provision[%d].script` must not be empty", i)) } if p.Content != nil { - return fmt.Errorf("field `provision[%d].content` can only be set when mode is %q", i, ProvisionModeData) + errs = errors.Join(errs, fmt.Errorf("field `provision[%d].content` can only be set when mode is %q", i, ProvisionModeData)) } if p.Overwrite != nil { - return fmt.Errorf("field `provision[%d].overwrite` can only be set when mode is %q", i, ProvisionModeData) + errs = errors.Join(errs, fmt.Errorf("field `provision[%d].overwrite` can only be set when mode is %q", i, ProvisionModeData)) } if p.Owner != nil { - return fmt.Errorf("field `provision[%d].owner` can only be set when mode is %q", i, ProvisionModeData) + errs = errors.Join(errs, fmt.Errorf("field `provision[%d].owner` can only be set when mode is %q", i, ProvisionModeData)) } if p.Path != nil { - return fmt.Errorf("field `provision[%d].path` can only be set when mode is %q", i, ProvisionModeData) + errs = errors.Join(errs, fmt.Errorf("field `provision[%d].path` can only be set when mode is %q", i, ProvisionModeData)) } if p.Permissions != nil { - return fmt.Errorf("field `provision[%d].permissions` can only be set when mode is %q", i, ProvisionModeData) + errs = errors.Join(errs, fmt.Errorf("field `provision[%d].permissions` can only be set when mode is %q", i, ProvisionModeData)) } } if p.Playbook != "" { if p.Mode != ProvisionModeAnsible { - return fmt.Errorf("field `provision[%d].playbook can only be set when mode is %q", i, ProvisionModeAnsible) + errs = errors.Join(errs, fmt.Errorf("field `provision[%d].playbook can only be set when mode is %q", i, ProvisionModeAnsible)) } if p.Script != "" { - return fmt.Errorf("field `provision[%d].script must be empty if playbook is set", i) + errs = errors.Join(errs, fmt.Errorf("field `provision[%d].script must be empty if playbook is set", i)) } playbook := p.Playbook if _, err := os.Stat(playbook); err != nil { - return fmt.Errorf("field `provision[%d].playbook` refers to an inaccessible path: %q: %w", i, playbook, err) + errs = errors.Join(errs, fmt.Errorf("field `provision[%d].playbook` refers to an inaccessible path: %q: %w", i, playbook, err)) } logrus.Warnf("provision mode %q is deprecated, use `ansible-playbook %q` instead", ProvisionModeAnsible, playbook) } @@ -279,109 +286,110 @@ func Validate(y *LimaYAML, warn bool) error { needsContainerdArchives := (y.Containerd.User != nil && *y.Containerd.User) || (y.Containerd.System != nil && *y.Containerd.System) if needsContainerdArchives { if len(y.Containerd.Archives) == 0 { - return errors.New("field `containerd.archives` must be provided") + errs = errors.Join(errs, errors.New("field `containerd.archives` must be provided")) } for i, f := range y.Containerd.Archives { - if err := validateFileObject(f, fmt.Sprintf("containerd.archives[%d]", i)); err != nil { - return err + err := validateFileObject(f, fmt.Sprintf("containerd.archives[%d]", i)) + if err != nil { + errs = errors.Join(errs, err) } } } for i, p := range y.Probes { if p.File != nil { if p.File.URL != "" { - return fmt.Errorf("field `probe[%d].file.url` must be empty during validation (script should already be embedded)", i) + errs = errors.Join(errs, fmt.Errorf("field `probe[%d].file.url` must be empty during validation (script should already be embedded)", i)) } if p.File.Digest != nil { - return fmt.Errorf("field `probe[%d].file.digest` support is not yet implemented", i) + errs = errors.Join(errs, fmt.Errorf("field `probe[%d].file.digest` support is not yet implemented", i)) } } if !strings.HasPrefix(p.Script, "#!") { - return fmt.Errorf("field `probe[%d].script` must start with a '#!' line", i) + errs = errors.Join(errs, fmt.Errorf("field `probe[%d].script` must start with a '#!' line", i)) } switch p.Mode { case ProbeModeReadiness: default: - return fmt.Errorf("field `probe[%d].mode` can only be %q", i, ProbeModeReadiness) + errs = errors.Join(errs, fmt.Errorf("field `probe[%d].mode` can only be %q", i, ProbeModeReadiness)) } } for i, rule := range y.PortForwards { field := fmt.Sprintf("portForwards[%d]", i) if rule.GuestIPMustBeZero && !rule.GuestIP.Equal(net.IPv4zero) { - return fmt.Errorf("field `%s.guestIPMustBeZero` can only be true when field `%s.guestIP` is 0.0.0.0", field, field) + errs = errors.Join(errs, fmt.Errorf("field `%s.guestIPMustBeZero` can only be true when field `%s.guestIP` is 0.0.0.0", field, field)) } if rule.GuestPort != 0 { if rule.GuestSocket != "" { - return fmt.Errorf("field `%s.guestPort` must be 0 when field `%s.guestSocket` is set", field, field) + errs = errors.Join(errs, fmt.Errorf("field `%s.guestPort` must be 0 when field `%s.guestSocket` is set", field, field)) } if rule.GuestPort != rule.GuestPortRange[0] { - return fmt.Errorf("field `%s.guestPort` must match field `%s.guestPortRange[0]`", field, field) + errs = errors.Join(errs, fmt.Errorf("field `%s.guestPort` must match field `%s.guestPortRange[0]`", field, field)) } // redundant validation to make sure the error contains the correct field name if err := validatePort(field+".guestPort", rule.GuestPort); err != nil { - return err + errs = errors.Join(errs, err) } } if rule.HostPort != 0 { if rule.HostSocket != "" { - return fmt.Errorf("field `%s.hostPort` must be 0 when field `%s.hostSocket` is set", field, field) + errs = errors.Join(errs, fmt.Errorf("field `%s.hostPort` must be 0 when field `%s.hostSocket` is set", field, field)) } if rule.HostPort != rule.HostPortRange[0] { - return fmt.Errorf("field `%s.hostPort` must match field `%s.hostPortRange[0]`", field, field) + errs = errors.Join(errs, fmt.Errorf("field `%s.hostPort` must match field `%s.hostPortRange[0]`", field, field)) } // redundant validation to make sure the error contains the correct field name if err := validatePort(field+".hostPort", rule.HostPort); err != nil { - return err + errs = errors.Join(errs, err) } } for j := range 2 { if err := validatePort(fmt.Sprintf("%s.guestPortRange[%d]", field, j), rule.GuestPortRange[j]); err != nil { - return err + errs = errors.Join(errs, err) } if err := validatePort(fmt.Sprintf("%s.hostPortRange[%d]", field, j), rule.HostPortRange[j]); err != nil { - return err + errs = errors.Join(errs, err) } } if rule.GuestPortRange[0] > rule.GuestPortRange[1] { - return fmt.Errorf("field `%s.guestPortRange[1]` must be greater than or equal to field `%s.guestPortRange[0]`", field, field) + errs = errors.Join(errs, fmt.Errorf("field `%s.guestPortRange[1]` must be greater than or equal to field `%s.guestPortRange[0]`", field, field)) } if rule.HostPortRange[0] > rule.HostPortRange[1] { - return fmt.Errorf("field `%s.hostPortRange[1]` must be greater than or equal to field `%s.hostPortRange[0]`", field, field) + errs = errors.Join(errs, fmt.Errorf("field `%s.hostPortRange[1]` must be greater than or equal to field `%s.hostPortRange[0]`", field, field)) } if rule.GuestPortRange[1]-rule.GuestPortRange[0] != rule.HostPortRange[1]-rule.HostPortRange[0] { - return fmt.Errorf("field `%s.hostPortRange` must specify the same number of ports as field `%s.guestPortRange`", field, field) + errs = errors.Join(errs, fmt.Errorf("field `%s.hostPortRange` must specify the same number of ports as field `%s.guestPortRange`", field, field)) } if rule.GuestSocket != "" { if !path.IsAbs(rule.GuestSocket) { - return fmt.Errorf("field `%s.guestSocket` must be an absolute path, but is %q", field, rule.GuestSocket) + errs = errors.Join(errs, fmt.Errorf("field `%s.guestSocket` must be an absolute path, but is %q", field, rule.GuestSocket)) } if rule.HostSocket == "" && rule.HostPortRange[1]-rule.HostPortRange[0] > 0 { - return fmt.Errorf("field `%s.guestSocket` can only be mapped to a single port or socket. not a range", field) + errs = errors.Join(errs, fmt.Errorf("field `%s.guestSocket` can only be mapped to a single port or socket. not a range", field)) } } if rule.HostSocket != "" { if !filepath.IsAbs(rule.HostSocket) { // should be unreachable because FillDefault() will prepend the instance directory to relative names - return fmt.Errorf("field `%s.hostSocket` must be an absolute path, but is %q", field, rule.HostSocket) + errs = errors.Join(errs, fmt.Errorf("field `%s.hostSocket` must be an absolute path, but is %q", field, rule.HostSocket)) } if rule.GuestSocket == "" && rule.GuestPortRange[1]-rule.GuestPortRange[0] > 0 { - return fmt.Errorf("field `%s.hostSocket` can only be mapped from a single port or socket. not a range", field) + errs = errors.Join(errs, fmt.Errorf("field `%s.hostSocket` can only be mapped from a single port or socket. not a range", field)) } } if len(rule.HostSocket) >= osutil.UnixPathMax { - return fmt.Errorf("field `%s.hostSocket` must be less than UNIX_PATH_MAX=%d characters, but is %d", - field, osutil.UnixPathMax, len(rule.HostSocket)) + errs = errors.Join(errs, fmt.Errorf("field `%s.hostSocket` must be less than UNIX_PATH_MAX=%d characters, but is %d", + field, osutil.UnixPathMax, len(rule.HostSocket))) } switch rule.Proto { case ProtoTCP, ProtoUDP, ProtoAny: default: - return fmt.Errorf("field `%s.proto` must be %q, %q, or %q", field, ProtoTCP, ProtoUDP, ProtoAny) + errs = errors.Join(errs, fmt.Errorf("field `%s.proto` must be %q, %q, or %q", field, ProtoTCP, ProtoUDP, ProtoAny)) } if rule.Reverse && rule.GuestSocket == "" { - return fmt.Errorf("field `%s.reverse` must be %t", field, false) + errs = errors.Join(errs, fmt.Errorf("field `%s.reverse` must be %t", field, false)) } if rule.Reverse && rule.HostSocket == "" { - return fmt.Errorf("field `%s.reverse` must be %t", field, false) + errs = errors.Join(errs, fmt.Errorf("field `%s.reverse` must be %t", field, false)) } // Not validating that the various GuestPortRanges and HostPortRanges are not overlapping. Rules will be // processed sequentially and the first matching rule for a guest port determines forwarding behavior. @@ -390,23 +398,25 @@ func Validate(y *LimaYAML, warn bool) error { field := fmt.Sprintf("CopyToHost[%d]", i) if rule.GuestFile != "" { if !path.IsAbs(rule.GuestFile) { - return fmt.Errorf("field `%s.guest` must be an absolute path, but is %q", field, rule.GuestFile) + errs = errors.Join(errs, fmt.Errorf("field `%s.guest` must be an absolute path, but is %q", field, rule.GuestFile)) } } if rule.HostFile != "" { if !filepath.IsAbs(rule.HostFile) { - return fmt.Errorf("field `%s.host` must be an absolute path, but is %q", field, rule.HostFile) + errs = errors.Join(errs, fmt.Errorf("field `%s.host` must be an absolute path, but is %q", field, rule.HostFile)) } } } if y.HostResolver.Enabled != nil && *y.HostResolver.Enabled && len(y.DNS) > 0 { - return errors.New("field `dns` must be empty when field `HostResolver.Enabled` is true") + errs = errors.Join(errs, errors.New("field `dns` must be empty when field `HostResolver.Enabled` is true")) } - if err := validateNetwork(y); err != nil { - return err + err := validateNetwork(y) + if err != nil { + errs = errors.Join(errs, err) } + if warn { warnExperimental(y) } @@ -416,19 +426,20 @@ func Validate(y *LimaYAML, warn bool) error { validParamName := regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]*$`) for param, value := range y.Param { if !validParamName.MatchString(param) { - return fmt.Errorf("param %q name does not match regex %q", param, validParamName.String()) + errs = errors.Join(errs, fmt.Errorf("param %q name does not match regex %q", param, validParamName.String())) } for _, r := range value { if !unicode.IsPrint(r) && r != '\t' && r != ' ' { - return fmt.Errorf("param %q value contains unprintable character %q", param, r) + errs = errors.Join(errs, fmt.Errorf("param %q value contains unprintable character %q", param, r)) } } } - return nil + return errs } func validateNetwork(y *LimaYAML) error { + var errs error interfaceName := make(map[string]int) for i, nw := range y.Networks { field := fmt.Sprintf("networks[%d]", i) @@ -439,68 +450,69 @@ func validateNetwork(y *LimaYAML) error { return err } if nwCfg.Check(nw.Lima) != nil { - return fmt.Errorf("field `%s.lima` references network %q which is not defined in networks.yaml", field, nw.Lima) + errs = errors.Join(errs, fmt.Errorf("field `%s.lima` references network %q which is not defined in networks.yaml", field, nw.Lima)) } usernet, err := nwCfg.Usernet(nw.Lima) if err != nil { return err } if !usernet && runtime.GOOS != "darwin" { - return fmt.Errorf("field `%s.lima` is only supported on macOS right now", field) + errs = errors.Join(errs, fmt.Errorf("field `%s.lima` is only supported on macOS right now", field)) } if nw.Socket != "" { - return fmt.Errorf("field `%s.lima` and field `%s.socket` are mutually exclusive", field, field) + errs = errors.Join(errs, fmt.Errorf("field `%s.lima` and field `%s.socket` are mutually exclusive", field, field)) } if nw.VZNAT != nil && *nw.VZNAT { - return fmt.Errorf("field `%s.lima` and field `%s.vzNAT` are mutually exclusive", field, field) + errs = errors.Join(errs, fmt.Errorf("field `%s.lima` and field `%s.vzNAT` are mutually exclusive", field, field)) } case nw.Socket != "": if nw.VZNAT != nil && *nw.VZNAT { - return fmt.Errorf("field `%s.socket` and field `%s.vzNAT` are mutually exclusive", field, field) + errs = errors.Join(errs, fmt.Errorf("field `%s.socket` and field `%s.vzNAT` are mutually exclusive", field, field)) } if fi, err := os.Stat(nw.Socket); err != nil && !errors.Is(err, os.ErrNotExist) { - return err + errs = errors.Join(errs, err) } else if err == nil && fi.Mode()&os.ModeSocket == 0 { - return fmt.Errorf("field `%s.socket` %q points to a non-socket file", field, nw.Socket) + errs = errors.Join(errs, fmt.Errorf("field `%s.socket` %q points to a non-socket file", field, nw.Socket)) } case nw.VZNAT != nil && *nw.VZNAT: if y.VMType == nil || *y.VMType != VZ { - return fmt.Errorf("field `%s.vzNAT` requires `vmType` to be %q", field, VZ) + errs = errors.Join(errs, fmt.Errorf("field `%s.vzNAT` requires `vmType` to be %q", field, VZ)) } if nw.Lima != "" { - return fmt.Errorf("field `%s.vzNAT` and field `%s.lima` are mutually exclusive", field, field) + errs = errors.Join(errs, fmt.Errorf("field `%s.vzNAT` and field `%s.lima` are mutually exclusive", field, field)) } if nw.Socket != "" { - return fmt.Errorf("field `%s.vzNAT` and field `%s.socket` are mutually exclusive", field, field) + errs = errors.Join(errs, fmt.Errorf("field `%s.vzNAT` and field `%s.socket` are mutually exclusive", field, field)) } default: - return fmt.Errorf("field `%s.lima` or field `%s.socket must be set", field, field) + errs = errors.Join(errs, fmt.Errorf("field `%s.lima` or field `%s.socket must be set", field, field)) } if nw.MACAddress != "" { hw, err := net.ParseMAC(nw.MACAddress) if err != nil { - return fmt.Errorf("field `vmnet.mac` invalid: %w", err) + errs = errors.Join(errs, fmt.Errorf("field `vmnet.mac` invalid: %w", err)) } if len(hw) != 6 { - return fmt.Errorf("field `%s.macAddress` must be a 48 bit (6 bytes) MAC address; actual length of %q is %d bytes", field, nw.MACAddress, len(hw)) + errs = errors.Join(errs, fmt.Errorf("field `%s.macAddress` must be a 48 bit (6 bytes) MAC address; actual length of %q is %d bytes", field, nw.MACAddress, len(hw))) } } // FillDefault() will make sure that nw.Interface is not the empty string if len(nw.Interface) >= 16 { - return fmt.Errorf("field `%s.interface` must be less than 16 bytes, but is %d bytes: %q", field, len(nw.Interface), nw.Interface) + errs = errors.Join(errs, fmt.Errorf("field `%s.interface` must be less than 16 bytes, but is %d bytes: %q", field, len(nw.Interface), nw.Interface)) } if strings.ContainsAny(nw.Interface, " \t\n/") { - return fmt.Errorf("field `%s.interface` must not contain whitespace or slashes", field) + errs = errors.Join(errs, fmt.Errorf("field `%s.interface` must not contain whitespace or slashes", field)) } if nw.Interface == networks.SlirpNICName { - return fmt.Errorf("field `%s.interface` must not be set to %q because it is reserved for slirp", field, networks.SlirpNICName) + errs = errors.Join(errs, fmt.Errorf("field `%s.interface` must not be set to %q because it is reserved for slirp", field, networks.SlirpNICName)) } if prev, ok := interfaceName[nw.Interface]; ok { - return fmt.Errorf("field `%s.interface` value %q has already been used by field `networks[%d].interface`", field, nw.Interface, prev) + errs = errors.Join(errs, fmt.Errorf("field `%s.interface` value %q has already been used by field `networks[%d].interface`", field, nw.Interface, prev)) } interfaceName[nw.Interface] = i } - return nil + + return errs } // ValidateParamIsUsed checks if the keys in the `param` field are used in any script, probe, copyToHost, or portForward. diff --git a/pkg/limayaml/validate_test.go b/pkg/limayaml/validate_test.go index ed2233822b5..ec1de61df80 100644 --- a/pkg/limayaml/validate_test.go +++ b/pkg/limayaml/validate_test.go @@ -37,7 +37,8 @@ func TestValidateProbes(t *testing.T) { assert.NilError(t, err) err = Validate(y, false) - assert.Error(t, err, "field `probe[0].file.digest` support is not yet implemented") + assert.Error(t, err, "field `probe[0].file.digest` support is not yet implemented\n"+ + "field `probe[0].script` must start with a '#!' line") } func TestValidateProvisionData(t *testing.T) { @@ -202,3 +203,32 @@ func TestValidateParamIsUsed(t *testing.T) { assert.Error(t, err, "field `param` key \"rootFul\" is not used in any provision, probe, copyToHost, or portForward") } } + +func TestValidateMultipleErrors(t *testing.T) { + yamlWithMultipleErrors := ` +os: windows +arch: unsupported_arch +vmType: invalid_type +portForwards: + - guestPort: 22 + hostPort: 2222 + - guestPort: 8080 + hostPort: 65536 +provision: + - mode: invalid_mode + script: echo test + - mode: data + content: test +` + + y, err := Load([]byte(yamlWithMultipleErrors), "multiple-errors.yaml") + assert.NilError(t, err) + err = Validate(y, false) + + assert.Error(t, err, "field `os` must be \"Linux\"; got \"windows\"\n"+ + "field `arch` must be one of [x86_64 aarch64 armv7l ppc64le riscv64 s390x]; got \"unsupported_arch\"\n"+ + "field `vmType` must be \"qemu\", \"vz\", \"wsl2\"; got \"invalid_type\"\n"+ + "field `images` must be set\n"+ + "field `provision[0].mode` must one of \"system\", \"user\", \"boot\", \"dependency\", \"ansible\", or \"ansible\"\n"+ + "field `provision[1].path` must not be empty when mode is \"data\"") +}