Skip to content
Open
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
1 change: 1 addition & 0 deletions cmd/unikraft/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ func instancesHelpTests(t *testing.T, unikraftPath string) {
[]string{"unikraft", "instance", "stop", "--help"},
[]string{"unikraft", "instance", "suspend", "--help"},
[]string{"unikraft", "instance", "restart", "--help"},
[]string{"unikraft", "instance", "tunnel", "--help"},
)
}

Expand Down
32 changes: 32 additions & 0 deletions cmd/unikraft/integration/instance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -825,4 +825,36 @@ cmd: ["cat", "/rom/hello.txt"]
// Command still running
}
})

t.Run("tunnel", func(t *testing.T) {
r := runner(t, true)
instName := uniq()

// Create an nginx instance without a public service.
r.Run(t, []string{
"unikraft", "instance", "create",
"--output", "quiet",
"--set", "name=test-" + instName,
"--set", "metro=" + r.Config.MetroName,
"--set", "image=nginx:latest",
"--set", "autostart=true",
"--set", "resources.memory=128",
"--set", "resources.vcpus=1",
})
r.Run(t, []string{"unikraft", "instance", "wait", "--until", "state==running", "--timeout", "60s", "test-" + instName})

// Use metro/instance:port/tcp syntax for the tunnel target.
tunnel := r.StartBackground(t,
[]string{"unikraft", "instance", "tunnel", "18081:" + r.Config.MetroName + "/test-" + instName + ":8080/tcp"},
"127.0.0.1:18081",
0,
)

// Verify that we can reach the instance through the tunnel.
body := integ.HTTPGet(t, "http://127.0.0.1:18081")
assert.Regexp(t, "Thank you for using nginx.", body)

r.StopBackground(t, tunnel)
r.Run(t, []string{"unikraft", "instance", "delete", "test-" + instName})
})
}
55 changes: 55 additions & 0 deletions cmd/unikraft/testdata/TestHelp/instances

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ require (
gotest.tools/v3 v3.5.2
mvdan.cc/sh/v3 v3.13.1
sigs.k8s.io/yaml v1.6.0
unikraft.com/cloud/sdk v0.0.0-20260610162742-d9f288f10c67
unikraft.com/cloud/sdk v0.0.0-20260701141639-e77a1fd88abf
unikraft.com/x/colors v0.0.0-20260313145522-d793c36d706e
unikraft.com/x/filters v0.0.0-20260416164455-ec39ae908f3f
unikraft.com/x/fingerprint v0.0.0-20260126094137-ab6e717e5679
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -485,8 +485,8 @@ sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
tailscale.com v1.94.1 h1:0dAst/ozTuFkgmxZULc3oNwR9+qPIt5ucvzH7kaM0Jw=
tailscale.com v1.94.1/go.mod h1:gLnVrEOP32GWvroaAHHGhjSGMPJ1i4DvqNwEg+Yuov4=
unikraft.com/cloud/sdk v0.0.0-20260610162742-d9f288f10c67 h1:T/hIAHzgEqQmBLkO0l9YC5GX8Sixpzc5HYklKxbdNBM=
unikraft.com/cloud/sdk v0.0.0-20260610162742-d9f288f10c67/go.mod h1:XM2/ocAKjvRqXN5oXoKQAI/bIdu452d9q9bdtehQcao=
unikraft.com/cloud/sdk v0.0.0-20260701141639-e77a1fd88abf h1:VmeyVleZ4YYgzBT+uSO58CWWKZUigxArXC2fHP/SVSM=
unikraft.com/cloud/sdk v0.0.0-20260701141639-e77a1fd88abf/go.mod h1:XM2/ocAKjvRqXN5oXoKQAI/bIdu452d9q9bdtehQcao=
unikraft.com/x/colors v0.0.0-20260313145522-d793c36d706e h1:C/V6l4ut5XpcVTN5CvnskRv6NHDbyIeLdgFVLEJ9BIE=
unikraft.com/x/colors v0.0.0-20260313145522-d793c36d706e/go.mod h1:SVlAGfyQ7MwJom7m9M2w83+TrO+nJoiLxeduJAxagEo=
unikraft.com/x/filters v0.0.0-20260416164455-ec39ae908f3f h1:v6pitpzsBnOjyzDIW0/YAEHHSI6cNOw4QOwQV0uD+dc=
Expand Down
73 changes: 73 additions & 0 deletions internal/cmd/instances.go
Comment thread
craciunoiuc marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"strconv"
"strings"

"github.com/MakeNowJust/heredoc"
"github.com/alecthomas/kong"
"github.com/distribution/reference"
"github.com/go-json-experiment/json/jsontext"
Expand All @@ -39,6 +40,7 @@ import (
"unikraft.com/cli/internal/resource"
"unikraft.com/cli/internal/resource/cmd"
"unikraft.com/cli/internal/resource/value"
"unikraft.com/cli/internal/tunnel"
"unikraft.com/cli/internal/types"
)

Expand All @@ -58,6 +60,7 @@ type InstancesCmd struct {
Stop InstancesStopCmd `cmd:"" help:"Stop one or more instances."`
Suspend InstancesSuspendCmd `cmd:"" help:"Suspend one or more instances."`
Restart InstancesRestartCmd `cmd:"" help:"Restart one or more instances."`
Tunnel InstancesTunnelCmd `cmd:"" help:"Forward a local port to an unexposed instance."`
}

// InstanceCreateCmd extends the generic resource create command with shortcut
Expand Down Expand Up @@ -1845,3 +1848,73 @@ func suspendInstances(ctx context.Context, g *group.Group[multimetro.MetroClient
})
return multimetro.Keys(suspended), err
}

