Skip to content

Commit

Permalink
Open playgrounds in IDE!
Browse files Browse the repository at this point in the history
  • Loading branch information
iximiuz committed Mar 24, 2024
1 parent ac348f9 commit 12b9b3a
Show file tree
Hide file tree
Showing 9 changed files with 150 additions and 71 deletions.
10 changes: 10 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
GIT_COMMIT=$(shell git rev-parse --verify HEAD)
UTC_NOW=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ")

.PHONY: build-dev
build-dev:
go build \
-ldflags="-X 'main.tagVersion=dev' -X 'main.tagCommit=${GIT_COMMIT}' -X 'main.tagDate=${UTC_NOW}'" \
-o labctl

.PHONY: build-dev-darwin-arm64
build-dev-darwin-arm64:
GOOS=darwin GOARCH=arm64 go build \
-ldflags="-X 'main.tagVersion=dev' -X 'main.tagCommit=${GIT_COMMIT}' -X 'main.tagDate=${UTC_NOW}'" \
-o labctl

.PHONY: release
release:
goreleaser --clean

.PHONY: release-snapshot
release-snapshot:
goreleaser release --snapshot --clean

.PHONY: test-e2e
test-e2e:
go test -v -count 1 ./e2e/exec
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ labctl ssh <playground-id> -- ls -la /

### Using IDE (VSCode, JetBrains, etc) to access playgrounds

You can start a playground and open it in your IDE with:

```sh
labctl playground start docker --ide
```

You can use the **SSH proxy mode** to access playgrounds from your IDE:

```sh
Expand All @@ -90,17 +96,22 @@ Example output:
```text
SSH proxy is running on 58279
Connect with: ssh -i ~/.iximiuz/labctl/ssh/id_ed25519 ssh://[email protected]:58279
# Connect from the terminal:
ssh -i ~/.ssh/iximiuz_labs_user ssh://[email protected]:58279
Or add the following to your ~/.ssh/config:
# Or add the following to your ~/.ssh/config:
Host 65ea1e10f6af43783e69fe68-docker-01
HostName 127.0.0.1
Port 58279
User root
IdentityFile ~/.iximiuz/labctl/ssh/id_ed25519
IdentityFile ~/.ssh/iximiuz_labs_user
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
# To access the playground in Visual Studio Code:
code --folder-uri vscode-remote://ssh-remote+127.0.0.1:58279/root
Press Ctrl+C to stop
```

Expand All @@ -121,7 +132,7 @@ You can also expose locally running services to the playground using **remote po
```sh
labctl ssh-proxy --address <local-proxy-address> <playground-id>

ssh -i ~/.iximiuz/labctl/ssh/id_ed25519 \
ssh -i ~/.ssh/iximiuz_labs_user \
-R <remote-host>:<remote-port>:<local-host>:<local-port> \
ssh://root@<local-proxy-address>
```
Expand Down
7 changes: 4 additions & 3 deletions cmd/auth/whoami.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import (

func newWhoAmICommand(cli labcli.CLI) *cobra.Command {
cmd := &cobra.Command{
Use: "whoami",
Short: "Print the current user info",
Args: cobra.NoArgs,
Use: "whoami",
Aliases: []string{"who", "me"},
Short: "Print the current user info",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return labcli.WrapStatusError(runWhoAmI(cmd.Context(), cli))
},
Expand Down
2 changes: 1 addition & 1 deletion cmd/content/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ func reconcileContentState(ctx context.Context, cli labcli.CLI, opts *pushOption

// Delete remote files that don't exist locally.
for _, file := range state.toDelete() {
cli.PrintAux("🗑️ Deleting remote %s\n", file)
cli.PrintAux("🗑️ Deleting remote %s\n", file)

if !opts.force && !cli.Confirm(fmt.Sprintf("File %s doesn't exist locally. Delete remotely?", file), "Yes", "No") {
cli.PrintAux("Skipping...\n")
Expand Down
25 changes: 23 additions & 2 deletions cmd/playground/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,20 @@ import (
"github.com/spf13/cobra"

"github.com/iximiuz/labctl/cmd/ssh"
"github.com/iximiuz/labctl/cmd/sshproxy"
"github.com/iximiuz/labctl/internal/api"
"github.com/iximiuz/labctl/internal/labcli"
)

type startOptions struct {
playground string
machine string

open bool

ssh bool
machine string
ssh bool

ide bool

quiet bool
}
Expand All @@ -40,6 +43,10 @@ func newStartCommand(cli labcli.CLI) *cobra.Command {
listKnownPlaygrounds(cmd.Context(), cli))
}

if opts.ide && opts.ssh {
return labcli.NewStatusError(1, "can't use --ide and --ssh flags at the same time")
}

opts.playground = args[0]

return labcli.WrapStatusError(runStartPlayground(cmd.Context(), cli, &opts))
Expand All @@ -60,6 +67,12 @@ func newStartCommand(cli labcli.CLI) *cobra.Command {
false,
`SSH into the playground immediately after it's created`,
)
flags.BoolVar(
&opts.ide,
"ide",
false,
`Open the playground in the IDE (only VSCode is supported at the moment)`,
)
flags.StringVar(
&opts.machine,
"machine",
Expand Down Expand Up @@ -95,6 +108,14 @@ func runStartPlayground(ctx context.Context, cli labcli.CLI, opts *startOptions)
}
}

