Skip to content

Commit de6e555

Browse files
committed
Support filtering instances by labels
Usage: ``` limactl create --name foo --label category=tmp limactl list --label category=tmp ``` - The allowed characters are similar to `identifiers`, but allows '/'. - `limactl list` interprets multiple labels as an AND-match query. - No support for negative match. Eventually we may add the more sophisticated `--filter` flag as in `docker` and `kubectl`. Signed-off-by: Akihiro Suda <[email protected]>
1 parent 1abadcd commit de6e555

File tree

8 files changed

+239
-30
lines changed

8 files changed

+239
-30
lines changed

cmd/limactl/editflags/editflags.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"strconv"
1212
"strings"
1313

14+
"github.com/lima-vm/lima/pkg/labels"
1415
"github.com/pbnjay/memory"
1516
"github.com/sirupsen/logrus"
1617
"github.com/spf13/cobra"
@@ -45,6 +46,8 @@ func registerEdit(cmd *cobra.Command, commentPrefix string) {
4546
return res, cobra.ShellCompDirectiveNoFileComp
4647
})
4748

49+
flags.StringToString("label", nil, commentPrefix+"Labels, e.g., \"category\"")
50+
4851
flags.StringSlice("mount", nil, commentPrefix+"Directories to mount, suffix ':w' for writable (Do not specify directories that overlap with the existing mounts)") // colima-compatible
4952
flags.Bool("mount-none", false, commentPrefix+"Remove all mounts")
5053

@@ -136,6 +139,27 @@ func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) {
136139
false,
137140
false,
138141
},
142+
{
143+
"label",
144+
func(_ *flag.Flag) (string, error) {
145+
m, err := flags.GetStringToString("label")
146+
if err != nil {
147+
return "", err
148+
}
149+
var expr string
150+
for k, v := range m {
151+
if err := labels.Validate(k); err != nil {
152+
return "", fmt.Errorf("field `labels` has an invalid label %q: %w", k, err)
153+
}
154+
// No validation for label values
155+
expr += fmt.Sprintf(".labels.%q = %q |", k, v)
156+
}
157+
expr = strings.TrimSuffix(expr, " |")
158+
return expr, nil
159+
},
160+
false,
161+
false,
162+
},
139163
{"memory", d(".memory = \"%sGiB\""), false, false},
140164
{
141165
"mount",

cmd/limactl/list.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ The following legacy flags continue to function:
6363
listCommand.Flags().Bool("json", false, "JSONify output")
6464
listCommand.Flags().BoolP("quiet", "q", false, "Only show names")
6565
listCommand.Flags().Bool("all-fields", false, "Show all fields")
66+
listCommand.Flags().StringToString("label", nil, "Filter instances by labels. Multiple labels can be specified (AND-match)")
6667

6768
return listCommand
6869
}
@@ -77,6 +78,16 @@ func instanceMatches(arg string, instances []string) []string {
7778
return matches
7879
}
7980

81+
// instanceMatchesAllLabels returns true if inst matches all labels, or, labels is nil
82+
func instanceMatchesAllLabels(inst *store.Instance, labels map[string]string) bool {
83+
for k, v := range labels {
84+
if inst.Config.Labels[k] != v {
85+
return false
86+
}
87+
}
88+
return true
89+
}
90+
8091
// unmatchedInstancesError is created when unmatched instance names found.
8192
type unmatchedInstancesError struct{}
8293

