Files
lima/pkg/limayaml/validate.go
T
Eugene Kalinin 1bf6ec2ee8 additionalDisks: support custom mountPoint and optional mounting
Additional disks were always mounted at the hardcoded path
/mnt/lima-<name>, with no way to change that location or to attach a disk
without Lima mounting it. Issue #4065 asks for both: a custom mount point
and an attach-only mode so the guest can manage the disk itself (a
flexibility Colima can benefit from).

Add two optional, backwards-compatible fields to each additionalDisks
entry:
- mountPoint: absolute guest path to mount at (default /mnt/lima-<name>)
- mount: whether Lima auto-mounts the disk (default true; false attaches
  the disk without mounting it)

A separate `mount` boolean is used rather than overloading an empty
mountPoint, per the discussion in #4065. nil for either field preserves
the current behavior. Values are passed to the guest via
LIMA_CIDATA_DISK_<i>_MOUNT / _MOUNTPOINT and honored by 05-lima-disks.sh
(non-swap disks only). mountPoint is validated to be an absolute guest
path (path.IsAbs, not the host-dependent filepath.IsAbs) and neither a
critical system path nor the reserved guest home directory.

Fixes #4065

Signed-off-by: Eugene Kalinin <e.v.kalinin@gmail.com>
2026-10-02 15:51:20 +03:00

780 lines
31 KiB
Go

