Repository: replicatedhq/replicated
Pull request #531: add `replicated vm ssh-endpoint` command to print the ssh endpoint of a vm
Author: login: ashjones-replicated
Labels: name: type::feature
Created: 2025-04-09T16:18:53Z | Merged: 2025-04-10T19:40:50Z

This enables users to use tools other than `ssh` or to construct more complicates commands with the endpoint. 

i.e: 
```bash
ssh $(replicated vm ssh-endpoint abdc123) 

scp ./file $(replicated vm ssh-endpoint abdc123):/some/path/

tar -c /path | ssh $(replicated vm ssh-endpoint abdc123) tar -xv
```


Diff:
diff --git a/cli/cmd/root.go b/cli/cmd/root.go
index 622afe7d4..57341df80 100644
--- a/cli/cmd/root.go
+++ b/cli/cmd/root.go
@@ -271,6 +271,7 @@ func Execute(rootCmd *cobra.Command, stdin io.Reader, stdout io.Writer, stderr i
 	runCmds.InitVMRemove(vmCmd)
 	runCmds.InitVMSSH(vmCmd)
 	runCmds.InitVMSCP(vmCmd)
+	runCmds.InitVMSSHEndpoint(vmCmd)
 	vmUpdateCmd := runCmds.InitVMUpdateCommand(vmCmd)
 	runCmds.InitVMUpdateTTL(vmUpdateCmd)
 
diff --git a/cli/cmd/vm_ssh_endpoint.go b/cli/cmd/vm_ssh_endpoint.go
new file mode 100644
index 000000000..102d15001
--- /dev/null
+++ b/cli/cmd/vm_ssh_endpoint.go
@@ -0,0 +1,55 @@
+package cmd
+
+import (
+	"fmt"
+
+	"github.com/pkg/errors"
+	"github.com/spf13/cobra"
+)
+
+func (r *runners) InitVMSSHEndpoint(parent *cobra.Command) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "ssh-endpoint VM_ID",
+		Short: "Get the SSH endpoint of a VM",
+		Long: `Get the SSH endpoint and port of a VM.
+
+The output will be in the format: hostname:port`,
+		Example: `# Get SSH endpoint for a specific VM by ID
+replicated vm ssh-endpoint <id>`,
+		RunE:              r.VMSSHEndpoint,
+		Args:              cobra.ExactArgs(1),
+		ValidArgsFunction: r.completeVMIDs,
+	}
+	parent.AddCommand(cmd)
+
+	return cmd
+}
+
+func (r *runners) VMSSHEndpoint(cmd *cobra.Command, args []string) error {
+	vmID := args[0]
+
+	vm, err := r.kotsAPI.GetVM(vmID)
+	if err != nil {
+		return errors.Wrap(err, "get vm")
+	}
+
+	if vm.DirectSSHEndpoint == "" || vm.DirectSSHPort == 0 {
+		return errors.Errorf("VM %s does not have SSH endpoint configured", vm.ID)
+	}
+
+	// Get GitHub username from API
+	githubUsername, err := r.kotsAPI.GetGitHubUsername()
+	if err != nil {
+		return errors.Wrap(err, "get github username")
+	}
+
+	// Format the SSH endpoint with username if available
+	if githubUsername == "" {
+		return errors.New(`no github account associated with vendor portal user
+Visit https://vendor.replicated.com/account-settings to link your account`)
+	}
+
+	fmt.Printf("ssh://%s@%s:%d\n", githubUsername, vm.DirectSSHEndpoint, vm.DirectSSHPort)
+
+	return nil
+}
diff --git a/pkg/kotsclient/user.go b/pkg/kotsclient/user.go
new file mode 100644
index 000000000..b6f472869
--- /dev/null
+++ b/pkg/kotsclient/user.go
@@ -0,0 +1,23 @@
+package kotsclient
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/pkg/errors"
+)
+
+type GetUserResponse struct {
+	GitHubUsername string `json:"gitHubUsername"`
+}
+
+func (c *VendorV3Client) GetGitHubUsername() (string, error) {
+	var response GetUserResponse
+
+	err := c.DoJSON(context.TODO(), "GET", "/v1/user", http.StatusOK, nil, &response)
+	if err != nil {
+		return "", errors.Wrap(err, "failed to fetch GitHub username from vendor API")
+	}
+
+	return response.GitHubUsername, nil
+}


Reviews:
user: login: squizzi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T17:41:31Z
user: login: squizzi
body: I think we should bring back the interactivity that was removed in #532, if I don't provide a VM ID I should still be able to select one from the list.  I liked that addition.  The difference being we'd just output the ssh URI from the selection rather than start a new SSH session.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T19:20:49Z
user: login: squizzi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T19:22:54Z
user: login: ashjones-replicated
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T19:24:55Z
user: login: squizzi
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T19:33:32Z

