--help fixes and new "orchard logs vm" command (#41)

* Make sure we list names/VM names as required argument in the --help

* Introduce "orchard logs vm" command

* Make sure each command has a Short field

* Future-proof port-forward, ssh and vnc commands

To support not only the VM resource.
This commit is contained in:
Nikolay Edigaryev
2023-03-23 15:59:57 +04:00
committed by GitHub
parent cdf5c5eb00
commit 5c3b47a247
20 changed files with 458 additions and 354 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ var force bool
func newCreateCommand() *cobra.Command {
command := &cobra.Command{
Use: "create",
Use: "create NAME",
Short: "Create a new context",
Args: cobra.ExactArgs(1),
RunE: runCreate,
+1 -1
View File
@@ -7,7 +7,7 @@ import (
func newDefaultCommand() *cobra.Command {
command := &cobra.Command{
Use: "default",
Use: "default NAME",
Short: "Set a default context to use for client/worker commands",
Args: cobra.ExactArgs(1),
RunE: runDefault,
+1 -1
View File
@@ -7,7 +7,7 @@ import (
func newDeleteCommand() *cobra.Command {
command := &cobra.Command{
Use: "delete",
Use: "delete NAME",
Short: "Delete a context",
Args: cobra.ExactArgs(1),
RunE: runDelete,
+4 -3
View File
@@ -13,9 +13,10 @@ var roles []string
func newCreateServiceAccount() *cobra.Command {
command := &cobra.Command{
Use: "service-account",
RunE: runCreateServiceAccount,
Args: cobra.ExactArgs(1),
Use: "service-account NAME",
Short: "Create a service account",
RunE: runCreateServiceAccount,
Args: cobra.ExactArgs(1),
}
command.PersistentFlags().StringVar(&token, "token", "",
+4 -3
View File
@@ -14,9 +14,10 @@ var headless bool
func newCreateVMCommand() *cobra.Command {
command := &cobra.Command{
Use: "vm",
RunE: runCreateVM,
Args: cobra.ExactArgs(1),
Use: "vm NAME",
Short: "Create a VM",
RunE: runCreateVM,
Args: cobra.ExactArgs(1),
}
command.PersistentFlags().StringVar(&image, "image", "ghcr.io/cirruslabs/macos-ventura-base:latest", "image to use")
@@ -7,9 +7,10 @@ import (
func newDeleteServiceComandCommand() *cobra.Command {
return &cobra.Command{
Use: "service-account",
Args: cobra.ExactArgs(1),
RunE: runDeleteServiceAccountCommand,
Use: "service-account NAME",
Short: "Delete a service account",
Args: cobra.ExactArgs(1),
RunE: runDeleteServiceAccountCommand,
}
}
+4 -3
View File
@@ -7,9 +7,10 @@ import (
func newDeleteVMCommand() *cobra.Command {
return &cobra.Command{
Use: "vm",
Args: cobra.ExactArgs(1),
RunE: runDeleteVM,
Use: "vm NAME",
Short: "Delete a VM",
Args: cobra.ExactArgs(1),
RunE: runDeleteVM,
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
func newGetBootstrapTokenCommand() *cobra.Command {
command := &cobra.Command{
Use: "bootstrap-token",
Use: "bootstrap-token NAME",
Short: "Retrieve a bootstrap token for the specified service account",
RunE: runGetBootstrapToken,
Args: cobra.ExactArgs(1),
+2 -2
View File
@@ -11,8 +11,8 @@ import (
func newGetServiceAccountCommand() *cobra.Command {
command := &cobra.Command{
Use: "service-account",
Short: "Retrieve service account and it's fields",
Use: "service-account NAME",
Short: "Retrieve a service account and it's fields",
RunE: runGetServiceAccount,
Args: cobra.ExactArgs(1),
}
+16
View File
@@ -0,0 +1,16 @@
package logs
import (
"github.com/spf13/cobra"
)
func NewCommand() *cobra.Command {
command := &cobra.Command{
Use: "logs",
Short: "Retrieve resource logs from the controller",
}
command.AddCommand(newLogsVMCommand())
return command
}
+38
View File
@@ -0,0 +1,38 @@
package logs
import (
"fmt"
"github.com/cirruslabs/orchard/pkg/client"
"github.com/spf13/cobra"
)
func newLogsVMCommand() *cobra.Command {
command := &cobra.Command{
Use: "vm NAME",
Short: "Retrieve VM logs",
RunE: runLogsVM,
Args: cobra.ExactArgs(1),
}
return command
}
func runLogsVM(cmd *cobra.Command, args []string) error {
name := args[0]
client, err := client.New()
if err != nil {
return err
}
lines, err := client.VMs().Logs(cmd.Context(), name)
if err != nil {
return err
}
for _, line := range lines {
fmt.Print(line)
}
return nil
}
+5 -74
View File
@@ -1,83 +1,14 @@
package portforward
import (
"fmt"
"github.com/cirruslabs/orchard/internal/proxy"
"github.com/cirruslabs/orchard/pkg/client"
"github.com/spf13/cobra"
"net"
)
import "github.com/spf13/cobra"
func NewCommand() *cobra.Command {
command := &cobra.Command{
Use: "port-forward <VM name> [<local port>]:<remote port>",
Short: "Forward TCP port to the VM",
Args: cobra.ExactArgs(2),
RunE: runPortForward,
Use: "port-forward",
Short: "Forward TCP port to the resources",
}
command.AddCommand(newPortForwardVMCommand())
return command
}
func runPortForward(cmd *cobra.Command, args []string) (err error) {
name := args[0]
portSpecRaw := args[1]
portSpec, err := NewPortSpec(portSpecRaw)
if err != nil {
return err
}
client, err := client.New()
if err != nil {
return err
}
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", portSpec.LocalPort))
if err != nil {
return err
}
defer func() {
if listenerErr := listener.Close(); listenerErr != nil && err == nil {
err = listenerErr
}
}()
fmt.Printf("forwarding %s -> %s:%d...\n", listener.Addr(), name, portSpec.RemotePort)
errCh := make(chan error, 1)
go func() {
for {
conn, err := listener.Accept()
if err != nil {
errCh <- err
return
}
go func() {
defer conn.Close()
wsConn, err := client.VMs().PortForward(cmd.Context(), name, portSpec.RemotePort)
if err != nil {
fmt.Printf("failed to forward port: %v\n", err)
return
}
defer wsConn.Close()
if err := proxy.Connections(wsConn, conn); err != nil {
fmt.Printf("failed to forward port: %v\n", err)
}
}()
}
}()
select {
case <-cmd.Context().Done():
return cmd.Context().Err()
case err := <-errCh:
return err
}
}
+83
View File
@@ -0,0 +1,83 @@
package portforward
import (
"fmt"
"github.com/cirruslabs/orchard/internal/proxy"
"github.com/cirruslabs/orchard/pkg/client"
"github.com/spf13/cobra"
"net"
)
func newPortForwardVMCommand() *cobra.Command {
command := &cobra.Command{
Use: "vm VM_NAME [LOCAL_PORT]:REMOTE_PORT",
Short: "Forward TCP port to the VM",
Args: cobra.ExactArgs(2),
RunE: runPortForwardVMCommand,
}
return command
}
func runPortForwardVMCommand(cmd *cobra.Command, args []string) (err error) {
name := args[0]
portSpecRaw := args[1]
portSpec, err := NewPortSpec(portSpecRaw)
if err != nil {
return err
}
client, err := client.New()
if err != nil {
return err
}
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", portSpec.LocalPort))
if err != nil {
return err
}
defer func() {
if listenerErr := listener.Close(); listenerErr != nil && err == nil {
err = listenerErr
}
}()
fmt.Printf("forwarding %s -> %s:%d...\n", listener.Addr(), name, portSpec.RemotePort)
errCh := make(chan error, 1)
go func() {
for {
conn, err := listener.Accept()
if err != nil {
errCh <- err
return
}
go func() {
defer conn.Close()
wsConn, err := client.VMs().PortForward(cmd.Context(), name, portSpec.RemotePort)
if err != nil {
fmt.Printf("failed to forward port: %v\n", err)
return
}
defer wsConn.Close()
if err := proxy.Connections(wsConn, conn); err != nil {
fmt.Printf("failed to forward port: %v\n", err)
}
}()
}
}()
select {
case <-cmd.Context().Done():
return cmd.Context().Err()
case err := <-errCh:
return err
}
}
+2
View File
@@ -8,6 +8,7 @@ import (
"github.com/cirruslabs/orchard/internal/command/dev"
"github.com/cirruslabs/orchard/internal/command/get"
"github.com/cirruslabs/orchard/internal/command/list"
"github.com/cirruslabs/orchard/internal/command/logs"
"github.com/cirruslabs/orchard/internal/command/portforward"
"github.com/cirruslabs/orchard/internal/command/ssh"
"github.com/cirruslabs/orchard/internal/command/stop"
@@ -29,6 +30,7 @@ func NewRootCmd() *cobra.Command {
create.NewCommand(),
get.NewCommand(),
list.NewCommand(),
logs.NewCommand(),
portforward.NewCommand(),
ssh.NewCommand(),
vnc.NewCommand(),
+4 -169
View File
@@ -1,179 +1,14 @@
package ssh
import (
"context"
"errors"
"fmt"
"github.com/cirruslabs/orchard/pkg/client"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh"
"golang.org/x/term"
"io"
"net"
"os"
"time"
)
var ErrFailed = errors.New("ssh command failed")
var username string
var password string
import "github.com/spf13/cobra"
func NewCommand() *cobra.Command {
command := &cobra.Command{
Use: "ssh <VM name>",
Short: "SSH into the VM",
Args: cobra.ExactArgs(1),
RunE: run,
Use: "ssh",
Short: "SSH into resources",
}
command.PersistentFlags().StringVarP(&username, "username", "u", "",
"SSH username")
command.PersistentFlags().StringVarP(&password, "password", "p", "",
"SSH password")
command.AddCommand(newSSHVMCommand())
return command
}
func run(cmd *cobra.Command, args []string) error {
name := args[0]
client, err := client.New()
if err != nil {
return err
}
wsConn, err := client.VMs().PortForward(cmd.Context(), name, 22)
if err != nil {
fmt.Printf("failed to forward an SSH port to VM %s: %v\n", name, err)
return err
}
defer wsConn.Close()
username, password = ChooseUsernameAndPassword(cmd.Context(), client, name, username, password)
sshConfig := &ssh.ClientConfig{
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
return nil
},
User: username,
Auth: []ssh.AuthMethod{
ssh.Password(password),
},
}
sshConn, chans, reqs, err := ssh.NewClientConn(wsConn, "", sshConfig)
if err != nil {
return fmt.Errorf("%w: failed to establish an SSH connection: %v", ErrFailed, err)
}
sshClient := ssh.NewClient(sshConn, chans, reqs)
sshSess, err := sshClient.NewSession()
if err != nil {
return fmt.Errorf("%w: failed to open an SSH session: %v", ErrFailed, err)
}
// Switch controlling terminal into raw mode,
// otherwise ANSI escape sequences that allow
// for cursor control and more wouldn't work
terminalFd := int(os.Stdin.Fd())
state, err := term.MakeRaw(terminalFd)
if err != nil {
return fmt.Errorf("%w: failed to switch controlling terminal into raw mode: %v", ErrFailed, err)
}
defer func() {
_ = term.Restore(terminalFd, state)
}()
width, height, err := term.GetSize(terminalFd)
if err != nil {
return err
}
if err := sshSess.RequestPty("xterm-256color", height, width, ssh.TerminalModes{}); err != nil {
return fmt.Errorf("%w: failed to request the PTY from the SSH server: %v", ErrFailed, err)
}
sshSess.Stdout = os.Stdout
sshSess.Stderr = os.Stderr
sshSessStdinPipe, err := sshSess.StdinPipe()
if err != nil {
return err
}
go func() {
_, _ = io.Copy(sshSessStdinPipe, os.Stdin)
_ = sshSessStdinPipe.Close()
_ = sshSess.Close()
}()
// Periodically adjust remote terminal size
go func() {
for {
newWidth, newHeight, err := term.GetSize(terminalFd)
if err != nil {
return
}
if height == newHeight && width == newWidth {
continue
}
if err := sshSess.WindowChange(newHeight, newWidth); err != nil {
continue
}
height = newHeight
width = newWidth
time.Sleep(time.Second)
}
}()
if err := sshSess.Shell(); err != nil {
return err
}
sshErr := make(chan error)
go func() {
sshErr <- sshSess.Wait()
}()
select {
case err := <-sshErr:
return err
case <-cmd.Context().Done():
return cmd.Context().Err()
}
}
func ChooseUsernameAndPassword(
ctx context.Context,
client *client.Client,
vmName string,
usernameFromUser string,
passwordFromUser string,
) (string, string) {
// User settings override everything
if usernameFromUser != "" || passwordFromUser != "" {
return usernameFromUser, passwordFromUser
}
// Try to get the credentials from the VM's object stored on controller
vm, err := client.VMs().Get(ctx, vmName)
if err != nil {
fmt.Printf("failed to retrieve VM %s's credentials from the API server: %v\n", vmName, err)
}
if vm.Username != "" && vm.Password != "" {
return vm.Username, vm.Password
}
// Fall back
fmt.Println("no credentials specified or found, trying default admin:admin credentials...")
return "admin", "admin"
}
+179
View File
@@ -0,0 +1,179 @@
package ssh
import (
"context"
"errors"
"fmt"
"github.com/cirruslabs/orchard/pkg/client"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh"
"golang.org/x/term"
"io"
"net"
"os"
"time"
)
var ErrFailed = errors.New("ssh command failed")
var username string
var password string
func newSSHVMCommand() *cobra.Command {
command := &cobra.Command{
Use: "vm VM_NAME",
Short: "SSH into the VM",
Args: cobra.ExactArgs(1),
RunE: runSSHVM,
}
command.PersistentFlags().StringVarP(&username, "username", "u", "",
"SSH username")
command.PersistentFlags().StringVarP(&password, "password", "p", "",
"SSH password")
return command
}
func runSSHVM(cmd *cobra.Command, args []string) error {
name := args[0]
client, err := client.New()
if err != nil {
return err
}
wsConn, err := client.VMs().PortForward(cmd.Context(), name, 22)
if err != nil {
fmt.Printf("failed to forward an SSH port to VM %s: %v\n", name, err)
return err
}
defer wsConn.Close()
username, password = ChooseUsernameAndPassword(cmd.Context(), client, name, username, password)
sshConfig := &ssh.ClientConfig{
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
return nil
},
User: username,
Auth: []ssh.AuthMethod{
ssh.Password(password),
},
}
sshConn, chans, reqs, err := ssh.NewClientConn(wsConn, "", sshConfig)
if err != nil {
return fmt.Errorf("%w: failed to establish an SSH connection: %v", ErrFailed, err)
}
sshClient := ssh.NewClient(sshConn, chans, reqs)
sshSess, err := sshClient.NewSession()
if err != nil {
return fmt.Errorf("%w: failed to open an SSH session: %v", ErrFailed, err)
}
// Switch controlling terminal into raw mode,
// otherwise ANSI escape sequences that allow
// for cursor control and more wouldn't work
terminalFd := int(os.Stdin.Fd())
state, err := term.MakeRaw(terminalFd)
if err != nil {
return fmt.Errorf("%w: failed to switch controlling terminal into raw mode: %v", ErrFailed, err)
}
defer func() {
_ = term.Restore(terminalFd, state)
}()
width, height, err := term.GetSize(terminalFd)
if err != nil {
return err
}
if err := sshSess.RequestPty("xterm-256color", height, width, ssh.TerminalModes{}); err != nil {
return fmt.Errorf("%w: failed to request the PTY from the SSH server: %v", ErrFailed, err)
}
sshSess.Stdout = os.Stdout
sshSess.Stderr = os.Stderr
sshSessStdinPipe, err := sshSess.StdinPipe()
if err != nil {
return err
}
go func() {
_, _ = io.Copy(sshSessStdinPipe, os.Stdin)
_ = sshSessStdinPipe.Close()
_ = sshSess.Close()
}()
// Periodically adjust remote terminal size
go func() {
for {
newWidth, newHeight, err := term.GetSize(terminalFd)
if err != nil {
return
}
if height == newHeight && width == newWidth {
continue
}
if err := sshSess.WindowChange(newHeight, newWidth); err != nil {
continue
}
height = newHeight
width = newWidth
time.Sleep(time.Second)
}
}()
if err := sshSess.Shell(); err != nil {
return err
}
sshErr := make(chan error)
go func() {
sshErr <- sshSess.Wait()
}()
select {
case err := <-sshErr:
return err
case <-cmd.Context().Done():
return cmd.Context().Err()
}
}
func ChooseUsernameAndPassword(
ctx context.Context,
client *client.Client,
vmName string,
usernameFromUser string,
passwordFromUser string,
) (string, string) {
// User settings override everything
if usernameFromUser != "" || passwordFromUser != "" {
return usernameFromUser, passwordFromUser
}
// Try to get the credentials from the VM's object stored on controller
vm, err := client.VMs().Get(ctx, vmName)
if err != nil {
fmt.Printf("failed to retrieve VM %s's credentials from the API server: %v\n", vmName, err)
}
if vm.Username != "" && vm.Password != "" {
return vm.Username, vm.Password
}
// Fall back
fmt.Println("no credentials specified or found, trying default admin:admin credentials...")
return "admin", "admin"
}
+4 -3
View File
@@ -7,9 +7,10 @@ import (
func newStopVMCommand() *cobra.Command {
return &cobra.Command{
Use: "vm",
Args: cobra.ExactArgs(1),
RunE: runStopVM,
Use: "vm NAME",
Short: "Stop a VM",
Args: cobra.ExactArgs(1),
RunE: runStopVM,
}
}
+97
View File
@@ -0,0 +1,97 @@
package vnc
import (
"fmt"
"github.com/cirruslabs/orchard/internal/command/ssh"
"github.com/cirruslabs/orchard/internal/proxy"
"github.com/cirruslabs/orchard/pkg/client"
"github.com/skratchdot/open-golang/open"
"github.com/spf13/cobra"
"net"
)
const vncPort = 5900
var username string
var password string
func newVNCVMCommand() *cobra.Command {
command := &cobra.Command{
Use: "vm VM_NAME",
Short: "Open VNC session with the VM",
Args: cobra.ExactArgs(1),
RunE: runVNCVM,
}
command.PersistentFlags().StringVarP(&username, "username", "u", "",
"VNC username")
command.PersistentFlags().StringVarP(&password, "password", "p", "",
"VNC password")
return command
}
func runVNCVM(cmd *cobra.Command, args []string) (err error) {
name := args[0]
client, err := client.New()
if err != nil {
return err
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return err
}
defer func() {
if listenerErr := listener.Close(); listenerErr != nil && err == nil {
err = listenerErr
}
}()
fmt.Printf("forwarding %s -> %s:%d...\n", listener.Addr(), name, vncPort)
errCh := make(chan error, 1)
go func() {
for {
conn, err := listener.Accept()
if err != nil {
errCh <- err
return
}
go func() {
wsConn, err := client.VMs().PortForward(cmd.Context(), name, vncPort)
if err != nil {
fmt.Printf("failed to forward port: %v\n", err)
return
}
if err := proxy.Connections(wsConn, conn); err != nil {
fmt.Printf("failed to forward port: %v\n", err)
}
}()
}
}()
username, password = ssh.ChooseUsernameAndPassword(cmd.Context(), client, name, username, password)
openURL := fmt.Sprintf("vnc://%s:%s@%s", username, password, listener.Addr().String())
openURLSanitized := fmt.Sprintf("vnc://%s@%s", username, listener.Addr().String())
fmt.Printf("opening %s...\n", openURLSanitized)
if err := open.Start(openURL); err != nil {
fmt.Printf("failed to open: %v\n", err)
}
select {
case <-cmd.Context().Done():
return cmd.Context().Err()
case err := <-errCh:
return err
}
}
+4 -87
View File
@@ -1,97 +1,14 @@
package vnc
import (
"fmt"
"github.com/cirruslabs/orchard/internal/command/ssh"
"github.com/cirruslabs/orchard/internal/proxy"
"github.com/cirruslabs/orchard/pkg/client"
"github.com/skratchdot/open-golang/open"
"github.com/spf13/cobra"
"net"
)
const vncPort = 5900
var username string
var password string
import "github.com/spf13/cobra"
func NewCommand() *cobra.Command {
command := &cobra.Command{
Use: "vnc <VM name>",
Short: "Open VNC session with the VM",
Args: cobra.ExactArgs(1),
RunE: run,
Use: "vnc",
Short: "Open VNC session with the resource",
}
command.PersistentFlags().StringVarP(&username, "username", "u", "",
"VNC username")
command.PersistentFlags().StringVarP(&password, "password", "p", "",
"VNC password")
command.AddCommand(newVNCVMCommand())
return command
}
func run(cmd *cobra.Command, args []string) (err error) {
name := args[0]
client, err := client.New()
if err != nil {
return err
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return err
}
defer func() {
if listenerErr := listener.Close(); listenerErr != nil && err == nil {
err = listenerErr
}
}()
fmt.Printf("forwarding %s -> %s:%d...\n", listener.Addr(), name, vncPort)
errCh := make(chan error, 1)
go func() {
for {
conn, err := listener.Accept()
if err != nil {
errCh <- err
return
}
go func() {
wsConn, err := client.VMs().PortForward(cmd.Context(), name, vncPort)
if err != nil {
fmt.Printf("failed to forward port: %v\n", err)
return
}
if err := proxy.Connections(wsConn, conn); err != nil {
fmt.Printf("failed to forward port: %v\n", err)
}
}()
}
}()
username, password = ssh.ChooseUsernameAndPassword(cmd.Context(), client, name, username, password)
openURL := fmt.Sprintf("vnc://%s:%s@%s", username, password, listener.Addr().String())
openURLSanitized := fmt.Sprintf("vnc://%s@%s", username, listener.Addr().String())
fmt.Printf("opening %s...\n", openURLSanitized)
if err := open.Start(openURL); err != nil {
fmt.Printf("failed to open: %v\n", err)
}
select {
case <-cmd.Context().Done():
return cmd.Context().Err()
case err := <-errCh:
return err
}
}
+4 -3
View File
@@ -16,9 +16,10 @@ var bootstrapTokenRaw string
func newRunCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "run",
RunE: runWorker,
Args: cobra.ExactArgs(1),
Use: "run CONTROLLER_URL",
Short: "Run worker",
RunE: runWorker,
Args: cobra.ExactArgs(1),
}
cmd.PersistentFlags().StringVar(&bootstrapTokenRaw, "bootstrap-token", "",
"a bootstrap token retrieved via `orchard get bootstrap-token <service-account-name-for-workers>`")