type InstancesTunnelCmd struct {
Targets []string `arg:"" name:"target" min:"1" help:"Forwarding target(s) in the form [LOCAL_PORT:]<INSTANCE>:DEST_PORT[/TYPE]." placeholder:"<target>"`

TunnelProxyPorts []string `short:"p" name:"tunnel-proxy-port" help:"Remote port(s) exposed by the tunnel service. When a single value is given it is used as the starting port for multiple targets." default:"4444" hidden:""`
ProxyControlPort uint `short:"P" name:"tunnel-control-port" help:"Command-and-control port of the tunnel service." default:"4443" hidden:""`
TunnelServiceImage string ` name:"tunnel-image" help:"Image to use for the tunnel service." default:"official/utils/tunnel:1.0" hidden:""`
Comment thread
craciunoiuc marked this conversation as resolved.
Comment thread
craciunoiuc marked this conversation as resolved.
}

func (InstancesTunnelCmd) Help() string {
return heredoc.Docf(`
Forward a local port to an unexposed instance through an intermediate
TLS tunnel service.

When you need to access an instance on Unikraft Cloud which is not
publicly exposed to the internet, you can use the
%[1]sunikraft instance tunnel%[1]s subcommand to forward from a local
port to a port which the instance listens on.
`, "`")
}

func (cmd InstancesTunnelCmd) Examples() []kingkong.Example {
return []kingkong.Example{
{
Description: "Forward local port 8080 to instance \"nginx\" port 8080",
Commands: []string{"unikraft instance tunnel nginx:8080"},
},
{
Description: "Forward local port 8333 to instance \"nginx\" port 8080",
Commands: []string{"unikraft instance tunnel 8333:nginx:8080"},
},
{
Description: "Forward multiple ports from multiple instances",
Commands: []string{"unikraft instance tunnel 8080:my-instance1:8080/tcp 8443:my-instance2:8080/tcp"},
},
{
Description: "Forward local port 8080 to instance \"my-instance1\" port 8080 on fra metro using TCP",
Commands: []string{"unikraft instance tunnel 8080:fra/my-instance1:8080/tcp"},
},
{
Description: "Use a custom relay port to avoid collisions",
Commands: []string{"unikraft instance tunnel -p 5500 my-instance:8080"},
},
}
}

