Skip to content

Commit f5d14a8

Browse files
committed
labctl port-forward --restore
1 parent cdaf82c commit f5d14a8

File tree

11 files changed

+347
-99
lines changed

11 files changed

+347
-99
lines changed

api/playgrounds.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type Playground struct {
2525
InitTasks map[string]InitTask `yaml:"initTasks,omitempty" json:"initTasks,omitempty"`
2626
InitConditions InitConditions `yaml:"initConditions,omitempty" json:"initConditions,omitempty"`
2727
RegistryAuth string `yaml:"registryAuth,omitempty" json:"registryAuth,omitempty"`
28+
PortForwards []PortForward `yaml:"portForwards,omitempty" json:"portForwards,omitempty"`
2829

2930
AccessControl PlaygroundAccessControl `yaml:"accessControl" json:"accessControl"`
3031
UserAccess PlaygroundUserAccess `yaml:"userAccess,omitempty" json:"userAccess,omitempty"`
@@ -251,6 +252,7 @@ type PlaygroundSpec struct {
251252
InitTasks map[string]InitTask `yaml:"initTasks,omitempty" json:"initTasks,omitempty"`
252253
InitConditions InitConditions `yaml:"initConditions,omitempty" json:"initConditions,omitempty"`
253254
RegistryAuth string `yaml:"registryAuth,omitempty" json:"registryAuth,omitempty"`
255+
PortForwards []PortForward `yaml:"portForwards,omitempty" json:"portForwards,omitempty"`
254256

255257
// Deprecated: Use PlaygroundAccessControl instead
256258
Access *PlaygroundAccess `yaml:"access,omitempty" json:"access,omitempty"`
@@ -289,6 +291,7 @@ type CreatePlaygroundRequest struct {
289291
InitTasks map[string]InitTask `yaml:"initTasks" json:"initTasks"`
290292
InitConditions InitConditions `yaml:"initConditions" json:"initConditions"`
291293
RegistryAuth string `yaml:"registryAuth" json:"registryAuth"`
294+
PortForwards []PortForward `yaml:"portForwards,omitempty" json:"portForwards,omitempty"`
292295

293296
AccessControl PlaygroundAccessControl `yaml:"accessControl" json:"accessControl"`
294297
}
@@ -315,6 +318,7 @@ type UpdatePlaygroundRequest struct {
315318
InitTasks map[string]InitTask `yaml:"initTasks" json:"initTasks"`
316319
InitConditions InitConditions `yaml:"initConditions" json:"initConditions"`
317320
RegistryAuth string `yaml:"registryAuth" json:"registryAuth"`
321+
PortForwards []PortForward `yaml:"portForwards,omitempty" json:"portForwards,omitempty"`
318322

319323
AccessControl PlaygroundAccessControl `yaml:"accessControl" json:"accessControl"`
320324
}

api/port_forwards.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"strconv"
6+
)
7+
8+
type PortForward struct {
9+
Kind string `json:"kind"`
10+
Machine string `json:"machine"`
11+
LocalHost string `json:"localHost,omitempty"`
12+
LocalPort int `json:"localPort,omitempty"`
13+
RemotePort int `json:"remotePort,omitempty"`
14+
RemoteHost string `json:"remoteHost,omitempty"`
15+
}
16+
17+
func (c *Client) ListPortForwards(ctx context.Context, playID string) ([]*PortForward, error) {
18+
var resp []*PortForward
19+
return resp, c.GetInto(ctx, "/plays/"+playID+"/port-forwards", nil, nil, &resp)
20+
}
21+
22+
func (c *Client) AddPortForward(ctx context.Context, playID string, pf PortForward) (*PortForward, error) {
23+
body, err := toJSONBody(pf)
24+
if err != nil {
25+
return nil, err
26+
}
27+
28+
var resp PortForward
29+
return &resp, c.PostInto(ctx, "/plays/"+playID+"/port-forwards", nil, nil, body, &resp)
30+
}
31+
32+
func (c *Client) RemovePortForward(ctx context.Context, playID string, index int) error {
33+
_, err := c.Delete(ctx, "/plays/"+playID+"/port-forwards/"+strconv.Itoa(index), nil, nil)
34+
return err
35+
}

cmd/playground/manifest.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ func runManifest(ctx context.Context, cli labcli.CLI, opts *manifestOptions) err
5555
InitTasks: playground.InitTasks,
5656
InitConditions: playground.InitConditions,
5757
RegistryAuth: playground.RegistryAuth,
58+
PortForwards: playground.PortForwards,
5859
AccessControl: playground.AccessControl,
5960
},
6061
}

