Skip to content

Commit dc963f5

Browse files
committed
add labctl cp command
1 parent 69acfff commit dc963f5

File tree

4 files changed

+212
-31
lines changed

4 files changed

+212
-31
lines changed

cmd/cp/cp.go

+149
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package cp
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os/exec"
7+
"strings"
8+
9+
"github.com/spf13/cobra"
10+
11+
"github.com/iximiuz/labctl/cmd/sshproxy"
12+
"github.com/iximiuz/labctl/internal/labcli"
13+
)
14+
15+
const example = ` # Copy a file to the playground
16+
labctl cp 65e78a64366c2b0cf9ddc34c:/home/laborant/some/file ./some/file
17+
18+
# Copy a file from the playground
19+
labctl cp ./some/file 65e78a64366c2b0cf9ddc34c:/home/laborant/some/file
20+
21+
# Copy a directory to the playground
22+
labctl cp -r ./some/dir 65e78a64366c2b0cf9ddc34c:/home/laborant/some/dir
23+
24+
# Copy a directory from the playground
25+
labctl cp 65e78a64366c2b0cf9ddc34c:/home/laborant/some/dir ./some/dir
26+
`
27+
28+
type Direction string
29+
30+
const (
31+
DirectionLocalToRemote Direction = "local-to-remote"
32+
DirectionRemoteToLocal Direction = "remote-to-local"
33+
)
34+
35+
type options struct {
36+
machine string
37+
user string
38+
39+
playID string
40+
localPath string
41+
remotePath string
42+
recursive bool
43+
44+
direction Direction
45+
}
46+
47+
func NewCommand(cli labcli.CLI) *cobra.Command {
48+
var opts options
49+
50+
cmd := &cobra.Command{
51+
Use: "cp [flags] <playground-id>:<source-path> <destination-path>\n labctl cp [flags] <source-path> <playground-id>:<destination-path>",
52+
Short: `Copy files to and from the target playground`,
53+
Example: example,
54+
Args: cobra.MinimumNArgs(2),
55+
RunE: func(cmd *cobra.Command, args []string) error {
56+
if strings.Contains(args[0], ":") {
57+
parts := strings.Split(args[0], ":")
58+
opts.playID = parts[0]
59+
opts.remotePath = parts[1]
60+
61+
opts.localPath = args[1]
62+
opts.direction = DirectionRemoteToLocal
63+
} else {
64+
parts := strings.Split(args[1], ":")
65+
opts.playID = parts[0]
66+
opts.remotePath = parts[1]
67+
68+
opts.localPath = args[0]
69+
opts.direction = DirectionLocalToRemote
70+
}
71+
72+
return labcli.WrapStatusError(runCopy(cmd.Context(), cli, &opts))
73+
},
74+
}
75+
76+
flags := cmd.Flags()
77+
78+
flags.StringVarP(
79+
&opts.machine,
80+
"machine",
81+
"m",
82+
"",
83+
`Target machine (default: the first machine in the playground)`,
84+
)
85+
flags.StringVarP(
86+
&opts.user,
87+
"user",
88+
"u",
89+
"",
90+
`SSH user (default: the machine's default login user)`,
91+
)
92+
flags.BoolVarP(
93+
&opts.recursive,
94+
"recursive",
95+
"r",
96+
false,
97+
`Copy directories recursively`,
98+
)
99+
100+
return cmd
101+
}
102+
103+
func runCopy(ctx context.Context, cli labcli.CLI, opts *options) error {
104+
ctx, cancel := context.WithCancel(ctx)
105+
defer cancel()
106+
107+
return sshproxy.RunSSHProxy(ctx, cli, &sshproxy.Options{
108+
PlayID: opts.playID,
109+
Machine: opts.machine,
110+
User: opts.user,
111+
Quiet: true,
112+
WithProxy: func(ctx context.Context, info *sshproxy.SSHProxyInfo) error {
113+
args := []string{
114+
"-i", info.IdentityFile,
115+
"-o", "StrictHostKeyChecking=no",
116+
"-o", "UserKnownHostsFile=/dev/null",
117+
"-P", info.ProxyPort,
118+
"-C", // compress
119+
}
120+
121+
if opts.recursive {
122+
args = append(args, "-r")
123+
}
124+
125+
if opts.direction == DirectionLocalToRemote {
126+
args = append(args,
127+
opts.localPath,
128+
fmt.Sprintf("%s@%s:%s", info.User, info.ProxyHost, opts.remotePath),
129+
)
130+
} else {
131+
args = append(args,
132+
fmt.Sprintf("%s@%s:%s", info.User, info.ProxyHost, opts.remotePath),
133+
opts.localPath,
134+
)
135+
}
136+
137+
cmd := exec.CommandContext(ctx, "scp", args...)
138+
cmd.Stdout = cli.OutputStream()
139+
cmd.Stderr = cli.ErrorStream()
140+
141+
if err := cmd.Run(); err != nil {
142+
return fmt.Errorf("copy command failed %s: %w", cmd.String(), err)
143+
}
144+
145+
cli.PrintAux("Done!\n")
146+
return nil
147+
},
148+
})
149+
}

