Skip to content

Implement limactl network (list|create|delete) #3677

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

Merged
merged 1 commit into from
Jul 8, 2025
Merged
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/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@
package main

import (
"maps"
"net"
"slices"
"strings"

"github.com/spf13/cobra"

"github.com/lima-vm/lima/pkg/networks"
"github.com/lima-vm/lima/pkg/store"
"github.com/lima-vm/lima/pkg/templatestore"
)
Expand Down Expand Up @@ -51,3 +55,24 @@ func bashCompleteDiskNames(_ *cobra.Command) ([]string, cobra.ShellCompDirective
}
return disks, cobra.ShellCompDirectiveNoFileComp
}

func bashCompleteNetworkNames(_ *cobra.Command) ([]string, cobra.ShellCompDirective) {
config, err := networks.LoadConfig()
if err != nil {
return nil, cobra.ShellCompDirectiveDefault
}
networks := slices.Sorted(maps.Keys(config.Networks))
return networks, cobra.ShellCompDirectiveNoFileComp
}

func bashFlagCompleteNetworkInterfaceNames(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
intf, err := net.Interfaces()
if err != nil {
return nil, cobra.ShellCompDirectiveDefault
}
var intfNames []string
for _, f := range intf {
intfNames = append(intfNames, f.Name)
}
return intfNames, cobra.ShellCompDirectiveNoFileComp
}
10 changes: 5 additions & 5 deletions cmd/limactl/disk.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,11 @@ $ limactl disk list
return diskListCommand
}