cmd/playground/restart.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/iximiuz/labctl/cmd/ssh"
1515
"github.com/iximiuz/labctl/cmd/sshproxy"
1616
"github.com/iximiuz/labctl/internal/labcli"
17+
"github.com/iximiuz/labctl/internal/portforward"
1718
)
1819

1920
const restartCommandTimeout = 5 * time.Minute
@@ -30,6 +31,8 @@ type restartOptions struct {
3031

3132
forwardAgent bool
3233

34+
restorePortForwards bool
35+
3336
quiet bool
3437
}
3538

@@ -105,6 +108,12 @@ func newRestartCommand(cli labcli.CLI) *cobra.Command {
105108
false,
106109
`Do not print any diagnostic messages`,
107110
)
111+
flags.BoolVar(
112+
&opts.restorePortForwards,
113+
"restore-port-forwards",
114+
false,
115+
`Automatically restore all forwarded ports from previous session`,
116+
)
108117

109118
return cmd
110119
}
@@ -168,6 +177,17 @@ func runRestartPlayground(ctx context.Context, cli labcli.CLI, opts *restartOpti
168177
}
169178
}
170179

180+
// Start port forwarding if requested.
181+
// If combined with --ide or --ssh, run in background; otherwise block.
182+
var portForwardErrCh <-chan error
183+
if opts.restorePortForwards {
184+
var err error
185+
portForwardErrCh, err = portforward.RestoreSavedForwards(ctx, cli.Client(), play.ID, cli)
186+
if err != nil {
187+
return err
188+
}
189+
}
190+
171191
if opts.ide != "" {
172192
return sshproxy.RunSSHProxy(ctx, cli, &sshproxy.Options{
173193
PlayID: play.ID,
@@ -197,5 +217,11 @@ func runRestartPlayground(ctx context.Context, cli labcli.CLI, opts *restartOpti
197217
}
198218

199219
cli.PrintOut("%s\n", play.ID)
220+
221+
// If only --port-forward was provided (no --ide or --ssh), wait for it
222+
if portForwardErrCh != nil {
223+
return <-portForwardErrCh
224+
}
225+
200226
return nil
201227
}

cmd/portforward/portforward.go

Lines changed: 123 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package portforward
33
import (
44
"context"
55
"fmt"
6+
"strconv"
67

78
"github.com/spf13/cobra"
89
"golang.org/x/sync/errgroup"
@@ -11,6 +12,16 @@ import (
1112
"github.com/iximiuz/labctl/internal/portforward"
1213
)
1314

15+
// runRestorePortForwards restores saved port forwards and blocks until done.
16+
func runRestorePortForwards(ctx context.Context, cli labcli.CLI, opts *options) error {
17+
resultCh, err := portforward.RestoreSavedForwards(ctx, cli.Client(), opts.playID, cli)
18+
if err != nil {
19+
return err
20+
}
21+
22+
return <-resultCh
23+
}
24+
1425
type options struct {
1526
playID string
1627
machine string
@@ -21,6 +32,11 @@ type options struct {
2132
remotes []string
2233

2334
quiet bool
35+
36+
// New flags
37+
list bool
38+
restore bool
39+
remove int
2440
}
2541

2642
// Local port forwarding's possible modes (kinda sorta as in ssh -L):
@@ -32,18 +48,48 @@ type options struct {
3248
// - LOCAL_HOST:LOCAL_PORT:REMOTE_HOST:REMOTE_PORT # the most explicit form
3349

3450
func NewCommand(cli labcli.CLI) *cobra.Command {
35-
var opts options
51+
opts := options{
52+
remove: -1, // -1 means not set
53+
}
3654

3755
cmd := &cobra.Command{
38-
Use: "port-forward <playground> [-m machine] -L [LOCAL:]REMOTE [-L ...] | -R [REMOTE:]:LOCAL [-R ...]",
56+
Use: "port-forward <playground> [-m machine] -L [LOCAL:]REMOTE [-L ...] | --list | --restore | --remove <index>",
3957
Short: `Forward one or more local or remote ports to a running playground`,
40-
Long: `While the implementation for sure differs, the behavior and semantic of the command
58+
Long: `Forward one or more local or remote ports to a running playground.
59+
60+
While the implementation differs significantly, the behavior and semantic of the command
4161
are meant to be similar to SSH local (-L) and remote (-R) port forwarding. The word "local" always
42-
refers to the labctl side. The word "remote" always refers to the target playground side.`,
62+
refers to the labctl side. The word "remote" always refers to the target playground side.
63+
64+
The command also supports managing "saved" port forwards:
65+
--list List all "should be forwarded" ports for the playground
66+
--restore Forward all "should be forwarded" ports (handy after a persistent playground restart)
67+
--remove Remove a "should be forwarded" port from the playground's config by its index (0-based)
68+
69+
When using -L|-R flags, port forwards are automatically saved to the playground's config for later restoration.`,
4370
Args: cobra.ExactArgs(1),
4471
RunE: func(cmd *cobra.Command, args []string) error {
72+
opts.playID = args[0]
73+
cli.SetQuiet(opts.quiet)
74+
75+
// Handle list mode
76+
if opts.list {
77+
return labcli.WrapStatusError(runListPortForwards(cmd.Context(), cli, &opts))
78+
}
79+
80+
// Handle remove mode
81+
if opts.remove >= 0 {
82+
return labcli.WrapStatusError(runRemovePortForward(cmd.Context(), cli, &opts))
83+
}
84+
85+
// Handle restore mode
86+
if opts.restore {
87+
return labcli.WrapStatusError(runRestorePortForwards(cmd.Context(), cli, &opts))
88+
}
89+
90+
// Regular port forwarding mode
4591
if len(opts.locals)+len(opts.remotes) == 0 {
46-
return labcli.NewStatusError(1, "at least one -L or -R flag must be provided")
92+
return labcli.NewStatusError(1, "at least one -L or -R flag must be provided (or use --list, --restore, --remove)")
4793
}
4894
if len(opts.remotes) > 0 {
4995
// TODO: Implement me!
@@ -58,10 +104,6 @@ refers to the labctl side. The word "remote" always refers to the target playgro
58104
opts.localsParsed = append(opts.localsParsed, parsed)
59105
}
60106

61-
cli.SetQuiet(opts.quiet)
62-
63-
opts.playID = args[0]
64-
65107
return labcli.WrapStatusError(runPortForward(cmd.Context(), cli, &opts))
66108
},
67109
}
@@ -97,9 +139,68 @@ refers to the labctl side. The word "remote" always refers to the target playgro
97139
`Suppress verbose output`,
98140
)
99141

142+
flags.BoolVar(&opts.list, "list", false, `List saved port forwards ("saved" means "should be forwarded")`)
143+
flags.BoolVar(&opts.restore, "restore", false, `Forward all "should be forwarded" ports for the playground`)
144+
flags.IntVar(&opts.remove, "remove", -1, `Remove a "should be forwarded" port from the playground's config by index (0-based)`)
145+
100146
return cmd
101147
}
102148

149+
func runListPortForwards(ctx context.Context, cli labcli.CLI, opts *options) error {
150+
forwards, err := cli.Client().ListPortForwards(ctx, opts.playID)
151+
if err != nil {
152+
return fmt.Errorf("couldn't list port forwards: %w", err)
153+
}
154+
155+
if len(forwards) == 0 {
156+
cli.PrintAux("No saved port forwards found.\n")
157+
return nil
158+
}
159+
160+
cli.PrintAux("Saved port forwards:\n")
161+
for i, pf := range forwards {
162+
localPart := ""
163+
if pf.LocalHost != "" || pf.LocalPort > 0 {
164+
if pf.LocalHost != "" {
165+
localPart = pf.LocalHost
166+
}
167+
if pf.LocalPort > 0 {
168+
if localPart != "" {
169+
localPart += ":"
170+
}
171+
localPart += strconv.Itoa(pf.LocalPort)
172+
}
173+
localPart += " -> "
174+
}
175+
176+
remotePart := ""
177+
if pf.RemoteHost != "" {
178+
remotePart = pf.RemoteHost + ":"
179+
}
180+
if pf.RemotePort > 0 {
181+
remotePart += strconv.Itoa(pf.RemotePort)
182+
}
183+
184+
kindLabel := pf.Kind
185+
if kindLabel == "" {
186+
kindLabel = "local"
187+
}
188+
189+
cli.PrintAux(" [%d] %s (%s): %s%s\n", i, pf.Machine, kindLabel, localPart, remotePart)
190+
}
191+
192+
return nil
193+
}
194+
195+
func runRemovePortForward(ctx context.Context, cli labcli.CLI, opts *options) error {
196+
if err := cli.Client().RemovePortForward(ctx, opts.playID, opts.remove); err != nil {
197+
return fmt.Errorf("couldn't remove port forward: %w", err)
198+
}
199+
200+
cli.PrintAux("Port forward at index %d removed.\n", opts.remove)
201+
return nil
202+
}
203+
103204
func runPortForward(ctx context.Context, cli labcli.CLI, opts *options) error {
104205
p, err := cli.Client().GetPlay(ctx, opts.playID)
105206
if err != nil {
@@ -114,11 +215,20 @@ func runPortForward(ctx context.Context, cli labcli.CLI, opts *options) error {
114215
}
115216
}
116217

218+
// Save port forwards to play's config
219+
for _, spec := range opts.localsParsed {
220+
pf, err := spec.ToPortForward(opts.machine)
221+
if err != nil {
222+
return fmt.Errorf("couldn't convert port forwarding spec to API port forward model: %w", err)
223+
}
224+
if _, err := cli.Client().AddPortForward(ctx, p.ID, *pf); err != nil {
225+
cli.PrintErr("Warning: couldn't save port forward: %v\n", err)
226+
}
227+
}
228+
117229
tunnel, err := portforward.StartTunnel(ctx, cli.Client(), portforward.TunnelOptions{
118-
PlayID: p.ID,
119-
FactoryID: p.FactoryID(),
120-
Machine: opts.machine,
121-
PlaysDir: cli.Config().PlaysDir,
230+
PlayID: p.ID,
231+
Machine: opts.machine,
122232
})
123233
if err != nil {
124234
return fmt.Errorf("couldn't start tunnel: %w", err)

cmd/ssh/ssh.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,7 @@ func StartSSHSession(
130130
) (*ssh.Session, <-chan error, error) {
131131
tunnel, err := portforward.StartTunnel(ctx, cli.Client(), portforward.TunnelOptions{
132132
PlayID: play.ID,
133-
FactoryID: play.FactoryID(),
134133
Machine: machine,
135-
PlaysDir: cli.Config().PlaysDir,
136134
SSHUser: user,
137135
SSHIdentityFile: cli.Config().SSHIdentityFile,
138136
})

cmd/sshproxy/sshproxy.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,7 @@ func RunSSHProxy(ctx context.Context, cli labcli.CLI, opts *Options) error {
128128

129129
tunnel, err := portforward.StartTunnel(ctx, cli.Client(), portforward.TunnelOptions{
130130
PlayID: p.ID,
131-
FactoryID: p.FactoryID(),
132131
Machine: opts.Machine,
133-
PlaysDir: cli.Config().PlaysDir,
134132
SSHUser: opts.User,
135133
SSHIdentityFile: cli.Config().SSHIdentityFile,
136134
})

internal/labcli/cliutil.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ type Streams interface {
2020
ErrorStream() io.Writer
2121
}
2222

23+
type Outputer interface {
24+
PrintOut(string, ...any)
25+
PrintErr(string, ...any)
26+
PrintAux(string, ...any)
27+
}
28+
2329
type CLI interface {
2430
Streams
2531

@@ -61,7 +67,10 @@ type cli struct {
6167
version string
6268
}
6369

64-
var _ CLI = &cli{}
70+
var (
71+
_ CLI = &cli{}
72+
_ Outputer = &cli{}
73+
)
6574

6675
func NewCLI(cin io.ReadCloser, cout io.Writer, cerr io.Writer, version string) CLI {
6776
return &cli{

0 commit comments

Comments
 (0)