cmd/sshproxy/sshproxy.go

+61-25
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"log/slog"
77
"os/exec"
8+
"path/filepath"
89
"strings"
910

1011
"github.com/spf13/cobra"
@@ -20,7 +21,10 @@ type Options struct {
2021
User string
2122
Address string
2223

23-
IDE bool
24+
IDE bool
25+
Quiet bool
26+
27+
WithProxy func(ctx context.Context, info *SSHProxyInfo) error
2428
}
2529

2630
func NewCommand(cli labcli.CLI) *cobra.Command {
@@ -31,6 +35,8 @@ func NewCommand(cli labcli.CLI) *cobra.Command {
3135
Short: `Start SSH proxy to the playground's machine`,
3236
Args: cobra.ExactArgs(1),
3337
RunE: func(cmd *cobra.Command, args []string) error {
38+
cli.SetQuiet(opts.Quiet)
39+
3440
opts.PlayID = args[0]
3541

3642
if opts.Address != "" && strings.Count(opts.Address, ":") != 1 {
@@ -68,10 +74,25 @@ func NewCommand(cli labcli.CLI) *cobra.Command {
6874
false,
6975
`Open the playground in the IDE (only VSCode is supported at the moment)`,
7076
)
77+
flags.BoolVarP(
78+
&opts.Quiet,
79+
"quiet",
80+
"q",
81+
false,
82+
`Quiet mode (don't print any messages except errors)`,
83+
)
7184

7285
return cmd
7386
}
7487

88+
type SSHProxyInfo struct {
89+
User string
90+
Machine string
91+
ProxyHost string
92+
ProxyPort string
93+
IdentityFile string
94+
}
95+
7596
func RunSSHProxy(ctx context.Context, cli labcli.CLI, opts *Options) error {
7697
p, err := cli.Client().GetPlay(ctx, opts.PlayID)
7798
if err != nil {
@@ -140,28 +161,7 @@ func RunSSHProxy(ctx context.Context, cli labcli.CLI, opts *Options) error {
140161
}
141162
}()
142163

143-
if !opts.IDE {
144-
cli.PrintOut("SSH proxy is running on %s\n", localPort)
145-
cli.PrintOut(
146-
"\n# Connect from the terminal:\nssh -i %s/%s ssh://%s@%s:%s\n",
147-
cli.Config().SSHDir, ssh.IdentityFile, opts.User, localHost, localPort,
148-
)
149-
150-
cli.PrintOut("\n# Or add the following to your ~/.ssh/config:\n")
151-
cli.PrintOut("Host %s\n", opts.PlayID+"-"+opts.Machine)
152-
cli.PrintOut(" HostName %s\n", localHost)
153-
cli.PrintOut(" Port %s\n", localPort)
154-
cli.PrintOut(" User %s\n", opts.User)
155-
cli.PrintOut(" IdentityFile %s/%s\n", cli.Config().SSHDir, ssh.IdentityFile)
156-
cli.PrintOut(" StrictHostKeyChecking no\n")
157-
cli.PrintOut(" UserKnownHostsFile /dev/null\n\n")
158-
159-
cli.PrintOut("# To access the playground in Visual Studio Code:\n")
160-
cli.PrintOut("code --folder-uri vscode-remote://ssh-remote+%s@%s:%s%s\n\n",
161-
opts.User, localHost, localPort, userHomeDir(opts.User))
162-
163-
cli.PrintOut("\nPress Ctrl+C to stop\n")
164-
} else {
164+
if opts.IDE {
165165
cli.PrintAux("Opening the playground in the IDE...\n")
166166

167167
// Hack: SSH into the playground first - otherwise, VSCode will fail to connect for some reason.
@@ -184,8 +184,44 @@ func RunSSHProxy(ctx context.Context, cli labcli.CLI, opts *Options) error {
184184
}
185185
}
186186

187-
// Wait for ctrl+c
188-
<-ctx.Done()
187+
if !opts.IDE && !opts.Quiet {
188+
cli.PrintAux("SSH proxy is running on %s\n", localPort)
189+
cli.PrintAux(
190+
"\n# Connect from the terminal:\nssh -i %s/%s ssh://%s@%s:%s\n",
191+
cli.Config().SSHDir, ssh.IdentityFile, opts.User, localHost, localPort,
192+
)
193+
194+
cli.PrintAux("\n# Or add the following to your ~/.ssh/config:\n")
195+
cli.PrintAux("Host %s\n", opts.PlayID+"-"+opts.Machine)
196+
cli.PrintAux(" HostName %s\n", localHost)
197+
cli.PrintAux(" Port %s\n", localPort)
198+
cli.PrintAux(" User %s\n", opts.User)
199+
cli.PrintAux(" IdentityFile %s/%s\n", cli.Config().SSHDir, ssh.IdentityFile)
200+
cli.PrintAux(" StrictHostKeyChecking no\n")
201+
cli.PrintAux(" UserKnownHostsFile /dev/null\n\n")
202+
203+
cli.PrintAux("# To access the playground in Visual Studio Code:\n")
204+
cli.PrintAux("code --folder-uri vscode-remote://ssh-remote+%s@%s:%s%s\n\n",
205+
opts.User, localHost, localPort, userHomeDir(opts.User))
206+
207+
cli.PrintAux("\nPress Ctrl+C to stop\n")
208+
}
209+
210+
if opts.WithProxy != nil {
211+
info := &SSHProxyInfo{
212+
User: opts.User,
213+
Machine: opts.Machine,
214+
ProxyHost: localHost,
215+
ProxyPort: localPort,
216+
IdentityFile: filepath.Join(cli.Config().SSHDir, ssh.IdentityFile),
217+
}
218+
if err := opts.WithProxy(ctx, info); err != nil {
219+
return fmt.Errorf("proxy callback failed: %w", err)
220+
}
221+
} else {
222+
// Wait for ctrl+c
223+
<-ctx.Done()
224+
}
189225

190226
return nil
191227
}

go.sum

-6
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,8 @@ github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWma
1818
github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU=
1919
github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw=
2020
github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY=
21-
github.com/charmbracelet/x/ansi v0.3.1 h1:CRO6lc/6HCx2/D6S/GZ87jDvRvk6GtPyFP+IljkNtqI=
22-
github.com/charmbracelet/x/ansi v0.3.1/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
2321
github.com/charmbracelet/x/ansi v0.3.2 h1:wsEwgAN+C9U06l9dCVMX0/L3x7ptvY1qmjMwyfE6USY=
2422
github.com/charmbracelet/x/ansi v0.3.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
25-
github.com/charmbracelet/x/exp/strings v0.0.0-20240914193755-48d9a4a13687 h1:tAmXpXce4cvIGUE0A6qKs/Q+vyUREBqdrlLjGjTHMr4=
26-
github.com/charmbracelet/x/exp/strings v0.0.0-20240914193755-48d9a4a13687/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
2723
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a h1:JMdM89Udp/cOl5tC3MuUJXTPE/nAdU1oyt9jRU44qq8=
2824
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
2925
github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
@@ -34,8 +30,6 @@ github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr
3430
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3531
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
3632
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
37-
github.com/docker/cli v27.2.1+incompatible h1:U5BPtiD0viUzjGAjV1p0MGB8eVA3L3cbIrnyWmSJI70=
38-
github.com/docker/cli v27.2.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
3933
github.com/docker/cli v27.3.1+incompatible h1:qEGdFBF3Xu6SCvCYhc7CzaQTlBmqDuzxPDpigSyeKQQ=
4034
github.com/docker/cli v27.3.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
4135
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=

main.go

+2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/iximiuz/labctl/cmd/auth"
1212
"github.com/iximiuz/labctl/cmd/challenge"
1313
"github.com/iximiuz/labctl/cmd/content"
14+
"github.com/iximiuz/labctl/cmd/cp"
1415
"github.com/iximiuz/labctl/cmd/playground"
1516
"github.com/iximiuz/labctl/cmd/portforward"
1617
"github.com/iximiuz/labctl/cmd/ssh"
@@ -70,6 +71,7 @@ func main() {
7071
auth.NewCommand(cli),
7172
challenge.NewCommand(cli),
7273
content.NewCommand(cli),
74+
cp.NewCommand(cli),
7375
playground.NewCommand(cli),
7476
portforward.NewCommand(cli),
7577
ssh.NewCommand(cli),

0 commit comments

Comments
 (0)