diff --git a/cmd/limactl/editflags/editflags.go b/cmd/limactl/editflags/editflags.go index ae9fa593e4f..d68d99a4a06 100644 --- a/cmd/limactl/editflags/editflags.go +++ b/cmd/limactl/editflags/editflags.go @@ -15,6 +15,8 @@ import ( "github.com/sirupsen/logrus" "github.com/spf13/cobra" flag "github.com/spf13/pflag" + + "github.com/lima-vm/lima/pkg/labels" ) // RegisterEdit registers flags related to in-place YAML modification, for `limactl edit`. @@ -45,6 +47,8 @@ func registerEdit(cmd *cobra.Command, commentPrefix string) { return res, cobra.ShellCompDirectiveNoFileComp }) + flags.StringToString("label", nil, commentPrefix+"Labels, e.g., \"category\"") + flags.StringSlice("mount", nil, commentPrefix+"Directories to mount, suffix ':w' for writable (Do not specify directories that overlap with the existing mounts)") // colima-compatible flags.Bool("mount-none", false, commentPrefix+"Remove all mounts") @@ -136,6 +140,27 @@ func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) { false, false, }, + { + "label", + func(_ *flag.Flag) (string, error) { + m, err := flags.GetStringToString("label") + if err != nil { + return "", err + } + var expr string + for k, v := range m { + if err := labels.Validate(k); err != nil { + return "", fmt.Errorf("field `labels` has an invalid label %q: %w", k, err) + } + // No validation for label values + expr += fmt.Sprintf(".labels.%q = %q |", k, v) + } + expr = strings.TrimSuffix(expr, " |") + return expr, nil + }, + false, + false, + }, {"memory", d(".memory = \"%sGiB\""), false, false}, { "mount", diff --git a/cmd/limactl/list.go b/cmd/limactl/list.go index 20706ad9874..8b0635ca69c 100644 --- a/cmd/limactl/list.go +++ b/cmd/limactl/list.go @@ -63,6 +63,7 @@ The following legacy flags continue to function: listCommand.Flags().Bool("json", false, "JSONify output") listCommand.Flags().BoolP("quiet", "q", false, "Only show names") listCommand.Flags().Bool("all-fields", false, "Show all fields") + listCommand.Flags().StringToString("label", nil, "Filter instances by labels. Multiple labels can be specified (AND-match)") return listCommand } @@ -77,6 +78,16 @@ func instanceMatches(arg string, instances []string) []string { return matches } +// instanceMatchesAllLabels returns true if inst matches all labels, or, labels is nil. +func instanceMatchesAllLabels(inst *store.Instance, labels map[string]string) bool { + for k, v := range labels { + if inst.Config.Labels[k] != v { + return false + } + } + return true +} + // unmatchedInstancesError is created when unmatched instance names found. type unmatchedInstancesError struct{} @@ -107,6 +118,10 @@ func listAction(cmd *cobra.Command, args []string) error { if err != nil { return err } + labels, err := cmd.Flags().GetStringToString("label") + if err != nil { + return err + } if jsonFormat { format = "json" @@ -177,7 +192,12 @@ func listAction(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("unable to load instance %s: %w", instanceName, err) } - instances = append(instances, instance) + if instanceMatchesAllLabels(instance, labels) { + instances = append(instances, instance) + } + } + if len(instances) == 0 { + return unmatchedInstancesError{} } for _, instance := range instances { diff --git a/pkg/labels/validate.go b/pkg/labels/validate.go new file mode 100644 index 00000000000..9d8726b0dc6 --- /dev/null +++ b/pkg/labels/validate.go @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +// From https://github.com/containerd/containerd/blob/v2.1.1/pkg/identifiers/validate.go +// SPDX-FileCopyrightText: Copyright The containerd Authors +// LICENSE: https://github.com/containerd/containerd/blob/v2.1.1/LICENSE +// NOTICE: https://github.com/containerd/containerd/blob/v2.1.1/NOTICE + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +// Package labels provides common validation for labels. +// Labels are similar to [github.com/lima-vm/lima/pkg/identifiers], but allows '/'. +package labels + +import ( + "errors" + "fmt" + "regexp" +) + +const ( + maxLength = 76 + alphanum = `[A-Za-z0-9]+` + separators = `[/._-]` // contains slash, unlike identifiers +) + +// labelRe defines the pattern for valid identifiers. +var labelRe = regexp.MustCompile(reAnchor(alphanum + reGroup(separators+reGroup(alphanum)) + "*")) + +// Validate returns nil if the string s is a valid label. +// +// Labels are similar to [github.com/lima-vm/lima/pkg/identifiers], but allows '/'. +// +// Labels that pass this validation are NOT safe for use as filesystem path components. +func Validate(s string) error { + if s == "" { + return errors.New("label must not be empty") + } + + if len(s) > maxLength { + return fmt.Errorf("label %q greater than maximum length (%d characters)", s, maxLength) + } + + if !labelRe.MatchString(s) { + return fmt.Errorf("label %q must match %v", s, labelRe) + } + return nil +} + +func reGroup(s string) string { + return `(?:` + s + `)` +} + +func reAnchor(s string) string { + return `^` + s + `$` +} diff --git a/pkg/labels/validate_test.go b/pkg/labels/validate_test.go new file mode 100644 index 00000000000..f0e91f78783 --- /dev/null +++ b/pkg/labels/validate_test.go @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +// From https://github.com/containerd/containerd/blob/v2.1.1/pkg/identifiers/validate_test.go +// SPDX-FileCopyrightText: Copyright The containerd Authors +// LICENSE: https://github.com/containerd/containerd/blob/v2.1.1/LICENSE +// NOTICE: https://github.com/containerd/containerd/blob/v2.1.1/NOTICE + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package labels + +import ( + "strings" + "testing" + + "gotest.tools/v3/assert" +) + +func TestValidLabels(t *testing.T) { + for _, input := range []string{ + "default", + "Default", + t.Name(), + "default-default", + "containerd.io", + "foo.boo", + "swarmkit.docker.io", + "0912341234", + "task.0.0123456789", + "container.system-75-f19a.00", + "underscores_are_allowed", + "foo/foo", + "foo.example.com/foo", + strings.Repeat("a", maxLength), + } { + t.Run(input, func(t *testing.T) { + assert.NilError(t, Validate(input)) + }) + } +} + +func TestInvalidLabels(t *testing.T) { + for _, input := range []string{ + "", + ".foo..foo", + "foo/..", + "foo..foo", + "foo.-boo", + "-foo.boo", + "foo.boo-", + "but__only_tasteful_underscores", + "zn--e9.org", // or something like it! + "default--default", + strings.Repeat("a", maxLength+1), + } { + t.Run(input, func(t *testing.T) { + assert.ErrorContains(t, Validate(input), "") + }) + } +} diff --git a/pkg/limayaml/defaults.go b/pkg/limayaml/defaults.go index a2193a28389..09740cf8aa2 100644 --- a/pkg/limayaml/defaults.go +++ b/pkg/limayaml/defaults.go @@ -214,6 +214,12 @@ func FillDefault(y, d, o *LimaYAML, filePath string, warn bool) { } } + labels := make(map[string]string) + maps.Copy(labels, d.Labels) + maps.Copy(labels, y.Labels) + maps.Copy(labels, o.Labels) + y.Labels = labels + if y.User.Name == nil { y.User.Name = d.User.Name } diff --git a/pkg/limayaml/limayaml.go b/pkg/limayaml/limayaml.go index d72e5b602c8..9a3189a4676 100644 --- a/pkg/limayaml/limayaml.go +++ b/pkg/limayaml/limayaml.go @@ -10,35 +10,36 @@ import ( ) type LimaYAML struct { - Base BaseTemplates `yaml:"base,omitempty" json:"base,omitempty"` - MinimumLimaVersion *string `yaml:"minimumLimaVersion,omitempty" json:"minimumLimaVersion,omitempty" jsonschema:"nullable"` - VMType *VMType `yaml:"vmType,omitempty" json:"vmType,omitempty" jsonschema:"nullable"` - VMOpts VMOpts `yaml:"vmOpts,omitempty" json:"vmOpts,omitempty"` - OS *OS `yaml:"os,omitempty" json:"os,omitempty" jsonschema:"nullable"` - Arch *Arch `yaml:"arch,omitempty" json:"arch,omitempty" jsonschema:"nullable"` - Images []Image `yaml:"images,omitempty" json:"images,omitempty" jsonschema:"nullable"` - CPUType CPUType `yaml:"cpuType,omitempty" json:"cpuType,omitempty" jsonschema:"nullable"` - CPUs *int `yaml:"cpus,omitempty" json:"cpus,omitempty" jsonschema:"nullable"` - Memory *string `yaml:"memory,omitempty" json:"memory,omitempty" jsonschema:"nullable"` // go-units.RAMInBytes - Disk *string `yaml:"disk,omitempty" json:"disk,omitempty" jsonschema:"nullable"` // go-units.RAMInBytes - AdditionalDisks []Disk `yaml:"additionalDisks,omitempty" json:"additionalDisks,omitempty" jsonschema:"nullable"` - Mounts []Mount `yaml:"mounts,omitempty" json:"mounts,omitempty"` - MountTypesUnsupported []string `yaml:"mountTypesUnsupported,omitempty" json:"mountTypesUnsupported,omitempty" jsonschema:"nullable"` - MountType *MountType `yaml:"mountType,omitempty" json:"mountType,omitempty" jsonschema:"nullable"` - MountInotify *bool `yaml:"mountInotify,omitempty" json:"mountInotify,omitempty" jsonschema:"nullable"` - SSH SSH `yaml:"ssh,omitempty" json:"ssh,omitempty"` // REQUIRED (FIXME) - Firmware Firmware `yaml:"firmware,omitempty" json:"firmware,omitempty"` - Audio Audio `yaml:"audio,omitempty" json:"audio,omitempty"` - Video Video `yaml:"video,omitempty" json:"video,omitempty"` - Provision []Provision `yaml:"provision,omitempty" json:"provision,omitempty"` - UpgradePackages *bool `yaml:"upgradePackages,omitempty" json:"upgradePackages,omitempty" jsonschema:"nullable"` - Containerd Containerd `yaml:"containerd,omitempty" json:"containerd,omitempty"` - GuestInstallPrefix *string `yaml:"guestInstallPrefix,omitempty" json:"guestInstallPrefix,omitempty" jsonschema:"nullable"` - Probes []Probe `yaml:"probes,omitempty" json:"probes,omitempty"` - PortForwards []PortForward `yaml:"portForwards,omitempty" json:"portForwards,omitempty"` - CopyToHost []CopyToHost `yaml:"copyToHost,omitempty" json:"copyToHost,omitempty"` - Message string `yaml:"message,omitempty" json:"message,omitempty"` - Networks []Network `yaml:"networks,omitempty" json:"networks,omitempty" jsonschema:"nullable"` + Labels map[string]string `yaml:"labels,omitempty" json:"labels,omitempty"` + Base BaseTemplates `yaml:"base,omitempty" json:"base,omitempty"` + MinimumLimaVersion *string `yaml:"minimumLimaVersion,omitempty" json:"minimumLimaVersion,omitempty" jsonschema:"nullable"` + VMType *VMType `yaml:"vmType,omitempty" json:"vmType,omitempty" jsonschema:"nullable"` + VMOpts VMOpts `yaml:"vmOpts,omitempty" json:"vmOpts,omitempty"` + OS *OS `yaml:"os,omitempty" json:"os,omitempty" jsonschema:"nullable"` + Arch *Arch `yaml:"arch,omitempty" json:"arch,omitempty" jsonschema:"nullable"` + Images []Image `yaml:"images,omitempty" json:"images,omitempty" jsonschema:"nullable"` + CPUType CPUType `yaml:"cpuType,omitempty" json:"cpuType,omitempty" jsonschema:"nullable"` + CPUs *int `yaml:"cpus,omitempty" json:"cpus,omitempty" jsonschema:"nullable"` + Memory *string `yaml:"memory,omitempty" json:"memory,omitempty" jsonschema:"nullable"` // go-units.RAMInBytes + Disk *string `yaml:"disk,omitempty" json:"disk,omitempty" jsonschema:"nullable"` // go-units.RAMInBytes + AdditionalDisks []Disk `yaml:"additionalDisks,omitempty" json:"additionalDisks,omitempty" jsonschema:"nullable"` + Mounts []Mount `yaml:"mounts,omitempty" json:"mounts,omitempty"` + MountTypesUnsupported []string `yaml:"mountTypesUnsupported,omitempty" json:"mountTypesUnsupported,omitempty" jsonschema:"nullable"` + MountType *MountType `yaml:"mountType,omitempty" json:"mountType,omitempty" jsonschema:"nullable"` + MountInotify *bool `yaml:"mountInotify,omitempty" json:"mountInotify,omitempty" jsonschema:"nullable"` + SSH SSH `yaml:"ssh,omitempty" json:"ssh,omitempty"` // REQUIRED (FIXME) + Firmware Firmware `yaml:"firmware,omitempty" json:"firmware,omitempty"` + Audio Audio `yaml:"audio,omitempty" json:"audio,omitempty"` + Video Video `yaml:"video,omitempty" json:"video,omitempty"` + Provision []Provision `yaml:"provision,omitempty" json:"provision,omitempty"` + UpgradePackages *bool `yaml:"upgradePackages,omitempty" json:"upgradePackages,omitempty" jsonschema:"nullable"` + Containerd Containerd `yaml:"containerd,omitempty" json:"containerd,omitempty"` + GuestInstallPrefix *string `yaml:"guestInstallPrefix,omitempty" json:"guestInstallPrefix,omitempty" jsonschema:"nullable"` + Probes []Probe `yaml:"probes,omitempty" json:"probes,omitempty"` + PortForwards []PortForward `yaml:"portForwards,omitempty" json:"portForwards,omitempty"` + CopyToHost []CopyToHost `yaml:"copyToHost,omitempty" json:"copyToHost,omitempty"` + Message string `yaml:"message,omitempty" json:"message,omitempty"` + Networks []Network `yaml:"networks,omitempty" json:"networks,omitempty" jsonschema:"nullable"` // `network` was deprecated in Lima v0.7.0, removed in Lima v0.14.0. Use `networks` instead. Env map[string]string `yaml:"env,omitempty" json:"env,omitempty"` Param map[string]string `yaml:"param,omitempty" json:"param,omitempty"` diff --git a/pkg/limayaml/validate.go b/pkg/limayaml/validate.go index 443eaff02f7..8a1c001e56f 100644 --- a/pkg/limayaml/validate.go +++ b/pkg/limayaml/validate.go @@ -22,6 +22,7 @@ import ( "github.com/sirupsen/logrus" "github.com/lima-vm/lima/pkg/identifiers" + "github.com/lima-vm/lima/pkg/labels" "github.com/lima-vm/lima/pkg/localpathutil" "github.com/lima-vm/lima/pkg/networks" "github.com/lima-vm/lima/pkg/osutil" @@ -54,6 +55,13 @@ func validateFileObject(f File, fieldName string) error { func Validate(y *LimaYAML, warn bool) error { var errs error + for k := range y.Labels { + if err := labels.Validate(k); err != nil { + errs = errors.Join(errs, fmt.Errorf("field `labels` has an invalid label %q: %w", k, err)) + } + // No validation for label values + } + if len(y.Base) > 0 { errs = errors.Join(errs, errors.New("field `base` must be empty for YAML validation")) } diff --git a/templates/default.yaml b/templates/default.yaml index 99f01eccdf0..df3e6767f3a 100644 --- a/templates/default.yaml +++ b/templates/default.yaml @@ -5,6 +5,11 @@ # Default values in this YAML file are specified by `null` instead of Lima's "builtin default" values, # so they can be overridden by the $LIMA_HOME/_config/default.yaml mechanism documented at the end of this file. +# Arbitrary labels. e.g., "category", "description". +# 🟢 Builtin default: {} +# labels: +# KEY: value + # VM type: "qemu", "vz" (on macOS 13 and later), or "default". # The vmType can be specified only on creating the instance. # The vmType of existing instances cannot be changed.