Skip to content

Support filtering instances by labels #3659

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions cmd/limactl/editflags/editflags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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",
Expand Down
22 changes: 21 additions & 1 deletion cmd/limactl/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not labels?

Suggested change
listCommand.Flags().StringToString("label", nil, "Filter instances by labels. Multiple labels can be specified (AND-match)")
listCommand.Flags().StringToString("labels", nil, "Filter instances by labels. Multiple labels can be specified (AND-match)")

For consistency with

hostagentCommand.Flags().StringToString("leases", nil, "Pass default static leases for startup. Eg: '192.168.104.1=52:55:55:b3:bc:d9,192.168.104.2=5a:94:ef:e4:0c:df' ")

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add an example of labels with values here?


return listCommand
}
Expand All @@ -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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// instanceMatchesAllLabels returns true if inst matches all labels, or, labels is nil.
// instanceMatchesAllLabels returns true if inst matches all labels, or if 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{}

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
70 changes: 70 additions & 0 deletions pkg/labels/validate.go
Original file line number Diff line number Diff line change
@@ -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 + `$`
}
75 changes: 75 additions & 0 deletions pkg/labels/validate_test.go
Original file line number Diff line number Diff line change
@@ -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), "")
})
}
}
6 changes: 6 additions & 0 deletions pkg/limayaml/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
59 changes: 30 additions & 29 deletions pkg/limayaml/limayaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
8 changes: 8 additions & 0 deletions pkg/limayaml/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Comment on lines +58 to +63
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's cover these lines with a unit test in the file validate_test.go

package limayaml


if len(y.Base) > 0 {
errs = errors.Join(errs, errors.New("field `base` must be empty for YAML validation"))
}
Expand Down
5 changes: 5 additions & 0 deletions templates/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Arbitrary labels. e.g., "category", "description".
# 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.
Expand Down