func (cmd *InstancesTunnelCmd) Run(ctx context.Context, stdio config.Stdio) error {
targets, err := tunnel.ParseTargets(cmd.Targets, cmd.TunnelProxyPorts)
if err != nil {
return fmt.Errorf("could not parse targets: %w", err)
}

tun, err := tunnel.New(ctx, targets)
if err != nil {
return fmt.Errorf("could not create tunnel: %w", err)
}

g, err := multimetro.NewClient(ctx)
if err != nil {
return err
}

defer func() {
if err := tun.Close(context.WithoutCancel(ctx), g); err != nil {
log.G(ctx).Error().Err(err).Msg("could not terminate tunnel proxy")
}
}()

return tun.Run(ctx, g, cmd.ProxyControlPort, cmd.TunnelServiceImage)
}
89 changes: 89 additions & 0 deletions internal/integration/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ package integration
import (
"bytes"
"context"
"net"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"

Expand Down Expand Up @@ -138,3 +140,90 @@ func (env *TestEnv) Run(t *testing.T, args []string, opts ...CmdOption) string {
}
return out
}

// BackgroundProcess is a handle to a process started by StartBackground.
type BackgroundProcess struct {
cmd *exec.Cmd
output *bytes.Buffer
t *testing.T
once sync.Once
}

func (p *BackgroundProcess) stop() {
p.once.Do(func() {
_ = p.cmd.Cancel()
err := p.cmd.Wait()
if err != nil && p.output.Len() > 0 {
p.t.Logf("background process output:\n%s", p.output.String())
}
})
}

// StartBackground starts a command in the background and registers cleanup to
// stop it when the test ends. If waitAddr is non-empty, it polls that TCP
// address until it is reachable or the timeout (default 90s) expires.
// The returned handle can be passed to StopBackground for early termination.
func (env *TestEnv) StartBackground(t *testing.T, args []string, waitAddr string, timeout time.Duration) *BackgroundProcess {
t.Helper()
t.Logf("starting background: %s", strings.Join(args, " "))

var c *exec.Cmd
if args[0] == "unikraft" {
c = exec.CommandContext(t.Context(), env.unikraftPath, args[1:]...)
} else {
c = exec.CommandContext(t.Context(), args[0], args[1:]...)
}

c.Env = os.Environ()
c.Env = slices.DeleteFunc(c.Env, func(s string) bool {
return strings.HasPrefix(s, "UNIKRAFT_")
})
c.Env = append(c.Env, "NO_COLOR=1")
c.Env = append(c.Env, "UNIKRAFT_CONFIG="+env.configPath)
c.Env = append(c.Env, "BUILDKIT_PROGRESS=quiet")
c.Env = append(c.Env, resource.UnikraftSandboxEnv+"="+env.sandboxPath)
c.Cancel = func() error {
return c.Process.Signal(os.Interrupt)
}
c.WaitDelay = 30 * time.Second
Comment thread
craciunoiuc marked this conversation as resolved.

var output bytes.Buffer
c.Stdout = &output
c.Stderr = &output

require.NoError(t, c.Start(), "failed to start background command %q", strings.Join(args, " "))

proc := &BackgroundProcess{cmd: c, output: &output, t: t}
t.Cleanup(proc.stop)

if waitAddr != "" {
waitForAddr(t, waitAddr, timeout)
}

return proc
}

// StopBackground terminates a background process immediately, without waiting
// for the test to finish.
func (env *TestEnv) StopBackground(t *testing.T, proc *BackgroundProcess) {
t.Helper()
t.Logf("stopping background process")
proc.stop()
}

func waitForAddr(t *testing.T, addr string, timeout time.Duration) {
t.Helper()
if timeout == 0 {
timeout = 90 * time.Second
}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, time.Second)
if err == nil {
conn.Close()
return
}
time.Sleep(500 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s to become reachable", addr)
}
Loading
Loading