Skip to content

Commit fc32f37

Browse files
committed
Plain mode: support port forwarding lima-vm#3699.
Signed-off-by: Praful Khanduri <[email protected]>
1 parent df4694a commit fc32f37

File tree

12 files changed

+552
-2
lines changed

12 files changed

+552
-2
lines changed

cmd/limactl/editflags/editflags.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ func RegisterCreate(cmd *cobra.Command, commentPrefix string) {
104104
})
105105

106106
flags.Bool("plain", false, commentPrefix+"Plain mode. Disables mounts, port forwarding, containerd, etc.")
107+
108+
flags.StringArray("port-forward", nil, commentPrefix+"Port forwards (host:guest), e.g., '8080:80' or '9090:9090,static=true' for static port-forwards")
109+
_ = cmd.RegisterFlagCompletionFunc("port-forward", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
110+
return []string{"8080:80", "3000:3000", "8080:80,static=true"}, cobra.ShellCompDirectiveNoFileComp
111+
})
107112
}
108113

109114
func defaultExprFunc(expr string) func(v *flag.Flag) (string, error) {
@@ -112,6 +117,56 @@ func defaultExprFunc(expr string) func(v *flag.Flag) (string, error) {
112117
}
113118
}
114119

120+
func ParsePortForward(spec string) (hostPort, guestPort string, isStatic bool, err error) {
121+
parts := strings.Split(spec, ",")
122+
if len(parts) > 2 {
123+
return "", "", false, fmt.Errorf("invalid port forward format %q, expected HOST:GUEST or HOST:GUEST,static=true", spec)
124+
}
125+
126+
portParts := strings.Split(strings.TrimSpace(parts[0]), ":")
127+
if len(portParts) != 2 {
128+
return "", "", false, fmt.Errorf("invalid port forward format %q, expected HOST:GUEST", parts[0])
129+
}
130+
131+
hostPort = strings.TrimSpace(portParts[0])
132+
guestPort = strings.TrimSpace(portParts[1])
133+
134+
if len(parts) == 2 {
135+
staticPart := strings.TrimSpace(parts[1])
136+
if strings.HasPrefix(staticPart, "static=") {
137+
staticValue := strings.TrimPrefix(staticPart, "static=")
138+
isStatic, err = strconv.ParseBool(staticValue)
139+
if err != nil {
140+
return "", "", false, fmt.Errorf("invalid value for static parameter: %q", staticValue)
141+
}
142+
} else {
143+
return "", "", false, fmt.Errorf("invalid parameter %q, expected 'static=' followed by a boolean value", staticPart)
144+
}
145+
}
146+
147+
return hostPort, guestPort, isStatic, nil
148+
}
149+
150+
func BuildPortForwardExpression(portForwards []string) (string, error) {
151+
if len(portForwards) == 0 {
152+
return "", nil
153+
}
154+
155+
expr := `.portForwards += [`
156+
for i, spec := range portForwards {
157+
hostPort, guestPort, isStatic, err := ParsePortForward(spec)
158+
if err != nil {
159+
return "", err
160+
}
161+
expr += fmt.Sprintf(`{"guestPort": %q, "hostPort": %q, "static": %v}`, guestPort, hostPort, isStatic)
162+
if i < len(portForwards)-1 {
163+
expr += ","
164+
}
165+
}
166+
expr += `]`
167+
return expr, nil
168+
}
169+
115170
// YQExpressions returns YQ expressions.
116171
func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) {
117172
type def struct {
@@ -215,6 +270,7 @@ func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) {
215270
false,
216271
false,
217272
},
273+
218274
{
219275
"rosetta",
220276
func(_ *flag.Flag) (string, error) {
@@ -270,6 +326,18 @@ func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) {
270326
},
271327
{"disk", d(".disk= \"%sGiB\""), false, false},
272328
{"plain", d(".plain = %s"), true, false},
329+
{
330+
"port-forward",
331+
func(_ *flag.Flag) (string, error) {
332+
ss, err := flags.GetStringArray("port-forward")
333+
if err != nil {
334+
return "", err
335+
}
336+
return BuildPortForwardExpression(ss)
337+
},
338+
false,
339+
false,
340+
},
273341
}
274342
var exprs []string
275343
for _, def := range defs {

cmd/limactl/editflags/editflags_test.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,137 @@ func TestCompleteMemoryGiB(t *testing.T) {
2323
assert.DeepEqual(t, []float32{1, 2, 4}, completeMemoryGiB(8<<30))
2424
assert.DeepEqual(t, []float32{1, 2, 4, 8, 10}, completeMemoryGiB(20<<30))
2525
}
26+
27+
func TestBuildPortForwardExpression(t *testing.T) {
28+
tests := []struct {
29+
name string
30+
portForwards []string
31+
expected string
32+
expectError bool
33+
}{
34+
{
35+
name: "empty port forwards",
36+
portForwards: []string{},
37+
expected: "",
38+
},
39+
{
40+
name: "single dynamic port forward",
41+
portForwards: []string{"8080:80"},
42+
expected: `.portForwards += [{"guestPort": "80", "hostPort": "8080", "static": false}]`,
43+
},
44+
{
45+
name: "single static port forward",
46+
portForwards: []string{"8080:80,static=true"},
47+
expected: `.portForwards += [{"guestPort": "80", "hostPort": "8080", "static": true}]`,
48+
},
49+
{
50+
name: "multiple mixed port forwards",
51+
portForwards: []string{"8080:80", "2222:22,static=true", "3000:3000"},
52+
expected: `.portForwards += [{"guestPort": "80", "hostPort": "8080", "static": false},{"guestPort": "22", "hostPort": "2222", "static": true},{"guestPort": "3000", "hostPort": "3000", "static": false}]`,
53+
},
54+
{
55+
name: "invalid format - missing colon",
56+
portForwards: []string{"8080"},
57+
expectError: true,
58+
},
59+
{
60+
name: "invalid format - too many colons",
61+
portForwards: []string{"8080:80:extra"},
62+
expectError: true,
63+
},
64+
{
65+
name: "invalid static parameter",
66+
portForwards: []string{"8080:80,invalid=true"},
67+
expectError: true,
68+
},
69+
{
70+
name: "too many parameters",
71+
portForwards: []string{"8080:80,static=true,extra=value"},
72+
expectError: true,
73+
},
74+
{
75+
name: "whitespace handling",
76+
portForwards: []string{" 8080 : 80 , static=true "},
77+
expected: `.portForwards += [{"guestPort": "80", "hostPort": "8080", "static": true}]`,
78+
},
79+
}
80+
81+
for _, tt := range tests {
82+
t.Run(tt.name, func(t *testing.T) {
83+
result, err := BuildPortForwardExpression(tt.portForwards)
84+
if tt.expectError {
85+
assert.Check(t, err != nil)
86+
} else {
87+
assert.NilError(t, err)
88+
assert.Equal(t, tt.expected, result)
89+
}
90+
})
91+
}
92+
}
93+
94+
func TestParsePortForward(t *testing.T) {
95+
tests := []struct {
96+
name string
97+
spec string
98+
hostPort string
99+
guestPort string
100+
isStatic bool
101+
expectError bool
102+
}{
103+
{
104+
name: "dynamic port forward",
105+
spec: "8080:80",
106+
hostPort: "8080",
107+
guestPort: "80",
108+
isStatic: false,
109+
},
110+
{
111+
name: "static port forward",
112+
spec: "8080:80,static=true",
113+
hostPort: "8080",
114+
guestPort: "80",
115+
isStatic: true,
116+
},
117+
{
118+
name: "whitespace handling",
119+
spec: " 8080 : 80 , static=true ",
120+
hostPort: "8080",
121+
guestPort: "80",
122+
isStatic: true,
123+
},
124+
{
125+
name: "invalid format - missing colon",
126+
spec: "8080",
127+
expectError: true,
128+
},
129+
{
130+
name: "invalid format - too many colons",
131+
spec: "8080:80:extra",
132+
expectError: true,
133+
},
134+
{
135+
name: "invalid parameter",
136+
spec: "8080:80,invalid=true",
137+
expectError: true,
138+
},
139+
{
140+
name: "too many parameters",
141+
spec: "8080:80,static=true,extra=value",
142+
expectError: true,
143+
},
144+
}
145+
146+
for _, tt := range tests {
147+
t.Run(tt.name, func(t *testing.T) {
148+
hostPort, guestPort, isStatic, err := ParsePortForward(tt.spec)
149+
if tt.expectError {
150+
assert.Check(t, err != nil)
151+
} else {
152+
assert.NilError(t, err)
153+
assert.Equal(t, tt.hostPort, hostPort)
154+
assert.Equal(t, tt.guestPort, guestPort)
155+
assert.Equal(t, tt.isStatic, isStatic)
156+
}
157+
})
158+
}
159+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#!/usr/bin/env bash
2+
3+
# SPDX-FileCopyrightText: Copyright The Lima Authors
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
set -euxo pipefail
7+
8+
INSTANCE=nonplain-static-port-forward
9+
TEMPLATE=hack/test-templates/static-port-forward.yaml
10+
11+
limactl delete -f $INSTANCE || true
12+
13+
limactl start --name=$INSTANCE --tty=false $TEMPLATE
14+
15+
limactl shell $INSTANCE -- bash -c 'until [ -e /run/nginx.pid ]; do sleep 1; done'
16+
limactl shell $INSTANCE -- bash -c 'until systemctl is-active --quiet test-server-9080; do sleep 1; done'
17+
limactl shell $INSTANCE -- bash -c 'until systemctl is-active --quiet test-server-9070; do sleep 1; done'
18+
19+
curl -sSf http://127.0.0.1:9090 | grep -i 'nginx' && echo 'Static port forwarding (9090) works in normal mode!'
20+
curl -sSf http://127.0.0.1:9080 | grep -i 'Dynamic port 9080' && echo 'Dynamic port forwarding (9080) works in normal mode!'
21+
curl -sSf http://127.0.0.1:9070 | grep -i 'Dynamic port 9070' && echo 'Dynamic port forwarding (9070) works in normal mode!'
22+
23+
limactl delete -f $INSTANCE
24+
echo "All tests passed for normal mode - both static and dynamic ports work!"
25+
# EOF
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/usr/bin/env bash
2+
3+
# SPDX-FileCopyrightText: Copyright The Lima Authors
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
set -euxo pipefail
7+
8+
INSTANCE=plain-static-port-forward
9+
TEMPLATE=hack/test-templates/static-port-forward.yaml
10+
11+
limactl delete -f $INSTANCE || true
12+
13+
limactl start --name=$INSTANCE --plain=true --tty=false $TEMPLATE
14+
15+
limactl shell $INSTANCE -- bash -c 'until [ -e /run/nginx.pid ]; do sleep 1; done'
16+
17+
curl -sSf http://127.0.0.1:9090 | grep -i 'nginx' && echo 'Static port forwarding (9090) works in plain mode!'
18+
19+
if curl -sSf http://127.0.0.1:9080 2>/dev/null; then
20+
echo 'ERROR: Dynamic port 9080 should not be forwarded in plain mode!'
21+
exit 1
22+
else
23+
echo 'Dynamic port 9080 is correctly NOT forwarded in plain mode.'
24+
fi
25+
26+
if curl -sSf http://127.0.0.1:9070 2>/dev/null; then
27+
echo 'ERROR: Dynamic port 9070 should not be forwarded in plain mode!'
28+
exit 1
29+
else
30+
echo 'Dynamic port 9070 is correctly NOT forwarded in plain mode.'
31+
fi
32+
33+
limactl delete -f $INSTANCE
34+
echo "All tests passed for plain mode - only static ports work!"
35+
# EOF

hack/test-templates.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ declare -A CHECKS=(
5151
["snapshot-offline"]=""
5252
["clone"]=""
5353
["port-forwards"]="1"
54+
["static-port-forwards"]=""
5455
["vmnet"]=""
5556
["disk"]=""
5657
["user-v2"]=""
@@ -95,6 +96,12 @@ case "$NAME" in
9596
CHECKS["param-env-variables"]="1"
9697
CHECKS["set-user"]="1"
9798
;;
99+
"static-port-forward")
100+
CHECKS["static-port-forwards"]="1"
101+
CHECKS["port-forwards"]=""
102+
CHECKS["container-engine"]=""
103+
CHECKS["restart"]=""
104+
;;
98105
"docker")
99106
CONTAINER_ENGINE="docker"
100107
;;
@@ -405,6 +412,13 @@ if [[ -n ${CHECKS["port-forwards"]} ]]; then
405412
set +x
406413
fi
407414

415+
if [[ -n ${CHECKS["static-port-forwards"]} ]]; then
416+
INFO "Testing static port forwarding functionality"
417+
"${scriptdir}/test-plain-static-port-forward.sh" "$NAME"
418+
"${scriptdir}/test-nonplain-static-port-forward.sh" "$NAME"
419+
INFO "All static port forwarding tests passed!"
420+
fi
421+
408422
if [[ -n ${CHECKS["vmnet"]} ]]; then
409423
INFO "Testing vmnet functionality"
410424
guestip="$(limactl shell "$NAME" ip -4 -j addr show dev lima0 | jq -r '.[0].addr_info[0].local')"
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
images:
2+
- location: "https://cloud-images.ubuntu.com/releases/22.04/release/ubuntu-22.04-server-cloudimg-amd64.img"
3+
arch: "x86_64"
4+
5+
provision:
6+
- mode: system
7+
script: |
8+
apt-get update
9+
apt-get install -y nginx python3
10+
systemctl enable nginx
11+
systemctl start nginx
12+
13+
cat > /etc/systemd/system/test-server-9080.service << 'EOF'
14+
[Unit]
15+
Description=Test Server on Port 9080
16+
After=network.target
17+
18+
[Service]
19+
Type=simple
20+
User=root
21+
ExecStart=/usr/bin/python3 -m http.server 9080 --bind 127.0.0.1
22+
Restart=always
23+
24+
[Install]
25+
WantedBy=multi-user.target
26+
EOF
27+
28+
cat > /etc/systemd/system/test-server-9070.service << 'EOF'
29+
[Unit]
30+
Description=Test Server on Port 9070
31+
After=network.target
32+
33+
[Service]
34+
Type=simple
35+
User=root
36+
ExecStart=/usr/bin/python3 -m http.server 9070 --bind 127.0.0.1
37+
Restart=always
38+
39+
[Install]
40+
WantedBy=multi-user.target
41+
EOF
42+
43+
mkdir -p /var/www/html-9080
44+
mkdir -p /var/www/html-9070
45+
46+
echo '<html><body><h1>Dynamic port 9080</h1></body></html>' > /var/www/html-9080/index.html
47+
echo '<html><body><h1>Dynamic port 9070</h1></body></html>' > /var/www/html-9070/index.html
48+
49+
systemctl daemon-reload
50+
systemctl enable test-server-9080
51+
systemctl enable test-server-9070
52+
systemctl start test-server-9080
53+
systemctl start test-server-9070
54+
55+
portForwards:
56+
- guestPort: 80
57+
hostPort: 9090
58+
static: true
59+
- guestPort: 9080
60+
hostPort: 9080
61+
static: false
62+
- guestPort: 9070
63+
hostPort: 9070
64+
static: false

0 commit comments

Comments
 (0)