Skip to content

Commit 5b71e30

Browse files
committed
Implement limactl network (list|create|delete)
Fix issue 3672 Signed-off-by: Akihiro Suda <[email protected]>
1 parent 53d7186 commit 5b71e30

File tree

7 files changed

+311
-16
lines changed

7 files changed

+311
-16
lines changed

cmd/limactl/completion.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@
44
package main
55

66
import (
7+
"maps"
8+
"net"
9+
"slices"
710
"strings"
811

912
"github.com/spf13/cobra"
1013

14+
"github.com/lima-vm/lima/pkg/networks"
1115
"github.com/lima-vm/lima/pkg/store"
1216
"github.com/lima-vm/lima/pkg/templatestore"
1317
)
@@ -51,3 +55,24 @@ func bashCompleteDiskNames(_ *cobra.Command) ([]string, cobra.ShellCompDirective
5155
}
5256
return disks, cobra.ShellCompDirectiveNoFileComp
5357
}
58+
59+
func bashCompleteNetworkNames(_ *cobra.Command) ([]string, cobra.ShellCompDirective) {
60+
config, err := networks.LoadConfig()
61+
if err != nil {
62+
return nil, cobra.ShellCompDirectiveDefault
63+
}
64+
networks := slices.Sorted(maps.Keys(config.Networks))
65+
return networks, cobra.ShellCompDirectiveNoFileComp
66+
}
67+
68+
func bashFlagCompleteNetworkInterfaceNames(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
69+
intf, err := net.Interfaces()
70+
if err != nil {
71+
return nil, cobra.ShellCompDirectiveDefault
72+
}
73+
var intfNames []string
74+
for _, f := range intf {
75+
intfNames = append(intfNames, f.Name)
76+
}
77+
return intfNames, cobra.ShellCompDirectiveNoFileComp
78+
}

cmd/limactl/disk.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,11 +141,11 @@ $ limactl disk list
141141
return diskListCommand
142142
}
143143