func diskMatches(diskName string, disks []string) []string {
func nameMatches(nameName string, names []string) []string {
matches := []string{}
for _, disk := range disks {
if disk == diskName {
matches = append(matches, disk)
for _, name := range names {
if name == nameName {
matches = append(matches, name)
}
}
return matches
Expand All @@ -165,7 +165,7 @@ func diskListAction(cmd *cobra.Command, args []string) error {
disks := []string{}
if len(args) > 0 {
for _, arg := range args {
matches := diskMatches(arg, allDisks)
matches := nameMatches(arg, allDisks)
if len(matches) > 0 {
disks = append(disks, matches...)
} else {
Expand Down
1 change: 1 addition & 0 deletions cmd/limactl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ func newApp() *cobra.Command {
newRestartCommand(),
newSudoersCommand(),
newStartAtLoginCommand(),
newNetworkCommand(),
)

return rootCmd
Expand Down
271 changes: 271 additions & 0 deletions cmd/limactl/network.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
// SPDX-FileCopyrightText: Copyright The Lima Authors
// SPDX-License-Identifier: Apache-2.0

package main

import (
"encoding/json"
"errors"
"fmt"
"maps"
"net"
"os"
"slices"
"strings"
"text/tabwriter"

"github.com/sirupsen/logrus"
"github.com/spf13/cobra"

"github.com/lima-vm/lima/pkg/networks"
"github.com/lima-vm/lima/pkg/yqutil"
)

const networkCreateExample = ` Create a network:
$ limactl network create foo --gateway 192.168.42.1/24

Connect VM instances to the newly created network:
$ limactl create --network lima:foo --name vm1
$ limactl create --network lima:foo --name vm2
`

func newNetworkCommand() *cobra.Command {
networkCommand := &cobra.Command{
Use: "network",
Short: "Lima network management",
Example: networkCreateExample,
GroupID: advancedCommand,
}
networkCommand.AddCommand(
newNetworkListCommand(),
newNetworkCreateCommand(),
newNetworkDeleteCommand(),
)
return networkCommand
}

func newNetworkListCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List networks",
Aliases: []string{"ls"},
Args: WrapArgsError(cobra.ArbitraryArgs),
RunE: networkListAction,
ValidArgsFunction: networkBashComplete,
}
flags := cmd.Flags()
flags.Bool("json", false, "JSONify output")
return cmd
}

func networkListAction(cmd *cobra.Command, args []string) error {
flags := cmd.Flags()
jsonFormat, err := flags.GetBool("json")
if err != nil {
return err
}

config, err := networks.LoadConfig()
if err != nil {
return err
}

allNetworks := slices.Sorted(maps.Keys(config.Networks))

networks := []string{}
if len(args) > 0 {
for _, arg := range args {
matches := nameMatches(arg, allNetworks)
if len(matches) > 0 {
networks = append(networks, matches...)
} else {
logrus.Warnf("No network matching %v found.", arg)
}
}
} else {
networks = allNetworks
}

if jsonFormat {
w := cmd.OutOrStdout()
for _, name := range networks {
nw, ok := config.Networks[name]
if !ok {
logrus.Errorf("network %q does not exist", nw)
continue
}
j, err := json.Marshal(nw)
if err != nil {
return err
}
fmt.Fprintln(w, string(j))
}
return nil
}

w := tabwriter.NewWriter(cmd.OutOrStdout(), 4, 8, 4, ' ', 0)
fmt.Fprintln(w, "NAME\tMODE\tGATEWAY\tINTERFACE")
for _, name := range networks {
nw, ok := config.Networks[name]
if !ok {
logrus.Errorf("network %q does not exist", nw)
continue
}
gwStr := "-"
if nw.Gateway != nil {
gw := net.IPNet{
IP: nw.Gateway,
Mask: net.IPMask(nw.NetMask),
}
gwStr = gw.String()
}
intfStr := "-"
if nw.Interface != "" {
intfStr = nw.Interface
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, nw.Mode, gwStr, intfStr)
}
return w.Flush()
}

func newNetworkCreateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "create NETWORK",
Short: "Create a Lima network",
Example: networkCreateExample,
Args: WrapArgsError(cobra.ExactArgs(1)),
RunE: networkCreateAction,
}
flags := cmd.Flags()
flags.String("mode", networks.ModeUserV2, "mode")
_ = cmd.RegisterFlagCompletionFunc("mode", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return networks.Modes, cobra.ShellCompDirectiveNoFileComp
})
flags.String("gateway", "", "gateway, e.g., \"192.168.42.1/24\"")
flags.String("interface", "", "interface for bridged mode")
_ = cmd.RegisterFlagCompletionFunc("interface", bashFlagCompleteNetworkInterfaceNames)
return cmd
}

func networkCreateAction(cmd *cobra.Command, args []string) error {
name := args[0]
// LoadConfig ensures existence of networks.yaml
config, err := networks.LoadConfig()
if err != nil {
return err
}
if _, ok := config.Networks[name]; ok {
return fmt.Errorf("network %q already exists", name)
}

flags := cmd.Flags()
mode, err := flags.GetString("mode")
if err != nil {
return err
}

gateway, err := flags.GetString("gateway")
if err != nil {
return err
}

intf, err := flags.GetString("interface")
if err != nil {
return err
}

switch mode {
case networks.ModeBridged:
if gateway != "" {
return fmt.Errorf("network mode %q does not support specifying gateway", mode)
}
if intf == "" {
return fmt.Errorf("network mode %q requires specifying interface", mode)
}
default:
if gateway == "" {
return fmt.Errorf("network mode %q requires specifying gateway", mode)
}
if intf != "" {
return fmt.Errorf("network mode %q does not support specifying interface", mode)
}
}

if !strings.Contains(gateway, "/") {
gateway += "/24"
}
gwIP, gwMask, err := net.ParseCIDR(gateway)
if err != nil {
return fmt.Errorf("failed to parse CIDR %q: %w", gateway, err)
}
if gwIP.IsUnspecified() || gwIP.IsLoopback() {
return fmt.Errorf("invalid IP address: %v", gwIP)
}
gwMaskStr := "255.255.255.0"
if gwMask != nil {
gwMaskStr = net.IP(gwMask.Mask).String()
}
// TODO: check IP range collision

yq := fmt.Sprintf(`.networks.%q = {"mode":%q,"gateway":%q,"netmask":%q,"interface":%q}`, name, mode, gwIP.String(), gwMaskStr, intf)

return networkApplyYQ(yq)
}

func networkApplyYQ(yq string) error {
filePath, err := networks.ConfigFile()
if err != nil {
return err
}
yContent, err := os.ReadFile(filePath)
if err != nil {
return err
}
yBytes, err := yqutil.EvaluateExpression(yq, yContent)
if err != nil {
return err
}
if err := os.WriteFile(filePath, yBytes, 0o644); err != nil {
return err
}
return nil
}

func newNetworkDeleteCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "delete NETWORK [NETWORK, ...]",
Short: "Delete one or more Lima networks",
Aliases: []string{"remove", "rm"},
Args: WrapArgsError(cobra.MinimumNArgs(1)),
RunE: networkDeleteAction,
ValidArgsFunction: networkBashComplete,
}
flags := cmd.Flags()
flags.BoolP("force", "f", false, "Force delete (currently always required)")
return cmd
}

func networkDeleteAction(cmd *cobra.Command, args []string) error {
flags := cmd.Flags()
force, err := flags.GetBool("force")
if err != nil {
return err
}
if !force {
return errors.New("`limactl network delete` currently always requires `--force`")
// Because the command currently does not check whether the network being removed is in use
}
Copy link
Member Author

Choose a reason for hiding this comment

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

Updated the PR to always require --force for now


var yq string
for i, name := range args {
yq += fmt.Sprintf("del(.networks.%q)", name)
if i < len(args)-1 {
yq += " | "
}
}
return networkApplyYQ(yq)
}

func networkBashComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return bashCompleteNetworkNames(cmd)
}
29 changes: 18 additions & 11 deletions pkg/networks/networks.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ package networks
import "net"