Review comments:
diff_hunk: @@ -0,0 +1,54 @@
+package cmd
+
+import (
+	"fmt"
+
+	"github.com/pkg/errors"
+	"github.com/spf13/cobra"
+)
+
+func (r *runners) InitVMSSHEndpoint(parent *cobra.Command) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "ssh-endpoint VM_ID",
+		Short: "Get the SSH endpoint of a VM",
+		Long: `Get the SSH endpoint and port of a VM.
+
+The output will be in the format: hostname:port`,
+		Example: `# Get SSH endpoint for a specific VM by ID
+replicated vm ssh-endpoint <id>`,
+		RunE:              r.getVMSSHEndpoint,
+		Args:              cobra.ExactArgs(1),
+		ValidArgsFunction: r.completeVMIDs,
+	}
+	parent.AddCommand(cmd)
+
+	return cmd
+}
+
+func (r *runners) getVMSSHEndpoint(cmd *cobra.Command, args []string) error {
+	vmID := args[0]
+
+	vm, err := r.kotsAPI.GetVM(vmID)
+	if err != nil {
+		return errors.Wrap(err, "get vm")
+	}
+
+	if vm.DirectSSHEndpoint == "" || vm.DirectSSHPort == 0 {
+		return errors.Errorf("VM %s does not have SSH endpoint configured", vm.ID)
+	}
+
+	// Get GitHub username from API
+	githubUsername, err := r.kotsAPI.GetGitHubUsername()
+	if err != nil {
+		return errors.Wrap(err, "get github username")
+	}
+
+	// Format the SSH endpoint with username if available
+	if githubUsername != "" {
+		fmt.Printf("ssh://%s@%s:%d\n", githubUsername, vm.DirectSSHEndpoint, vm.DirectSSHPort)
+	} else {
+		fmt.Printf("ssh://%s:%d\n", vm.DirectSSHEndpoint, vm.DirectSSHPort)
path: cli/cmd/vm_get_ssh_endpoint.go
user: login: squizzi
body: We should just return error here, if there's no github user affiliated with the account the user isn't going to be able to get into the box anyways because there's no key affiliated with them at that point.

We should error and point the user to updating their username to serve as a key on the Replicated portal.
created_at: 2025-04-09T17:41:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,16 @@
+package cmd
+
+import (
+	"github.com/spf13/cobra"
+)
+
+func (r *runners) InitVMGetCommand(parent *cobra.Command) *cobra.Command {
path: cli/cmd/vm_get.go
user: login: squizzi
body: Is this getter neccessary?  The command results in `replicated vm ssh-endpoint`, but this is trying to place that underneath a sub-command like `replicated vm get ssh-endpoint`.  The getter isn't a bad idea, but currently with only one thing to get it seems like we should maybe add it later if we ever add more `getter` type commands.  

Based on how `replicated vm ssh-endpoint` works now we should just be able to remove this without much effect.
created_at: 2025-04-10T19:22:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,16 @@
+package cmd
+
+import (
+	"github.com/spf13/cobra"
+)
+
+func (r *runners) InitVMGetCommand(parent *cobra.Command) *cobra.Command {
path: cli/cmd/vm_get.go
user: login: ashjones-replicated
body: oops this is a holdover I missed from the first agent's run... 
created_at: 2025-04-10T19:24:55Z
author_association: MEMBER

PR comments:
user: login: ashjones-replicated
created_at: 2025-04-10T19:26:14Z
body: > I think we should bring back the interactivity that was removed in #532, if I don't provide a VM ID I should still be able to select one from the list. I liked that addition. The difference being we'd just output the ssh URI from the selection rather than start a new SSH session.

The problem with having interactivity in something we expect to run in subshells is that it doesn't always work the way we expect, things run inside bash's `$()` or fish's `()` don't behave the same way as if run directly from a tty
author_association: MEMBER
user: login: squizzi
created_at: 2025-04-10T19:33:21Z
body: > The problem with having interactivity in something we expect to run in subshells is that it doesn't always work the way we expect, things run inside bash's $() or fish's () don't behave the same way as if run directly from a tty

Maybe a happy middle ground would be to print a `vm ls` of running status VMs in the case where an ID is fudged or the command is attempted against an ID that is not yet running.

I dunno, I see a benefit to keeping `vm ssh` around if thats the case -- which I guess we could discuss in greater detail on that PR because I find it moderately annoying to do a `r vm ls` and then make sure the VM is running and then `ssh $(replicated vm ssh-endpoint <id>)` 

I'm going to approve this and then we can discuss the potential of keeping `vm ssh` around for _just the interactive `ssh`_ use case in the other PR.
author_association: CONTRIBUTOR