144-
func diskMatches(diskName string, disks []string) []string {
144+
func nameMatches(nameName string, names []string) []string {
145145
matches := []string{}
146-
for _, disk := range disks {
147-
if disk == diskName {
148-
matches = append(matches, disk)
146+
for _, name := range names {
147+
if name == nameName {
148+
matches = append(matches, name)
149149
}
150150
}
151151
return matches
@@ -165,7 +165,7 @@ func diskListAction(cmd *cobra.Command, args []string) error {
165165
disks := []string{}
166166
if len(args) > 0 {
167167
for _, arg := range args {
168-
matches := diskMatches(arg, allDisks)
168+
matches := nameMatches(arg, allDisks)
169169
if len(matches) > 0 {
170170
disks = append(disks, matches...)
171171
} else {

cmd/limactl/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ func newApp() *cobra.Command {
189189
newRestartCommand(),
190190
newSudoersCommand(),
191191
newStartAtLoginCommand(),
192+
newNetworkCommand(),
192193
)
193194

194195
return rootCmd

cmd/limactl/network.go

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
// SPDX-FileCopyrightText: Copyright The Lima Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package main
5+
6+
import (
7+
"encoding/json"
8+
"fmt"
9+
"maps"
10+
"net"
11+
"os"
12+
"slices"
13+
"strings"
14+
"text/tabwriter"
15+
16+
"github.com/sirupsen/logrus"
17+
"github.com/spf13/cobra"
18+
19+
"github.com/lima-vm/lima/pkg/networks"
20+
"github.com/lima-vm/lima/pkg/yqutil"
21+
)
22+
23+
const networkCreateExample = ` Create a network:
24+
$ limactl network create foo --gateway 192.168.42.1/24
25+
26+
Connect VM instances to the newly created network:
27+
$ limactl create --network lima:foo --name vm1
28+
$ limactl create --network lima:foo --name vm2
29+
`
30+
31+
func newNetworkCommand() *cobra.Command {
32+
networkCommand := &cobra.Command{
33+
Use: "network",
34+
Short: "Lima network management",
35+
Example: networkCreateExample,
36+
GroupID: advancedCommand,
37+
}
38+
networkCommand.AddCommand(
39+
newNetworkListCommand(),
40+
newNetworkCreateCommand(),
41+
newNetworkDeleteCommand(),
42+
)
43+
return networkCommand
44+
}
45+
46+
func newNetworkListCommand() *cobra.Command {
47+
cmd := &cobra.Command{
48+
Use: "list",
49+
Short: "List networks",
50+
Aliases: []string{"ls"},
51+
Args: WrapArgsError(cobra.ArbitraryArgs),
52+
RunE: networkListAction,
53+
ValidArgsFunction: networkBashComplete,
54+
}
55+
flags := cmd.Flags()
56+
flags.Bool("json", false, "JSONify output")
57+
return cmd
58+
}
59+
60+
func networkListAction(cmd *cobra.Command, args []string) error {
61+
flags := cmd.Flags()
62+
jsonFormat, err := flags.GetBool("json")
63+
if err != nil {
64+
return err
65+
}
66+
67+
config, err := networks.LoadConfig()
68+
if err != nil {
69+
return err
70+
}
71+
72+
allNetworks := slices.Sorted(maps.Keys(config.Networks))
73+
74+
networks := []string{}
75+
if len(args) > 0 {
76+
for _, arg := range args {
77+
matches := nameMatches(arg, allNetworks)
78+
if len(matches) > 0 {
79+
networks = append(networks, matches...)
80+
} else {
81+
logrus.Warnf("No network matching %v found.", arg)
82+
}
83+
}
84+
} else {
85+
networks = allNetworks
86+
}
87+
88+
if jsonFormat {
89+
w := cmd.OutOrStdout()
90+
for _, name := range networks {
91+
nw, ok := config.Networks[name]
92+
if !ok {
93+
logrus.Errorf("network %q does not exist", nw)
94+
continue
95+
}
96+
j, err := json.Marshal(nw)
97+
if err != nil {
98+
return err
99+
}
100+
fmt.Fprintln(w, string(j))
101+
}
102+
return nil
103+
}
104+
105+
w := tabwriter.NewWriter(cmd.OutOrStdout(), 4, 8, 4, ' ', 0)
106+
fmt.Fprintln(w, "NAME\tMODE\tGATEWAY\tINTERFACE")
107+
for _, name := range networks {
108+
nw, ok := config.Networks[name]
109+
if !ok {
110+
logrus.Errorf("network %q does not exist", nw)
111+
continue
112+
}
113+
gwStr := "-"
114+
if nw.Gateway != nil {
115+
gw := net.IPNet{
116+
IP: nw.Gateway,
117+
Mask: net.IPMask(nw.NetMask),
118+
}
119+
gwStr = gw.String()
120+
}
121+
intfStr := "-"
122+
if nw.Interface != "" {
123+
intfStr = nw.Interface
124+
}
125+
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, nw.Mode, gwStr, intfStr)
126+
}
127+
return w.Flush()
128+
}
129+
130+
func newNetworkCreateCommand() *cobra.Command {
131+
cmd := &cobra.Command{
132+
Use: "create NETWORK",
133+
Short: "Create a Lima network",
134+
Example: networkCreateExample,
135+
Args: WrapArgsError(cobra.ExactArgs(1)),
136+
RunE: networkCreateAction,
137+
}
138+
flags := cmd.Flags()
139+
flags.String("mode", networks.ModeUserV2, "mode")
140+
_ = cmd.RegisterFlagCompletionFunc("mode", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
141+
return networks.Modes, cobra.ShellCompDirectiveNoFileComp
142+
})
143+
flags.String("gateway", "", "gateway, e.g., \"192.168.42.1/24\"")
144+
flags.String("interface", "", "interface for bridged mode")
145+
_ = cmd.RegisterFlagCompletionFunc("interface", bashFlagCompleteNetworkInterfaceNames)
146+
return cmd
147+
}
148+
149+
func networkCreateAction(cmd *cobra.Command, args []string) error {
150+
name := args[0]
151+
// LoadConfig ensures existence of networks.yaml
152+
config, err := networks.LoadConfig()
153+
if err != nil {
154+
return err
155+
}
156+
if _, ok := config.Networks[name]; ok {
157+
return fmt.Errorf("network %q already exists", name)
158+
}
159+
160+
flags := cmd.Flags()
161+
mode, err := flags.GetString("mode")
162+
if err != nil {
163+
return err
164+
}
165+
166+
gateway, err := flags.GetString("gateway")
167+
if err != nil {
168+
return err
169+
}
170+
171+
intf, err := flags.GetString("interface")
172+
if err != nil {
173+
return err
174+
}
175+
176+
switch mode {
177+
case networks.ModeBridged:
178+
if gateway != "" {
179+
return fmt.Errorf("network mode %q does not support specifying gateway", mode)
180+
}
181+
if intf == "" {
182+
return fmt.Errorf("network mode %q requires specifying interface", mode)
183+
}
184+
default:
185+
if gateway == "" {
186+
return fmt.Errorf("network mode %q requires specifying gateway", mode)
187+
}
188+
if intf != "" {
189+
return fmt.Errorf("network mode %q does not support specifying interface", mode)
190+
}
191+
}
192+
193+
if !strings.Contains(gateway, "/") {
194+
gateway += "/24"
195+
}
196+
gwIP, gwMask, err := net.ParseCIDR(gateway)
197+
if err != nil {
198+
return fmt.Errorf("failed to parse CIDR %q: %w", gateway, err)
199+
}
200+
gwMaskStr := "255.255.255.0"
201+
if gwMask != nil {
202+
gwMaskStr = net.IP(gwMask.Mask).String()
203+
}
204+
// TODO: check IP range collision
205+
206+
yq := fmt.Sprintf(`.networks.%q = {"mode":%q,"gateway":%q,"netmask":%q,"interface":%q}`, name, mode, gwIP.String(), gwMaskStr, intf)
207+
208+
return networkApplyYQ(yq)
209+
}
210+
211+
func networkApplyYQ(yq string) error {
212+
filePath, err := networks.ConfigFile()
213+
if err != nil {
214+
return err
215+
}
216+
yContent, err := os.ReadFile(filePath)
217+
if err != nil {
218+
return err
219+
}
220+
yBytes, err := yqutil.EvaluateExpression(yq, yContent)
221+
if err != nil {
222+
return err
223+
}
224+
if err := os.WriteFile(filePath, yBytes, 0o644); err != nil {
225+
return err
226+
}
227+
return nil
228+
}
229+
230+
func newNetworkDeleteCommand() *cobra.Command {
231+
cmd := &cobra.Command{
232+
Use: "delete NETWORK [NETWORK, ...]",
233+
Short: "Delete one or more Lima networks",
234+
Aliases: []string{"remove", "rm"},
235+
Args: WrapArgsError(cobra.MinimumNArgs(1)),
236+
RunE: networkDeleteAction,
237+
ValidArgsFunction: networkBashComplete,
238+
}
239+
flags := cmd.Flags()
240+
flags.BoolP("force", "f", false, "Force delete (currently always forced)")
241+
return cmd
242+
}
243+
244+
func networkDeleteAction(_ *cobra.Command, args []string) error {
245+
var yq string
246+
for i, name := range args {
247+
yq += fmt.Sprintf("del(.networks.%q)", name)
248+
if i < len(args)-1 {
249+
yq += " | "
250+
}
251+
}
252+
return networkApplyYQ(yq)
253+
}
254+
255+
func networkBashComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
256+
return bashCompleteNetworkNames(cmd)
257+
}

hack/test-templates.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,8 @@ fi
464464

465465
if [[ -n ${CHECKS["user-v2"]} ]]; then
466466
INFO "Testing user-v2 network"
467+
INFO 'Creating network "user-v2-another"'
468+
limactl network create user-v2-another --gateway 192.168.42.1/24
467469
secondvm="$NAME-1"
468470
"${LIMACTL_CREATE[@]}" --set ".additionalDisks=null" "$FILE_HOST" --name "$secondvm"
469471
if ! limactl start "$secondvm"; then
@@ -486,6 +488,8 @@ if [[ -n ${CHECKS["user-v2"]} ]]; then
486488
limactl stop "$secondvm"
487489
INFO "Deleting \"$secondvm\""
488490
limactl delete "$secondvm"
491+
INFO 'Deleting network "user-v2-another"'
492+
limactl network delete user-v2-another
489493
set +x
490494
fi
491495
if [[ -n ${CHECKS["snapshot-online"]} ]]; then

hack/test-templates/net-user-v2.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ mounts:
1515
writable: true
1616
networks:
1717
- lima: user-v2
18+
- lima: user-v2-another

pkg/networks/networks.go

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@ package networks
66
import "net"
77

88
type Config struct {
9-
Paths Paths `yaml:"paths"`
10-
Group string `yaml:"group,omitempty"` // default: "everyone"
11-
Networks map[string]Network `yaml:"networks"`
9+
Paths Paths `yaml:"paths" json:"paths"`
10+
Group string `yaml:"group,omitempty" json:"group,omitempty"` // default: "everyone"
11+
Networks map[string]Network `yaml:"networks" json:"networks"`
1212
}
1313

1414
type Paths struct {
15-
SocketVMNet string `yaml:"socketVMNet"`
16-
VarRun string `yaml:"varRun"`
17-
Sudoers string `yaml:"sudoers,omitempty"`
15+
SocketVMNet string `yaml:"socketVMNet" json:"socketVMNet"`
16+
VarRun string `yaml:"varRun" json:"varRun"`
17+
Sudoers string `yaml:"sudoers,omitempty" json:"sudoers,omitempty"`
1818
}
1919

2020
const (
@@ -24,10 +24,17 @@ const (
2424
ModeBridged = "bridged"
2525
)
2626

27+
var Modes = []string{
28+
ModeUserV2,
29+
ModeHost,
30+
ModeShared,
31+
ModeBridged,
32+
}
33+
2734
type Network struct {
28-
Mode string `yaml:"mode"` // "host", "shared", or "bridged"
29-
Interface string `yaml:"interface,omitempty"` // only used by "bridged" networks
30-
Gateway net.IP `yaml:"gateway,omitempty"` // only used by "host" and "shared" networks
31-
DHCPEnd net.IP `yaml:"dhcpEnd,omitempty"` // default: same as Gateway, last byte is 254
32-
NetMask net.IP `yaml:"netmask,omitempty"` // default: 255.255.255.0
35+
Mode string `yaml:"mode" json:"mode"` // "user-v2", "host", "shared", or "bridged"
36+
Interface string `yaml:"interface,omitempty" json:"interface,omitempty"` // only used by "bridged" networks
37+
Gateway net.IP `yaml:"gateway,omitempty" json:"gateway,omitempty"` // only used by "user-v2", "host" and "shared" networks
38+
DHCPEnd net.IP `yaml:"dhcpEnd,omitempty" json:"dhcpEnd,omitempty"` // default: same as Gateway, last byte is 254
39+
NetMask net.IP `yaml:"netmask,omitempty" json:"netmask,omitempty"` // default: 255.255.255.0
3340
}

0 commit comments

Comments
 (0)