if opts.ide {
return sshproxy.RunSSHProxy(ctx, cli, &sshproxy.Options{
PlayID: play.ID,
Machine: opts.machine,
IDE: true,
})
}

if opts.ssh {
if opts.machine == "" {
opts.machine = play.Machines[0].Name
Expand Down
108 changes: 72 additions & 36 deletions cmd/sshproxy/sshproxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,74 +4,84 @@ import (
"context"
"fmt"
"log/slog"
"os/exec"
"strings"

"github.com/spf13/cobra"

"github.com/iximiuz/labctl/internal/labcli"
"github.com/iximiuz/labctl/internal/portforward"
"github.com/iximiuz/labctl/internal/ssh"
)

type options struct {
playID string
machine string
address string
type Options struct {
PlayID string
Machine string
Address string

IDE bool
}

func NewCommand(cli labcli.CLI) *cobra.Command {
var opts options
var opts Options

cmd := &cobra.Command{
Use: "ssh-proxy [flags] <playground-id>",
Short: `Start SSH proxy to the playground's machine`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
opts.playID = args[0]
opts.PlayID = args[0]

if opts.address != "" && strings.Count(opts.address, ":") != 1 {
return fmt.Errorf("invalid address %q", opts.address)
if opts.Address != "" && strings.Count(opts.Address, ":") != 1 {
return fmt.Errorf("invalid address %q", opts.Address)
}

return labcli.WrapStatusError(runSSHProxy(cmd.Context(), cli, &opts))
return labcli.WrapStatusError(RunSSHProxy(cmd.Context(), cli, &opts))
},
}

flags := cmd.Flags()

flags.StringVarP(
&opts.machine,
&opts.Machine,
"machine",
"m",
"",
`Target machine (default: the first machine in the playground)`,
)
flags.StringVar(
&opts.address,
&opts.Address,
"address",
"",
`Local address to map to the machine's SSHD port (default: 'localhost:<random port>')`,
)
flags.BoolVar(
&opts.IDE,
"ide",
false,
`Open the playground in the IDE (only VSCode is supported at the moment)`,
)

return cmd
}

func runSSHProxy(ctx context.Context, cli labcli.CLI, opts *options) error {
p, err := cli.Client().GetPlay(ctx, opts.playID)
func RunSSHProxy(ctx context.Context, cli labcli.CLI, opts *Options) error {
p, err := cli.Client().GetPlay(ctx, opts.PlayID)
if err != nil {
return fmt.Errorf("couldn't get playground: %w", err)
}

if opts.machine == "" {
opts.machine = p.Machines[0].Name
if opts.Machine == "" {
opts.Machine = p.Machines[0].Name
} else {
if p.GetMachine(opts.machine) == nil {
return fmt.Errorf("machine %q not found in the playground", opts.machine)
if p.GetMachine(opts.Machine) == nil {
return fmt.Errorf("machine %q not found in the playground", opts.Machine)
}
}

tunnel, err := portforward.StartTunnel(ctx, cli.Client(), portforward.TunnelOptions{
PlayID: opts.playID,
Machine: opts.machine,
PlayID: opts.PlayID,
Machine: opts.Machine,
PlaysDir: cli.Config().PlaysDir,
SSHDir: cli.Config().SSHDir,
})
Expand All @@ -80,8 +90,8 @@ func runSSHProxy(ctx context.Context, cli labcli.CLI, opts *options) error {
}

var (
localPort = portStr(opts.address)
localHost = hostStr(opts.address)
localPort = portStr(opts.Address)
localHost = hostStr(opts.Address)
errCh = make(chan error, 100)
)

Expand Down Expand Up @@ -111,21 +121,47 @@ func runSSHProxy(ctx context.Context, cli labcli.CLI, opts *options) error {
}
}()

cli.PrintOut("SSH proxy is running on %s\n", localPort)
cli.PrintOut(
"\nConnect with: ssh -i %s/id_ed25519 ssh://root@%s:%s\n",
cli.Config().SSHDir, localHost, localPort,
)
cli.PrintOut("\nOr add the following to your ~/.ssh/config:\n")
cli.PrintOut("Host %s\n", opts.playID+"-"+opts.machine)
cli.PrintOut(" HostName %s\n", localHost)
cli.PrintOut(" Port %s\n", localPort)
cli.PrintOut(" User root\n")
cli.PrintOut(" IdentityFile %s/id_ed25519\n", cli.Config().SSHDir)
cli.PrintOut(" StrictHostKeyChecking no\n")
cli.PrintOut(" UserKnownHostsFile /dev/null\n")

cli.PrintOut("\nPress Ctrl+C to stop\n")
if !opts.IDE {
cli.PrintOut("SSH proxy is running on %s\n", localPort)
cli.PrintOut(
"\n# Connect from the terminal:\nssh -i %s/%s ssh://root@%s:%s\n",
cli.Config().SSHDir, ssh.IdentityFile, localHost, localPort,
)

cli.PrintOut("\n# Or add the following to your ~/.ssh/config:\n")
cli.PrintOut("Host %s\n", opts.PlayID+"-"+opts.Machine)
cli.PrintOut(" HostName %s\n", localHost)
cli.PrintOut(" Port %s\n", localPort)
cli.PrintOut(" User root\n")
cli.PrintOut(" IdentityFile %s/%s\n", cli.Config().SSHDir, ssh.IdentityFile)
cli.PrintOut(" StrictHostKeyChecking no\n")
cli.PrintOut(" UserKnownHostsFile /dev/null\n\n")

cli.PrintOut("# To access the playground in Visual Studio Code:\n")
cli.PrintOut("code --folder-uri vscode-remote://ssh-remote+root@%s:%s/root\n\n", localHost, localPort)

cli.PrintOut("\nPress Ctrl+C to stop\n")
} else {
cli.PrintAux("Opening the playground in the IDE...\n")

// Hack: SSH into the playground first - otherwise, VSCode will fail to connect for some reason.
cmd := exec.Command("ssh",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "StrictHostKeyChecking=no",
"-o", "IdentitiesOnly=yes",
"-o", "PreferredAuthentications=publickey",
"-i", fmt.Sprintf("%s/%s", cli.Config().SSHDir, ssh.IdentityFile),
fmt.Sprintf("ssh://root@%s:%s", localHost, localPort),
)
cmd.Run()

cmd = exec.Command("code",
"--folder-uri", fmt.Sprintf("vscode-remote://ssh-remote+root@%s:%s/root", localHost, localPort),
)
if err := cmd.Run(); err != nil {
return fmt.Errorf("couldn't open the IDE: %w", err)
}
}

// Wait for ctrl+c
<-ctx.Done()
Expand Down
25 changes: 12 additions & 13 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,29 +34,28 @@ type Config struct {
SSHDir string `yaml:"ssh_dir"`
}

func ConfigFilePath() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}

return filepath.Join(homeDir, ".iximiuz", "labctl", "config.yaml"), nil
func ConfigFilePath(homeDir string) string {
return filepath.Join(homeDir, ".iximiuz", "labctl", "config.yaml")
}

func Default(path string) *Config {
func Default(homeDir string) *Config {
configFilePath := ConfigFilePath(homeDir)

return &Config{
FilePath: path,
FilePath: configFilePath,
BaseURL: defaultBaseURL,
APIBaseURL: defaultAPIBaseURL,
PlaysDir: filepath.Join(filepath.Dir(path), "plays"),
SSHDir: filepath.Join(filepath.Dir(path), "ssh"),
PlaysDir: filepath.Join(filepath.Dir(configFilePath), "plays"),
SSHDir: filepath.Join(homeDir, ".ssh"),
}
}

func Load(path string) (*Config, error) {
func Load(homeDir string) (*Config, error) {
path := ConfigFilePath(homeDir)

file, err := os.Open(path)
if os.IsNotExist(err) {
return Default(path), nil
return Default(homeDir), nil
}
if err != nil {
return nil, fmt.Errorf("unable to open config file: %s", err)
Expand Down
Loading

0 comments on commit 12b9b3a

Please sign in to comment.