type Config struct {
Paths Paths `yaml:"paths"`
Group string `yaml:"group,omitempty"` // default: "everyone"
Networks map[string]Network `yaml:"networks"`
Paths Paths `yaml:"paths" json:"paths"`
Group string `yaml:"group,omitempty" json:"group,omitempty"` // default: "everyone"
Networks map[string]Network `yaml:"networks" json:"networks"`
}

type Paths struct {
SocketVMNet string `yaml:"socketVMNet"`
VarRun string `yaml:"varRun"`
Sudoers string `yaml:"sudoers,omitempty"`
SocketVMNet string `yaml:"socketVMNet" json:"socketVMNet"`
VarRun string `yaml:"varRun" json:"varRun"`
Sudoers string `yaml:"sudoers,omitempty" json:"sudoers,omitempty"`
}

const (
Expand All @@ -24,10 +24,17 @@ const (
ModeBridged = "bridged"
)

var Modes = []string{
ModeUserV2,
ModeHost,
ModeShared,
ModeBridged,
}

type Network struct {
Mode string `yaml:"mode"` // "host", "shared", or "bridged"
Interface string `yaml:"interface,omitempty"` // only used by "bridged" networks
Gateway net.IP `yaml:"gateway,omitempty"` // only used by "host" and "shared" networks
DHCPEnd net.IP `yaml:"dhcpEnd,omitempty"` // default: same as Gateway, last byte is 254
NetMask net.IP `yaml:"netmask,omitempty"` // default: 255.255.255.0
Mode string `yaml:"mode" json:"mode"` // "user-v2", "host", "shared", or "bridged"
Interface string `yaml:"interface,omitempty" json:"interface,omitempty"` // only used by "bridged" networks
Gateway net.IP `yaml:"gateway,omitempty" json:"gateway,omitempty"` // only used by "user-v2", "host" and "shared" networks
DHCPEnd net.IP `yaml:"dhcpEnd,omitempty" json:"dhcpEnd,omitempty"` // default: same as Gateway, last byte is 254
NetMask net.IP `yaml:"netmask,omitempty" json:"netmask,omitempty"` // default: 255.255.255.0
}
Loading