Skip to content

Commit 6e462d0

Browse files
authored
Can start/connect to ephemeral shell (#277)
1 parent 6eec17f commit 6e462d0

3 files changed

Lines changed: 80 additions & 3 deletions

File tree

cmd/ssh.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ func interactiveSSHView(ctx context.Context, input *views.SSHInput, breadcrumb s
3838
input.ServiceIDOrName = validate.ExtractServiceIDFromInstanceID(input.ServiceIDOrName)
3939
return InteractiveSSHView(ctx, input, breadcrumb)
4040
}
41-
4241
if input.ServiceIDOrName == "" {
4342
// No service specified, show service selection
4443
return command.AddToStackFunc(
@@ -48,7 +47,9 @@ func interactiveSSHView(ctx context.Context, input *views.SSHInput, breadcrumb s
4847
input,
4948
views.NewServiceList(ctx, getServiceListInput(ctx, input), func(ctx context.Context, r resource.Resource) tea.Cmd {
5049
input.ServiceIDOrName = r.ID()
51-
50+
if input.Ephemeral {
51+
return InteractiveSSHView(ctx, input, "SSH")
52+
}
5253
// Show instance selection for the selected service
5354
return command.AddToStackFunc(
5455
ctx,
@@ -63,6 +64,9 @@ func interactiveSSHView(ctx context.Context, input *views.SSHInput, breadcrumb s
6364
}, tui.WithCustomOptions[*service.Model](getSSHTableOptions(ctx, breadcrumb))),
6465
)
6566
} else if validate.IsServiceID(input.ServiceIDOrName) {
67+
if input.Ephemeral {
68+
return InteractiveSSHView(ctx, input, "SSH")
69+
}
6670
// Service ID provided, show instance selection
6771
return command.AddToStackFunc(
6872
ctx,
@@ -114,6 +118,8 @@ func getSSHTableOptions(ctx context.Context, breadcrumb string) []tui.CustomOpti
114118
func init() {
115119
rootCmd.AddCommand(sshCmd)
116120

121+
sshCmd.Flags().BoolP("ephemeral", "e", false, "connect to ephemeral instance")
122+
117123
sshCmd.RunE = func(cmd *cobra.Command, args []string) error {
118124
ctx := cmd.Context()
119125

pkg/command/wrapper.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,13 @@ func AddToStack[T any](stack *tui.StackModel, cmd *cobra.Command, breadcrumb str
169169
}
170170

171171
func LoadCmd[T any, D any](ctx context.Context, loadData func(context.Context, T) (D, error), in T) tui.TypedCmd[D] {
172+
return LoadCmdWithLoadingMsg(ctx, loadData, in, "")
173+
}
174+
175+
func LoadCmdWithLoadingMsg[T any, D any](ctx context.Context, loadData func(context.Context, T) (D, error), in T, loadingMsg string) tui.TypedCmd[D] {
172176
loadDataCmd := func() tea.Msg {
173177
return tui.LoadingDataMsg{
178+
LoadingMsgTmpl: loadingMsg,
174179
Cmd: tea.Sequence(
175180
func() tea.Msg {
176181
data, err := loadData(ctx, in)

pkg/tui/views/ssh.go

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
package views
22

33
import (
4+
"bytes"
45
"context"
6+
"encoding/json"
57
"fmt"
8+
"io"
9+
"net/http"
610
"os/exec"
711
"strings"
812

913
"github.com/render-oss/cli/pkg/client"
1014
"github.com/render-oss/cli/pkg/command"
15+
"github.com/render-oss/cli/pkg/config"
1116
"github.com/render-oss/cli/pkg/deploy"
1217
"github.com/render-oss/cli/pkg/service"
1318
"github.com/render-oss/cli/pkg/tui"
@@ -18,13 +23,18 @@ type SSHInput struct {
1823
InstanceID string // Set when a specific instance is selected or provided
1924
Project *client.Project
2025
EnvironmentIDs []string
26+
Ephemeral bool `cli:"ephemeral"`
2127

2228
Args []string
2329
}
2430

2531
// NewSSHView creates an SSH execution view - always returns ExecModel
2632
func NewSSHView(ctx context.Context, input *SSHInput) *tui.ExecModel {
27-
return tui.NewExecModel("ssh", handleSSHError, command.LoadCmd(ctx, loadDataSSH, input))
33+
loadingMsg := "%s Preparing SSH connection..."
34+
if input.Ephemeral {
35+
loadingMsg = "%s Creating ephemeral shell (this may take a moment)..."
36+
}
37+
return tui.NewExecModel("ssh", handleSSHError, command.LoadCmdWithLoadingMsg(ctx, loadDataSSH, input, loadingMsg))
2838
}
2939

3040
func handleSSHError(err error) error {
@@ -34,6 +44,46 @@ func handleSSHError(err error) error {
3444
}
3545
}
3646

47+
// createEphemeralShell creates an ephemeral shell pod for the given service.
48+
// This must be called before SSH'ing into an ephemeral shell.
49+
func createEphemeralShell(ctx context.Context, serviceID string) error {
50+
apiCfg, err := config.DefaultAPIConfig()
51+
if err != nil {
52+
return fmt.Errorf("failed to get API config: %w", err)
53+
}
54+
55+
url := fmt.Sprintf("%sservices/%s/ephemeral-shell", apiCfg.Host, serviceID)
56+
57+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(nil))
58+
if err != nil {
59+
return fmt.Errorf("failed to create request: %w", err)
60+
}
61+
62+
req.Header = client.AddHeaders(req.Header, apiCfg.Key)
63+
req.Header.Set("Accept", "application/json")
64+
65+
resp, err := http.DefaultClient.Do(req)
66+
if err != nil {
67+
return fmt.Errorf("failed to create ephemeral shell: %w", err)
68+
}
69+
defer resp.Body.Close()
70+
71+
body, _ := io.ReadAll(resp.Body)
72+
73+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
74+
// Try to extract error message from JSON response
75+
var apiErr struct {
76+
Message string `json:"message"`
77+
}
78+
if json.Unmarshal(body, &apiErr) == nil && apiErr.Message != "" {
79+
return fmt.Errorf("failed to create ephemeral shell: %s", apiErr.Message)
80+
}
81+
return fmt.Errorf("failed to create ephemeral shell: received status %d", resp.StatusCode)
82+
}
83+
84+
return nil
85+
}
86+
3787
func getServiceFromIDOrName(ctx context.Context, c *client.ClientWithResponses, idOrName string) (*client.Service, error) {
3888
serviceRepo := service.NewRepo(c)
3989

@@ -129,6 +179,22 @@ func loadDataSSH(ctx context.Context, in *SSHInput) (*exec.Cmd, error) {
129179
}
130180
}
131181

182+
if in.Ephemeral {
183+
// Create the ephemeral shell pod first
184+
if err := createEphemeralShell(ctx, serviceInfo.Id); err != nil {
185+
return nil, tui.UserFacingError{
186+
Title: "Failed to create ephemeral shell",
187+
Message: err.Error(),
188+
}
189+
}
190+
191+
// Format: ephemeral.{service-id}@{ssh-host}
192+
parts := strings.SplitN(finalSSHAddress, "@", 2)
193+
if len(parts) == 2 {
194+
finalSSHAddress = "ephemeral." + serviceInfo.Id + "@" + parts[1]
195+
}
196+
}
197+
132198
args := []string{finalSSHAddress}
133199
args = append(args, in.Args...)
134200

0 commit comments

Comments
 (0)