Skip to content

Commit 0f36e75

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

File tree

5 files changed

+320
-16
lines changed

5 files changed

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

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)