diff --git a/cmd/limactl/editflags/editflags.go b/cmd/limactl/editflags/editflags.go index ae9fa593e4f..1b14f8fcdee 100644 --- a/cmd/limactl/editflags/editflags.go +++ b/cmd/limactl/editflags/editflags.go @@ -96,6 +96,11 @@ func RegisterCreate(cmd *cobra.Command, commentPrefix string) { }) flags.Bool("plain", false, commentPrefix+"Plain mode. Disables mounts, port forwarding, containerd, etc.") + + flags.StringArray("port-forward", nil, commentPrefix+"Port forwards (host:guest), e.g., '8080:80' or '9090:9090,static=true' for static port-forwards") + _ = cmd.RegisterFlagCompletionFunc("port-forward", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return []string{"8080:80", "3000:3000", "8080:80,static=true"}, cobra.ShellCompDirectiveNoFileComp + }) } func defaultExprFunc(expr string) func(v *flag.Flag) (string, error) { @@ -104,6 +109,56 @@ func defaultExprFunc(expr string) func(v *flag.Flag) (string, error) { } } +func ParsePortForward(spec string) (hostPort, guestPort string, isStatic bool, err error) { + parts := strings.Split(spec, ",") + if len(parts) > 2 { + return "", "", false, fmt.Errorf("invalid port forward format %q, expected HOST:GUEST or HOST:GUEST,static=true", spec) + } + + portParts := strings.Split(strings.TrimSpace(parts[0]), ":") + if len(portParts) != 2 { + return "", "", false, fmt.Errorf("invalid port forward format %q, expected HOST:GUEST", parts[0]) + } + + hostPort = strings.TrimSpace(portParts[0]) + guestPort = strings.TrimSpace(portParts[1]) + + if len(parts) == 2 { + staticPart := strings.TrimSpace(parts[1]) + if strings.HasPrefix(staticPart, "static=") { + staticValue := strings.TrimPrefix(staticPart, "static=") + isStatic, err = strconv.ParseBool(staticValue) + if err != nil { + return "", "", false, fmt.Errorf("invalid value for static parameter: %q", staticValue) + } + } else { + return "", "", false, fmt.Errorf("invalid parameter %q, expected 'static=' followed by a boolean value", staticPart) + } + } + + return hostPort, guestPort, isStatic, nil +} + +func BuildPortForwardExpression(portForwards []string) (string, error) { + if len(portForwards) == 0 { + return "", nil + } + + expr := `.portForwards += [` + for i, spec := range portForwards { + hostPort, guestPort, isStatic, err := ParsePortForward(spec) + if err != nil { + return "", err + } + expr += fmt.Sprintf(`{"guestPort": %q, "hostPort": %q, "static": %v}`, guestPort, hostPort, isStatic) + if i < len(portForwards)-1 { + expr += "," + } + } + expr += `]` + return expr, nil +} + // YQExpressions returns YQ expressions. func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) { type def struct { @@ -206,6 +261,7 @@ func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) { false, false, }, + { "rosetta", func(_ *flag.Flag) (string, error) { @@ -261,6 +317,18 @@ func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) { {"disk", d(".disk= \"%sGiB\""), false, false}, {"vm-type", d(".vmType = %q"), true, false}, {"plain", d(".plain = %s"), true, false}, + { + "port-forward", + func(_ *flag.Flag) (string, error) { + ss, err := flags.GetStringArray("port-forward") + if err != nil { + return "", err + } + return BuildPortForwardExpression(ss) + }, + false, + false, + }, } var exprs []string for _, def := range defs { diff --git a/cmd/limactl/editflags/editflags_test.go b/cmd/limactl/editflags/editflags_test.go index 2dceac15596..1634da8211a 100644 --- a/cmd/limactl/editflags/editflags_test.go +++ b/cmd/limactl/editflags/editflags_test.go @@ -23,3 +23,137 @@ func TestCompleteMemoryGiB(t *testing.T) { assert.DeepEqual(t, []float32{1, 2, 4}, completeMemoryGiB(8<<30)) assert.DeepEqual(t, []float32{1, 2, 4, 8, 10}, completeMemoryGiB(20<<30)) } + +func TestBuildPortForwardExpression(t *testing.T) { + tests := []struct { + name string + portForwards []string + expected string + expectError bool + }{ + { + name: "empty port forwards", + portForwards: []string{}, + expected: "", + }, + { + name: "single dynamic port forward", + portForwards: []string{"8080:80"}, + expected: `.portForwards += [{"guestPort": "80", "hostPort": "8080", "static": false}]`, + }, + { + name: "single static port forward", + portForwards: []string{"8080:80,static=true"}, + expected: `.portForwards += [{"guestPort": "80", "hostPort": "8080", "static": true}]`, + }, + { + name: "multiple mixed port forwards", + portForwards: []string{"8080:80", "2222:22,static=true", "3000:3000"}, + expected: `.portForwards += [{"guestPort": "80", "hostPort": "8080", "static": false},{"guestPort": "22", "hostPort": "2222", "static": true},{"guestPort": "3000", "hostPort": "3000", "static": false}]`, + }, + { + name: "invalid format - missing colon", + portForwards: []string{"8080"}, + expectError: true, + }, + { + name: "invalid format - too many colons", + portForwards: []string{"8080:80:extra"}, + expectError: true, + }, + { + name: "invalid static parameter", + portForwards: []string{"8080:80,invalid=true"}, + expectError: true, + }, + { + name: "too many parameters", + portForwards: []string{"8080:80,static=true,extra=value"}, + expectError: true, + }, + { + name: "whitespace handling", + portForwards: []string{" 8080 : 80 , static=true "}, + expected: `.portForwards += [{"guestPort": "80", "hostPort": "8080", "static": true}]`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := BuildPortForwardExpression(tt.portForwards) + if tt.expectError { + assert.Check(t, err != nil) + } else { + assert.NilError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} + +func TestParsePortForward(t *testing.T) { + tests := []struct { + name string + spec string + hostPort string + guestPort string + isStatic bool + expectError bool + }{ + { + name: "dynamic port forward", + spec: "8080:80", + hostPort: "8080", + guestPort: "80", + isStatic: false, + }, + { + name: "static port forward", + spec: "8080:80,static=true", + hostPort: "8080", + guestPort: "80", + isStatic: true, + }, + { + name: "whitespace handling", + spec: " 8080 : 80 , static=true ", + hostPort: "8080", + guestPort: "80", + isStatic: true, + }, + { + name: "invalid format - missing colon", + spec: "8080", + expectError: true, + }, + { + name: "invalid format - too many colons", + spec: "8080:80:extra", + expectError: true, + }, + { + name: "invalid parameter", + spec: "8080:80,invalid=true", + expectError: true, + }, + { + name: "too many parameters", + spec: "8080:80,static=true,extra=value", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hostPort, guestPort, isStatic, err := ParsePortForward(tt.spec) + if tt.expectError { + assert.Check(t, err != nil) + } else { + assert.NilError(t, err) + assert.Equal(t, tt.hostPort, hostPort) + assert.Equal(t, tt.guestPort, guestPort) + assert.Equal(t, tt.isStatic, isStatic) + } + }) + } +} diff --git a/hack/test-nonplain-static-port-forward.sh b/hack/test-nonplain-static-port-forward.sh new file mode 100755 index 00000000000..92a94b95420 --- /dev/null +++ b/hack/test-nonplain-static-port-forward.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright The Lima Authors +# SPDX-License-Identifier: Apache-2.0 + +set -euxo pipefail + +INSTANCE=nonplain-static-port-forward +TEMPLATE=hack/test-templates/static-port-forward.yaml + +limactl delete -f $INSTANCE || true + +limactl start --name=$INSTANCE --tty=false $TEMPLATE + +limactl shell $INSTANCE -- bash -c 'until [ -e /run/nginx.pid ]; do sleep 1; done' +limactl shell $INSTANCE -- bash -c 'until systemctl is-active --quiet test-server-9080; do sleep 1; done' +limactl shell $INSTANCE -- bash -c 'until systemctl is-active --quiet test-server-9070; do sleep 1; done' + +curl -sSf http://127.0.0.1:9090 | grep -i 'nginx' && echo 'Static port forwarding (9090) works in normal mode!' +curl -sSf http://127.0.0.1:9080 | grep -i 'Dynamic port 9080' && echo 'Dynamic port forwarding (9080) works in normal mode!' +curl -sSf http://127.0.0.1:9070 | grep -i 'Dynamic port 9070' && echo 'Dynamic port forwarding (9070) works in normal mode!' + +limactl delete -f $INSTANCE +echo "All tests passed for normal mode - both static and dynamic ports work!" +# EOF diff --git a/hack/test-plain-static-port-forward.sh b/hack/test-plain-static-port-forward.sh new file mode 100755 index 00000000000..fbe70d62742 --- /dev/null +++ b/hack/test-plain-static-port-forward.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright The Lima Authors +# SPDX-License-Identifier: Apache-2.0 + +set -euxo pipefail + +INSTANCE=plain-static-port-forward +TEMPLATE=hack/test-templates/static-port-forward.yaml + +limactl delete -f $INSTANCE || true + +limactl start --name=$INSTANCE --plain=true --tty=false $TEMPLATE + +limactl shell $INSTANCE -- bash -c 'until [ -e /run/nginx.pid ]; do sleep 1; done' + +curl -sSf http://127.0.0.1:9090 | grep -i 'nginx' && echo 'Static port forwarding (9090) works in plain mode!' + +if curl -sSf http://127.0.0.1:9080 2>/dev/null; then + echo 'ERROR: Dynamic port 9080 should not be forwarded in plain mode!' + exit 1 +else + echo 'Dynamic port 9080 is correctly NOT forwarded in plain mode.' +fi + +if curl -sSf http://127.0.0.1:9070 2>/dev/null; then + echo 'ERROR: Dynamic port 9070 should not be forwarded in plain mode!' + exit 1 +else + echo 'Dynamic port 9070 is correctly NOT forwarded in plain mode.' +fi + +limactl delete -f $INSTANCE +echo "All tests passed for plain mode - only static ports work!" +# EOF diff --git a/hack/test-templates.sh b/hack/test-templates.sh index 2935907c99e..a6e692a5fe5 100755 --- a/hack/test-templates.sh +++ b/hack/test-templates.sh @@ -50,6 +50,7 @@ declare -A CHECKS=( ["snapshot-online"]="" ["snapshot-offline"]="" ["port-forwards"]="1" + ["static-port-forwards"]="" ["vmnet"]="" ["disk"]="" ["user-v2"]="" @@ -90,6 +91,12 @@ case "$NAME" in CHECKS["param-env-variables"]="1" CHECKS["set-user"]="1" ;; +"static-port-forward") + CHECKS["static-port-forwards"]="1" + CHECKS["port-forwards"]="" + CHECKS["container-engine"]="" + CHECKS["restart"]="" + ;; "docker") CONTAINER_ENGINE="docker" ;; @@ -383,6 +390,13 @@ if [[ -n ${CHECKS["port-forwards"]} ]]; then set +x fi +if [[ -n ${CHECKS["static-port-forwards"]} ]]; then + INFO "Testing static port forwarding functionality" + "${scriptdir}/test-plain-static-port-forward.sh" "$NAME" + "${scriptdir}/test-nonplain-static-port-forward.sh" "$NAME" + INFO "All static port forwarding tests passed!" +fi + if [[ -n ${CHECKS["vmnet"]} ]]; then INFO "Testing vmnet functionality" guestip="$(limactl shell "$NAME" ip -4 -j addr show dev lima0 | jq -r '.[0].addr_info[0].local')" diff --git a/hack/test-templates/static-port-forward.yaml b/hack/test-templates/static-port-forward.yaml new file mode 100644 index 00000000000..67998d54312 --- /dev/null +++ b/hack/test-templates/static-port-forward.yaml @@ -0,0 +1,64 @@ +images: +- location: "https://cloud-images.ubuntu.com/releases/22.04/release/ubuntu-22.04-server-cloudimg-amd64.img" + arch: "x86_64" + +provision: +- mode: system + script: | + apt-get update + apt-get install -y nginx python3 + systemctl enable nginx + systemctl start nginx + + cat > /etc/systemd/system/test-server-9080.service << 'EOF' + [Unit] + Description=Test Server on Port 9080 + After=network.target + + [Service] + Type=simple + User=root + ExecStart=/usr/bin/python3 -m http.server 9080 --bind 127.0.0.1 + Restart=always + + [Install] + WantedBy=multi-user.target + EOF + + cat > /etc/systemd/system/test-server-9070.service << 'EOF' + [Unit] + Description=Test Server on Port 9070 + After=network.target + + [Service] + Type=simple + User=root + ExecStart=/usr/bin/python3 -m http.server 9070 --bind 127.0.0.1 + Restart=always + + [Install] + WantedBy=multi-user.target + EOF + + mkdir -p /var/www/html-9080 + mkdir -p /var/www/html-9070 + + echo '