// SPDX-FileCopyrightText: Copyright The Lima Authors
// SPDX-License-Identifier: Apache-2.0
package limayaml
import (
"context"
"errors"
"fmt"
"net"
"os"
"path"
"path/filepath"
"regexp"
"runtime"
"slices"
"strconv"
"strings"
"unicode"
"github.com/docker/go-units"
"github.com/sirupsen/logrus"
"github.com/lima-vm/lima/v2/pkg/blockdevice"
"github.com/lima-vm/lima/v2/pkg/identifiers"
"github.com/lima-vm/lima/v2/pkg/limatype"
"github.com/lima-vm/lima/v2/pkg/localpathutil"
"github.com/lima-vm/lima/v2/pkg/networks"
"github.com/lima-vm/lima/v2/pkg/osutil"
"github.com/lima-vm/lima/v2/pkg/version"
"github.com/lima-vm/lima/v2/pkg/version/versionutil"
)
func Validate(y *limatype.LimaYAML, warn bool) error {
var errs error
if len(y.Base) > 0 {
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 {
errs = errors.Join(errs, fmt.Errorf("field `minimumLimaVersion` must be a semvar value, got %#q: %w", *y.MinimumLimaVersion, err))
}
// Unparsable version.Version (like commit hashes or "<unknown>") is treated as "latest/greatest"
// and will pass all version comparisons, allowing development builds to work.
if !versionutil.GreaterEqual(version.Version, *y.MinimumLimaVersion) {
errs = errors.Join(errs, fmt.Errorf("template requires Lima version %#q; this is only %#q", *y.MinimumLimaVersion, version.Version))
}
}
switch *y.OS {
case limatype.LINUX, limatype.DARWIN, limatype.FREEBSD:
case limatype.WINDOWS:
if *y.VMType != limatype.QEMU {
errs = errors.Join(errs, fmt.Errorf("currently Windows guest is only supported on %#q; got %#q", limatype.QEMU, *y.VMType))
}
if !slices.Contains([]limatype.Arch{limatype.X8664, limatype.AARCH64}, *y.Arch) {
errs = errors.Join(errs, fmt.Errorf("currently Windows guest is only supported on [%#q, %#q]; got %#q", limatype.X8664, limatype.AARCH64, *y.Arch))
}
default:
errs = errors.Join(errs, fmt.Errorf("field `os` must be one of %#q; got %#q", limatype.OSTypes, *y.OS))
}
if !slices.Contains(limatype.ArchTypes, *y.Arch) {
errs = errors.Join(errs, fmt.Errorf("field `arch` must be one of %v; got %#q", limatype.ArchTypes, *y.Arch))
}
errs = errors.Join(errs, validateBlockDevices(y.BlockDevices))
if y.User.Shell != nil {
shell := *y.User.Shell
if *y.OS == limatype.WINDOWS {
if !IsSupportedWindowsShell(shell) {
errs = errors.Join(errs, fmt.Errorf("field `user.shell` must be one of %v for Windows guest, got %#q", SupportedWindowsShells, shell))
}
} else if !path.IsAbs(shell) {
errs = errors.Join(errs, fmt.Errorf("field `user.shell` must be an absolute path, got %#q", shell))
}
}
if len(y.Images) == 0 {
errs = errors.Join(errs, errors.New("field `images` must be set"))
}
for i, f := range y.Images {
err := validateFileObject(f.File, fmt.Sprintf("images[%d]", i))
if err != nil {
errs = errors.Join(errs, err)
}
if f.Kernel != nil {
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 {
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 {
err := validateFileObject(*f.Initrd, fmt.Sprintf("images[%d].initrd", i))
if err != nil {
errs = errors.Join(errs, err)
}
if 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))
}
}
}
if *y.CPUs == 0 {
errs = errors.Join(errs, errors.New("field `cpus` must be set"))
}
if _, err := units.RAMInBytes(*y.Memory); err != nil {
errs = errors.Join(errs, fmt.Errorf("field `memory` has an invalid value: %w", err))
}
if _, err := units.RAMInBytes(*y.Disk); err != nil {
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 {
errs = errors.Join(errs, fmt.Errorf("field `additionalDisks[%d].name is invalid`: %w", i, err))
}
if disk.MountPoint != nil {
// This is a guest path, so use path.IsAbs (forward-slash) rather than the
// host-OS-dependent filepath.IsAbs. There is no tilde-expansion for guest
// filenames, so the mount point must be an absolute path.
if !path.IsAbs(*disk.MountPoint) {
errs = errors.Join(errs, fmt.Errorf("field `additionalDisks[%d].mountPoint` must be an absolute path, got %#q", i, *disk.MountPoint))
}
switch *disk.MountPoint {
case "/", "/bin", "/dev", "/etc", "/home", "/opt", "/sbin", "/tmp", "/usr", "/var":
errs = errors.Join(errs, fmt.Errorf("field `additionalDisks[%d].mountPoint` must not be a critical system path such as /etc or /usr", i))
// home directory defined in "cidata.iso:/user-data"
case *y.User.Home:
errs = errors.Join(errs, fmt.Errorf("field `additionalDisks[%d].mountPoint` is the reserved internal home directory %#q", i, *y.User.Home))
}
}
}
for i, f := range y.Mounts {
if !filepath.IsAbs(f.Location) && !strings.HasPrefix(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 {
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) {
errs = errors.Join(errs, fmt.Errorf("field `mounts[%d].location` refers to an inaccessible path: %#q: %w", i, f.Location, err))
}
if warn {
logrus.Warnf("field `mounts[%d].location` refers to a non-existent directory: %#q:", i, f.Location)
}
} else if !st.IsDir() {
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":
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:
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, "~") {
errs = errors.Join(errs, fmt.Errorf("field `mounts[%d].mountPoint` must not start with `~`", i))
}
if _, err := units.RAMInBytes(*f.NineP.Msize); err != nil {
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 {
errs = errors.Join(errs, err)
}
}
if y.MountType != nil {
switch *y.MountType {
case limatype.REVSSHFS, limatype.NINEP, limatype.VIRTIOFS, limatype.WSLMount:
default:
errs = errors.Join(errs, fmt.Errorf("field `mountType` must be %#q or %#q or %#q, or %#q, got %#q", limatype.REVSSHFS, limatype.NINEP, limatype.VIRTIOFS, limatype.WSLMount, *y.MountType))
}
if slices.Contains(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" {
for i, mount := range y.Mounts {
if mount.Virtiofs.QueueSize != nil {
logrus.Warnf("field mounts[%d].virtiofs.queueSize is only supported on Linux", i)
}
}
}
// y.Firmware.LegacyBIOS is ignored for aarch64, but not a fatal error.
for i, p := range y.Provision {
if p.File != nil {
if p.File.URL != "" {
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 {
errs = errors.Join(errs, fmt.Errorf("field `provision[%d].file.digest` support is not yet implemented", i))
}
}
switch p.Mode {
case limatype.ProvisionModeSystem, limatype.ProvisionModeUser, limatype.ProvisionModeBoot, limatype.ProvisionModeData, limatype.ProvisionModeDependency, limatype.ProvisionModeAnsible, limatype.ProvisionModeYQ:
default:
errs = errors.Join(errs, fmt.Errorf("field `provision[%d].mode` must one of %#q, %#q, %#q, %#q, %#q, %#q, or %#q",
i, limatype.ProvisionModeSystem, limatype.ProvisionModeUser, limatype.ProvisionModeBoot, limatype.ProvisionModeData, limatype.ProvisionModeDependency, limatype.ProvisionModeAnsible, limatype.ProvisionModeYQ))
}
if p.Mode != limatype.ProvisionModeDependency && p.SkipDefaultDependencyResolution != nil {
errs = errors.Join(errs, fmt.Errorf("field `provision[%d].mode` cannot set skipDefaultDependencyResolution, only valid on scripts of type %#q",
i, limatype.ProvisionModeDependency))
}
if *y.OS == limatype.WINDOWS && (p.Mode == limatype.ProvisionModeAnsible || p.Mode == limatype.ProvisionModeBoot || p.Mode == limatype.ProvisionModeYQ) {
errs = errors.Join(errs, fmt.Errorf("provision mode %#q is not supported on Windows VM", p.Mode))
}
// This can lead to fatal Panic if p.Path is nil, better to return an error here
switch p.Mode {
case limatype.ProvisionModeData, limatype.ProvisionModeYQ:
if p.Path == nil {
errs = errors.Join(errs, fmt.Errorf("field `provision[%d].path` must not be empty when mode is %#q", i, p.Mode))
return errs
}
if !path.IsAbs(*p.Path) {
errs = errors.Join(errs, fmt.Errorf("field `provision[%d].path` must be an absolute path", i))
}
if p.Mode == limatype.ProvisionModeData && p.Content == nil {
errs = errors.Join(errs, fmt.Errorf("field `provision[%d].content` must not be empty when mode is %#q", i, p.Mode))
}
if p.Mode == limatype.ProvisionModeYQ && p.Expression == nil {
errs = errors.Join(errs, fmt.Errorf("field `provision[%d].expression` must not be empty when mode is %#q", i, p.Mode))
}
// FillDefaults makes sure that p.Permissions is not nil
if _, err := strconv.ParseInt(*p.Permissions, 8, 64); err != nil {
errs = errors.Join(errs, fmt.Errorf("field `provision[%d].permissions` must be an octal number: %w", i, err))
}
default:
if (p.Script == nil || *p.Script == "") && p.Mode != limatype.ProvisionModeAnsible {
errs = errors.Join(errs, fmt.Errorf("field `provision[%d].script` must not be empty", i))
}
if p.Content != nil {
errs = errors.Join(errs, fmt.Errorf("field `provision[%d].content` can only be set when mode is %#q", i, limatype.ProvisionModeData))
}
if p.Overwrite != nil {
errs = errors.Join(errs, fmt.Errorf("field `provision[%d].overwrite` can only be set when mode is %#q", i, limatype.ProvisionModeData))
}
if p.Owner != nil {
errs = errors.Join(errs, fmt.Errorf("field `provision[%d].owner` can only be set when mode is %#q", i, limatype.ProvisionModeData))
}
if p.Path != nil {
errs = errors.Join(errs, fmt.Errorf("field `provision[%d].path` can only be set when mode is %#q, or %#q", i, limatype.ProvisionModeData, limatype.ProvisionModeYQ))
}
if p.Permissions != nil {
errs = errors.Join(errs, fmt.Errorf("field `provision[%d].permissions` can only be set when mode is %#q, or %#q", i, limatype.ProvisionModeData, limatype.ProvisionModeYQ))
}
if p.Format != nil {
errs = errors.Join(errs, fmt.Errorf("field `provision[%d].format` can only be set when mode is %#q", i, limatype.ProvisionModeYQ))
}
}
if p.Playbook != "" {
if p.Mode != limatype.ProvisionModeAnsible {
errs = errors.Join(errs, fmt.Errorf("field `provision[%d].playbook can only be set when mode is %#q", i, limatype.ProvisionModeAnsible))
}
if p.Script != nil && *p.Script != "" {
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 {
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", limatype.ProvisionModeAnsible, playbook)
}
if p.Script != nil {
if strings.Contains(*p.Script, "LIMA_CIDATA") {
logrus.Warn("provisioning scripts should not reference the LIMA_CIDATA variables")
}
}
}
needsContainerdArchives := (y.Containerd.User != nil && *y.Containerd.User) || (y.Containerd.System != nil && *y.Containerd.System)
if needsContainerdArchives {
if len(y.Containerd.Archives) == 0 {
errs = errors.Join(errs, errors.New("field `containerd.archives` must be provided"))
}
for i, f := range y.Containerd.Archives {
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 != "" {
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 {
errs = errors.Join(errs, fmt.Errorf("field `probe[%d].file.digest` support is not yet implemented", i))
}
}
if p.Script != nil && !strings.HasPrefix(*p.Script, "#!") {
errs = errors.Join(errs, fmt.Errorf("field `probe[%d].script` must start with a '#!' line", i))
}
switch p.Mode {
case limatype.ProbeModeReadiness:
default:
errs = errors.Join(errs, fmt.Errorf("field `probe[%d].mode` can only be %#q", i, limatype.ProbeModeReadiness))
}
}
for i, rule := range y.PortForwards {
field := fmt.Sprintf("portForwards[%d]", i)
if *rule.GuestIPMustBeZero && !rule.GuestIP.Equal(net.IPv4zero) {
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 != "" {
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] {
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 {
errs = errors.Join(errs, err)
}
}
if rule.HostPort != 0 {
if rule.HostSocket != "" {
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] {
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 {
errs = errors.Join(errs, err)
}
}
for j := range 2 {
if err := validatePort(fmt.Sprintf("%s.guestPortRange[%d]", field, j), rule.GuestPortRange[j]); err != nil {
errs = errors.Join(errs, err)
}
if rule.HostSocket == "" {
if err := validatePort(fmt.Sprintf("%s.hostPortRange[%d]", field, j), rule.HostPortRange[j]); err != nil {
errs = errors.Join(errs, err)
}
}
}
if rule.GuestPortRange[0] > rule.GuestPortRange[1] {
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] {
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.GuestSocket != "" {
if !path.IsAbs(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 {
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
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 {
errs = errors.Join(errs, fmt.Errorf("field `%s.hostSocket` can only be mapped from a single port or socket. not a range", field))
}
} else if rule.GuestPortRange[1]-rule.GuestPortRange[0] != rule.HostPortRange[1]-rule.HostPortRange[0] {
errs = errors.Join(errs, fmt.Errorf("field `%s.hostPortRange` must specify the same number of ports as field `%s.guestPortRange`", field, field))
}
if len(rule.HostSocket) >= osutil.UnixPathMax {
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 limatype.ProtoTCP, limatype.ProtoUDP, limatype.ProtoAny:
default:
errs = errors.Join(errs, fmt.Errorf("field `%s.proto` must be %#q, %#q, or %#q", field, limatype.ProtoTCP, limatype.ProtoUDP, limatype.ProtoAny))
}
if rule.Reverse && rule.GuestSocket == "" {
errs = errors.Join(errs, fmt.Errorf("field `%s.reverse` must be %t", field, false))
}
if rule.Reverse && rule.HostSocket == "" {
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.
}
for i, rule := range y.CopyToHost {
field := fmt.Sprintf("CopyToHost[%d]", i)
if rule.GuestFile != "" {
if !path.IsAbs(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) {
errs = errors.Join(errs, fmt.Errorf("field `%s.host` must be an absolute path, but is %#q", field, rule.HostFile))
}
}
}
if y.VMType != nil && *y.VMType == limatype.HCS {
if y.HostResolver.Enabled != nil && *y.HostResolver.Enabled && len(y.DNS) > 0 {
errs = errors.Join(errs, errors.New("field `dns` must be empty when field `HostResolver.Enabled` is true"))
}
} else {
if HostResolverEnabled(y) && len(y.DNS) > 0 {
errs = errors.Join(errs, errors.New("field `dns` must be empty when field `HostResolver.Enabled` is true"))
}
}
err := validateNetwork(y)
if err != nil {
errs = errors.Join(errs, err)
}
if warn {
warnExperimental(y)
}
// Validate Param settings
// Names must start with a letter, followed by any number of letters, digits, or underscores
validParamName := regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]*$`)
for param, value := range y.Param {
if !validParamName.MatchString(param) {
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 != ' ' {
errs = errors.Join(errs, fmt.Errorf("param %#q value contains unprintable character %#q", param, r))
}
}
}
// passwordlessSudo: true is not supported on macOS guests yet (lock_passwd unimplemented,
// account creation requires a password, and the same account backs the GUI session)
if y.OS != nil && *y.OS == limatype.DARWIN &&
y.User.PasswordlessSudo != nil && *y.User.PasswordlessSudo {
errs = errors.Join(errs, errors.New("`user.passwordlessSudo: true` is not supported on macOS guests"))
}
if y.VMType != nil && *y.VMType == limatype.WSL2 &&
y.User.PasswordlessSudo != nil && !*y.User.PasswordlessSudo {
errs = errors.Join(errs, errors.New("`user.passwordlessSudo: false` is not supported on WSL2 guest"))
}
// GHSA-2j9v-p4xj-cjw2 constraint: require plain mode when passwordless sudo is disabled
if y.User.PasswordlessSudo != nil && !*y.User.PasswordlessSudo {
if y.OS == nil || *y.OS != limatype.DARWIN {
if y.Plain == nil || !*y.Plain {
errs = errors.Join(errs, errors.New("`user.passwordlessSudo: false` requires `plain: true`"))
}
}
}
if y.Plain != nil && *y.Plain {
const portRangeWarnThreshold = 10
for i, rule := range y.PortForwards {
guestRange := rule.GuestPortRange[1] - rule.GuestPortRange[0] + 1
hostRange := rule.HostPortRange[1] - rule.HostPortRange[0] + 1
if guestRange > portRangeWarnThreshold || hostRange > portRangeWarnThreshold {
logrus.Warnf("[plain mode] portForwards[%d] covers a range of more than %d ports (guest: %d, host: %d). All ports will be forwarded unconditionally, which may be inefficient.", i, portRangeWarnThreshold, guestRange, hostRange)
}
}
}
if y.TPM != nil && *y.TPM {
switch *y.Arch {
case limatype.X8664, limatype.AARCH64, limatype.ARMV7L, limatype.RISCV64:
// supported
default:
errs = errors.Join(errs, fmt.Errorf("field `tpm` is not supported on architecture %#q", *y.Arch))
}
}
if y.Audio.Interface != nil {
if *y.Audio.Interface != "" && *y.Audio.Interface != "hda" && *y.Audio.Interface != "virtio" {
return fmt.Errorf("invalid audio.interface %q, must be \"hda\" or \"virtio\"", *y.Audio.Interface)
}
}
return errs
}
var SupportedWindowsShells = []string{"cmd.exe", "powershell.exe", "pwsh.exe"}
func IsSupportedWindowsShell(shell string) bool {
// Normalize both forward and backward slashes since Windows-style paths may
// be validated on a non-Windows host.
if i := strings.LastIndexAny(shell, `/\`); i >= 0 {
shell = shell[i+1:]
}
for _, s := range SupportedWindowsShells {
if strings.EqualFold(shell, s) {
return true
}
}
return false
}
func validateBlockDevices(blockDevices []string) error {
var errs error
for i, blockDevice := range blockDevices {
field := fmt.Sprintf("blockDevices[%d]", i)
if err := blockdevice.ValidateDiskDevicePath(blockDevice); err != nil {
errs = errors.Join(errs, fmt.Errorf("field `%s`: %w", field, err))
continue
}
for _, earlier := range blockDevices[:i] {
if blockdevice.DevicesOverlap(earlier, blockDevice) {
errs = errors.Join(errs, fmt.Errorf("field `%s` duplicates or overlaps block device %q", field, earlier))
}
}
}
return errs
}
func validateFileObject(f limatype.File, fieldName string) error {
var errs error
if !strings.Contains(f.Location, "://") {
if _, err := localpathutil.Expand(f.Location); err != nil {
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(limatype.ArchTypes, f.Arch) {
errs = errors.Join(errs, fmt.Errorf("field `arch` must be one of %v; got %#q", limatype.ArchTypes, f.Arch))
}
if f.Digest != "" {
if err := f.Digest.Validate(); err != nil {
errs = errors.Join(errs, fmt.Errorf("field `%s.digest` is invalid: %s: %w", fieldName, f.Digest.String(), err))
}
}
return errs
}
func validateNetwork(y *limatype.LimaYAML) error {
var errs error
interfaceName := make(map[string]int)
for i, nw := range y.Networks {
field := fmt.Sprintf("networks[%d]", i)
switch {
case nw.Lima != "":
nwCfg, err := networks.LoadConfig()
if err != nil {
return err
}
if nwCfg.Check(nw.Lima) != nil {
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" {
errs = errors.Join(errs, fmt.Errorf("field `%s.lima` is only supported on macOS right now", field))
}
if nw.Socket != "" {
errs = errors.Join(errs, fmt.Errorf("field `%s.lima` and field `%s.socket` are mutually exclusive", field, field))
}
if nw.VZNAT != nil && *nw.VZNAT {
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 {
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) {
errs = errors.Join(errs, err)
} else if err == nil && fi.Mode()&os.ModeSocket == 0 {
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 nw.Lima != "" {
errs = errors.Join(errs, fmt.Errorf("field `%s.vzNAT` and field `%s.lima` are mutually exclusive", field, field))
}
if nw.Socket != "" {
errs = errors.Join(errs, fmt.Errorf("field `%s.vzNAT` and field `%s.socket` are mutually exclusive", field, field))
}
default:
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 {
errs = errors.Join(errs, fmt.Errorf("field `vmnet.mac` invalid: %w", err))
}
if len(hw) != 6 {
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 {
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/") {
errs = errors.Join(errs, fmt.Errorf("field `%s.interface` must not contain whitespace or slashes", field))
}
if nw.Interface == 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 {
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 errs
}
// validateParamIsUsed checks if the keys in the `param` field are used in any script, probe, copyToHost, or portForward.
// It should be called before the `y` parameter is passed to FillDefault() that execute template.
func validateParamIsUsed(y *limatype.LimaYAML) error {
for key := range y.Param {
re, err := regexp.Compile(`{{[^}]*\.Param\.` + key + `[^}]*}}|\bPARAM_` + key + `\b`)
if err != nil {
return fmt.Errorf("field to compile regexp for key %#q: %w", key, err)
}
keyIsUsed := false
for _, p := range y.Provision {
for _, ptr := range []*string{p.Script, p.Content, p.Expression, p.Owner, p.Path, p.Permissions} {
if ptr != nil && re.MatchString(*ptr) {
keyIsUsed = true
break
}
}
if p.Playbook != "" {
playbook, err := os.ReadFile(p.Playbook)
if err != nil {
return err
}
if re.Match(playbook) {
keyIsUsed = true
break
}
}
}
for _, p := range y.Probes {
if p.Script != nil && re.MatchString(*p.Script) {
keyIsUsed = true
break
}
}
for _, p := range y.CopyToHost {
if re.MatchString(p.GuestFile) || re.MatchString(p.HostFile) {
keyIsUsed = true
break
}
}
for _, p := range y.PortForwards {
if re.MatchString(p.GuestSocket) || re.MatchString(p.HostSocket) {
keyIsUsed = true
break
}
}
for _, p := range y.Mounts {
if re.MatchString(p.Location) {
keyIsUsed = true
break
}
if p.MountPoint != nil && re.MatchString(*p.MountPoint) {
keyIsUsed = true
break
}
}
if key == "internal_netplanOptional" { // consumed by the cidata network-config template
keyIsUsed = true
}
if !keyIsUsed {
return fmt.Errorf("field `param` key %#q is not used in any provision, probe, copyToHost, or portForward", key)
}
}
return nil
}
func validatePort(field string, port int) error {
switch {
case port < 0:
return fmt.Errorf("field `%s` must be > 0", field)
case port == 0:
return fmt.Errorf("field `%s` must be set", field)
case port == 22:
return fmt.Errorf("field `%s` must not be 22", field)
case port > 65535:
return fmt.Errorf("field `%s` must be < 65536", field)
}
return nil
}
func warnExperimental(y *limatype.LimaYAML) {
if *y.OS == limatype.WINDOWS {
logrus.Warnf("`os: %s` is experimental", limatype.WINDOWS)
}
if *y.MountType == limatype.VIRTIOFS && runtime.GOOS == "linux" {
logrus.Warn("`mountType: virtiofs` on Linux is experimental")
}
switch *y.Arch {
case limatype.RISCV64, limatype.ARMV7L, limatype.S390X, limatype.PPC64LE:
logrus.Warnf("`arch: %s ` is experimental", *y.Arch)
}
if y.Video.Display != nil && strings.Contains(*y.Video.Display, "vnc") {
logrus.Warn("`video.display: vnc` is experimental")
}
if y.Audio.Device != nil && *y.Audio.Device != "" {
logrus.Warn("`audio.device` is experimental")
if y.Audio.Interface != nil && *y.Audio.Interface != "" {
logrus.Warn("`audio.interface` is experimental")
}
}
if y.Audio.Microphone != nil && *y.Audio.Microphone {
logrus.Warn("`audio.microphone` is experimental")
}
if y.MountInotify != nil && *y.MountInotify {
logrus.Warn("`mountInotify` is experimental")
}
if y.User.PasswordlessSudo != nil && !*y.User.PasswordlessSudo {
if y.OS == nil || *y.OS != limatype.DARWIN {
logrus.Warn("`user.passwordlessSudo: false` is experimental")
}
}
if y.User.PasswordlessSudo != nil && !*y.User.PasswordlessSudo && len(y.Param) > 0 {
logrus.Warn("`param` still relies on sudo internally, it may not work as expected when `user.passwordlessSudo: false` is set")
}
}
// ValidateAgainstLatestConfig validates the values between the latest YAML and the updated(New) YAML.
// This validates configuration rules that disallow certain changes, such as shrinking the disk.
func ValidateAgainstLatestConfig(ctx context.Context, yNew, yLatest []byte) error {
var n limatype.LimaYAML
var errs error
// Load the latest YAML and fill in defaults
l, err := LoadWithWarnings(ctx, yLatest, "")
if err != nil {
errs = errors.Join(errs, err)
}
if err := Unmarshal(yNew, &n, "Unmarshal new YAML bytes"); err != nil {
errs = errors.Join(errs, err)
}
// Handle editing the template without a disk value
if n.Disk == nil || l.Disk == nil {
return errs
}
// Disk value must be provided, as it is required when creating an instance.
nDisk, err := units.RAMInBytes(*n.Disk)
if err != nil {
errs = errors.Join(errs, err)
}
lDisk, err := units.RAMInBytes(*l.Disk)
if err != nil {
errs = errors.Join(errs, err)
}
// Reject shrinking disk
if nDisk < lDisk {
errs = errors.Join(errs, fmt.Errorf("field `disk`: shrinking the disk (%v --> %v) is not supported", *l.Disk, *n.Disk))
}
return errs
}