@@ -107,6 +118,10 @@ func listAction(cmd *cobra.Command, args []string) error {
107118
if err != nil {
108119
return err
109120
}
121+
labels, err := cmd.Flags().GetStringToString("label")
122+
if err != nil {
123+
return err
124+
}
110125

111126
if jsonFormat {
112127
format = "json"
@@ -177,7 +192,12 @@ func listAction(cmd *cobra.Command, args []string) error {
177192
if err != nil {
178193
return fmt.Errorf("unable to load instance %s: %w", instanceName, err)
179194
}
180-
instances = append(instances, instance)
195+
if instanceMatchesAllLabels(instance, labels) {
196+
instances = append(instances, instance)
197+
}
198+
}
199+
if len(instances) == 0 {
200+
return unmatchedInstancesError{}
181201
}
182202

183203
for _, instance := range instances {

pkg/labels/validate.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// SPDX-FileCopyrightText: Copyright The Lima Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// From https://github.com/containerd/containerd/blob/v2.1.1/pkg/identifiers/validate.go
5+
// SPDX-FileCopyrightText: Copyright The containerd Authors
6+
// LICENSE: https://github.com/containerd/containerd/blob/v2.1.1/LICENSE
7+
// NOTICE: https://github.com/containerd/containerd/blob/v2.1.1/NOTICE
8+
9+
/*
10+
Copyright The containerd Authors.
11+
12+
Licensed under the Apache License, Version 2.0 (the "License");
13+
you may not use this file except in compliance with the License.
14+
You may obtain a copy of the License at
15+
16+
http://www.apache.org/licenses/LICENSE-2.0
17+
18+
Unless required by applicable law or agreed to in writing, software
19+
distributed under the License is distributed on an "AS IS" BASIS,
20+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21+
See the License for the specific language governing permissions and
22+
limitations under the License.
23+
*/
24+
25+
// Package labels provides common validation for labels.
26+
// Labels are similar to [github.com/lima-vm/lima/pkg/identifiers], but allows '/'.
27+
package labels
28+
29+
import (
30+
"errors"
31+
"fmt"
32+
"regexp"
33+
)
34+
35+
const (
36+
maxLength = 76
37+
alphanum = `[A-Za-z0-9]+`
38+
separators = `[/._-]` // contains slash, unlike identifiers
39+
)
40+
41+
// labelRe defines the pattern for valid identifiers.
42+
var labelRe = regexp.MustCompile(reAnchor(alphanum + reGroup(separators+reGroup(alphanum)) + "*"))
43+
44+
// Validate returns nil if the string s is a valid label.
45+
//
46+
// Labels are similar to [github.com/lima-vm/lima/pkg/identifiers], but allows '/'.
47+
//
48+
// Labels that pass this validation are NOT safe for use as filesystem path components.
49+
func Validate(s string) error {
50+
if s == "" {
51+
return errors.New("label must not be empty")
52+
}
53+
54+
if len(s) > maxLength {
55+
return fmt.Errorf("label %q greater than maximum length (%d characters)", s, maxLength)
56+
}
57+
58+
if !labelRe.MatchString(s) {
59+
return fmt.Errorf("label %q must match %v", s, labelRe)
60+
}
61+
return nil
62+
}
63+
64+
func reGroup(s string) string {
65+
return `(?:` + s + `)`
66+
}
67+
68+
func reAnchor(s string) string {
69+
return `^` + s + `$`
70+
}

pkg/labels/validate_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// SPDX-FileCopyrightText: Copyright The Lima Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// From https://github.com/containerd/containerd/blob/v2.1.1/pkg/identifiers/validate_test.go
5+
// SPDX-FileCopyrightText: Copyright The containerd Authors
6+
// LICENSE: https://github.com/containerd/containerd/blob/v2.1.1/LICENSE
7+
// NOTICE: https://github.com/containerd/containerd/blob/v2.1.1/NOTICE
8+
9+
/*
10+
Copyright The containerd Authors.
11+
12+
Licensed under the Apache License, Version 2.0 (the "License");
13+
you may not use this file except in compliance with the License.
14+
You may obtain a copy of the License at
15+
16+
http://www.apache.org/licenses/LICENSE-2.0
17+
18+
Unless required by applicable law or agreed to in writing, software
19+
distributed under the License is distributed on an "AS IS" BASIS,
20+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21+
See the License for the specific language governing permissions and
22+
limitations under the License.
23+
*/
24+
25+
package labels
26+
27+
import (
28+
"strings"
29+
"testing"
30+
31+
"gotest.tools/v3/assert"
32+
)
33+
34+
func TestValidLabels(t *testing.T) {
35+
for _, input := range []string{
36+
"default",
37+
"Default",
38+
t.Name(),
39+
"default-default",
40+
"containerd.io",
41+
"foo.boo",
42+
"swarmkit.docker.io",
43+
"0912341234",
44+
"task.0.0123456789",
45+
"container.system-75-f19a.00",
46+
"underscores_are_allowed",
47+
"foo/foo",
48+
"foo.example.com/foo",
49+
strings.Repeat("a", maxLength),
50+
} {
51+
t.Run(input, func(t *testing.T) {
52+
assert.NilError(t, Validate(input))
53+
})
54+
}
55+
}
56+
57+
func TestInvalidLabels(t *testing.T) {
58+
for _, input := range []string{
59+
"",
60+
".foo..foo",
61+
"foo/..",
62+
"foo..foo",
63+
"foo.-boo",
64+
"-foo.boo",
65+
"foo.boo-",
66+
"but__only_tasteful_underscores",
67+
"zn--e9.org", // or something like it!
68+
"default--default",
69+
strings.Repeat("a", maxLength+1),
70+
} {
71+
t.Run(input, func(t *testing.T) {
72+
assert.ErrorContains(t, Validate(input), "")
73+
})
74+
}
75+
}

pkg/limayaml/defaults.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,12 @@ func FillDefault(y, d, o *LimaYAML, filePath string, warn bool) {
214214
}
215215
}
216216

217+
labels := make(map[string]string)
218+
maps.Copy(labels, d.Labels)
219+
maps.Copy(labels, y.Labels)
220+
maps.Copy(labels, o.Labels)
221+
y.Labels = labels
222+
217223
if y.User.Name == nil {
218224
y.User.Name = d.User.Name
219225
}

pkg/limayaml/limayaml.go

Lines changed: 30 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,35 +10,36 @@ import (
1010
)
1111

1212
type LimaYAML struct {
13-
Base BaseTemplates `yaml:"base,omitempty" json:"base,omitempty"`
14-
MinimumLimaVersion *string `yaml:"minimumLimaVersion,omitempty" json:"minimumLimaVersion,omitempty" jsonschema:"nullable"`
15-
VMType *VMType `yaml:"vmType,omitempty" json:"vmType,omitempty" jsonschema:"nullable"`
16-
VMOpts VMOpts `yaml:"vmOpts,omitempty" json:"vmOpts,omitempty"`
17-
OS *OS `yaml:"os,omitempty" json:"os,omitempty" jsonschema:"nullable"`
18-
Arch *Arch `yaml:"arch,omitempty" json:"arch,omitempty" jsonschema:"nullable"`
19-
Images []Image `yaml:"images,omitempty" json:"images,omitempty" jsonschema:"nullable"`
20-
CPUType CPUType `yaml:"cpuType,omitempty" json:"cpuType,omitempty" jsonschema:"nullable"`
21-
CPUs *int `yaml:"cpus,omitempty" json:"cpus,omitempty" jsonschema:"nullable"`
22-
Memory *string `yaml:"memory,omitempty" json:"memory,omitempty" jsonschema:"nullable"` // go-units.RAMInBytes
23-
Disk *string `yaml:"disk,omitempty" json:"disk,omitempty" jsonschema:"nullable"` // go-units.RAMInBytes
24-
AdditionalDisks []Disk `yaml:"additionalDisks,omitempty" json:"additionalDisks,omitempty" jsonschema:"nullable"`
25-
Mounts []Mount `yaml:"mounts,omitempty" json:"mounts,omitempty"`
26-
MountTypesUnsupported []string `yaml:"mountTypesUnsupported,omitempty" json:"mountTypesUnsupported,omitempty" jsonschema:"nullable"`
27-
MountType *MountType `yaml:"mountType,omitempty" json:"mountType,omitempty" jsonschema:"nullable"`
28-
MountInotify *bool `yaml:"mountInotify,omitempty" json:"mountInotify,omitempty" jsonschema:"nullable"`
29-
SSH SSH `yaml:"ssh,omitempty" json:"ssh,omitempty"` // REQUIRED (FIXME)
30-
Firmware Firmware `yaml:"firmware,omitempty" json:"firmware,omitempty"`
31-
Audio Audio `yaml:"audio,omitempty" json:"audio,omitempty"`
32-
Video Video `yaml:"video,omitempty" json:"video,omitempty"`
33-
Provision []Provision `yaml:"provision,omitempty" json:"provision,omitempty"`
34-
UpgradePackages *bool `yaml:"upgradePackages,omitempty" json:"upgradePackages,omitempty" jsonschema:"nullable"`
35-
Containerd Containerd `yaml:"containerd,omitempty" json:"containerd,omitempty"`
36-
GuestInstallPrefix *string `yaml:"guestInstallPrefix,omitempty" json:"guestInstallPrefix,omitempty" jsonschema:"nullable"`
37-
Probes []Probe `yaml:"probes,omitempty" json:"probes,omitempty"`
38-
PortForwards []PortForward `yaml:"portForwards,omitempty" json:"portForwards,omitempty"`
39-
CopyToHost []CopyToHost `yaml:"copyToHost,omitempty" json:"copyToHost,omitempty"`
40-
Message string `yaml:"message,omitempty" json:"message,omitempty"`
41-
Networks []Network `yaml:"networks,omitempty" json:"networks,omitempty" jsonschema:"nullable"`
13+
Labels map[string]string `yaml:"labels,omitempty" json:"labels,omitempty"`
14+
Base BaseTemplates `yaml:"base,omitempty" json:"base,omitempty"`
15+
MinimumLimaVersion *string `yaml:"minimumLimaVersion,omitempty" json:"minimumLimaVersion,omitempty" jsonschema:"nullable"`
16+
VMType *VMType `yaml:"vmType,omitempty" json:"vmType,omitempty" jsonschema:"nullable"`
17+
VMOpts VMOpts `yaml:"vmOpts,omitempty" json:"vmOpts,omitempty"`
18+
OS *OS `yaml:"os,omitempty" json:"os,omitempty" jsonschema:"nullable"`
19+
Arch *Arch `yaml:"arch,omitempty" json:"arch,omitempty" jsonschema:"nullable"`
20+
Images []Image `yaml:"images,omitempty" json:"images,omitempty" jsonschema:"nullable"`
21+
CPUType CPUType `yaml:"cpuType,omitempty" json:"cpuType,omitempty" jsonschema:"nullable"`
22+
CPUs *int `yaml:"cpus,omitempty" json:"cpus,omitempty" jsonschema:"nullable"`
23+
Memory *string `yaml:"memory,omitempty" json:"memory,omitempty" jsonschema:"nullable"` // go-units.RAMInBytes
24+
Disk *string `yaml:"disk,omitempty" json:"disk,omitempty" jsonschema:"nullable"` // go-units.RAMInBytes
25+
AdditionalDisks []Disk `yaml:"additionalDisks,omitempty" json:"additionalDisks,omitempty" jsonschema:"nullable"`
26+
Mounts []Mount `yaml:"mounts,omitempty" json:"mounts,omitempty"`
27+
MountTypesUnsupported []string `yaml:"mountTypesUnsupported,omitempty" json:"mountTypesUnsupported,omitempty" jsonschema:"nullable"`
28+
MountType *MountType `yaml:"mountType,omitempty" json:"mountType,omitempty" jsonschema:"nullable"`
29+
MountInotify *bool `yaml:"mountInotify,omitempty" json:"mountInotify,omitempty" jsonschema:"nullable"`
30+
SSH SSH `yaml:"ssh,omitempty" json:"ssh,omitempty"` // REQUIRED (FIXME)
31+
Firmware Firmware `yaml:"firmware,omitempty" json:"firmware,omitempty"`
32+
Audio Audio `yaml:"audio,omitempty" json:"audio,omitempty"`
33+
Video Video `yaml:"video,omitempty" json:"video,omitempty"`
34+
Provision []Provision `yaml:"provision,omitempty" json:"provision,omitempty"`
35+
UpgradePackages *bool `yaml:"upgradePackages,omitempty" json:"upgradePackages,omitempty" jsonschema:"nullable"`
36+
Containerd Containerd `yaml:"containerd,omitempty" json:"containerd,omitempty"`
37+
GuestInstallPrefix *string `yaml:"guestInstallPrefix,omitempty" json:"guestInstallPrefix,omitempty" jsonschema:"nullable"`
38+
Probes []Probe `yaml:"probes,omitempty" json:"probes,omitempty"`
39+
PortForwards []PortForward `yaml:"portForwards,omitempty" json:"portForwards,omitempty"`
40+
CopyToHost []CopyToHost `yaml:"copyToHost,omitempty" json:"copyToHost,omitempty"`
41+
Message string `yaml:"message,omitempty" json:"message,omitempty"`
42+
Networks []Network `yaml:"networks,omitempty" json:"networks,omitempty" jsonschema:"nullable"`
4243
// `network` was deprecated in Lima v0.7.0, removed in Lima v0.14.0. Use `networks` instead.
4344
Env map[string]string `yaml:"env,omitempty" json:"env,omitempty"`
4445
Param map[string]string `yaml:"param,omitempty" json:"param,omitempty"`

pkg/limayaml/validate.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/sirupsen/logrus"
2323

2424
"github.com/lima-vm/lima/pkg/identifiers"
25+
"github.com/lima-vm/lima/pkg/labels"
2526
"github.com/lima-vm/lima/pkg/localpathutil"
2627
"github.com/lima-vm/lima/pkg/networks"
2728
"github.com/lima-vm/lima/pkg/osutil"
@@ -54,6 +55,13 @@ func validateFileObject(f File, fieldName string) error {
5455
func Validate(y *LimaYAML, warn bool) error {
5556
var errs error
5657

58+
for k := range y.Labels {
59+
if err := labels.Validate(k); err != nil {
60+
errs = errors.Join(errs, fmt.Errorf("field `labels` has an invalid label %q: %w", k, err))
61+
}
62+
// No validation for label values
63+
}
64+
5765
if len(y.Base) > 0 {
5866
errs = errors.Join(errs, errors.New("field `base` must be empty for YAML validation"))
5967
}

templates/default.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
# Default values in this YAML file are specified by `null` instead of Lima's "builtin default" values,
66
# so they can be overridden by the $LIMA_HOME/_config/default.yaml mechanism documented at the end of this file.
77

8+
# Arbitrary labels. e.g., "category", "description".
9+
# 🟢 Builtin default: {}
10+
# labels:
11+
# KEY: value
12+
813
# VM type: "qemu", "vz" (on macOS 13 and later), or "default".
914
# The vmType can be specified only on creating the instance.
1015
# The vmType of existing instances cannot be changed.

0 commit comments

Comments
 (0)