Skip to content

Commit 1f27141

Browse files
authored
fix(shell): clear message when SSHing to a node without access (#418)
* fix(shell): clear message when SSHing to a node without access When an org member runs 'brev shell <node>' on a BYON node they can see in 'brev ls nodes' but have not been granted SSH access, they hit a confusing catch-all error: cannot resolve SSH for node "dgx-station" — no access, no port, or no hostname Replace it with classifyNodeSSHFailure, which mirrors ResolveNodeSSHEntry's resolution logic and surfaces the actual cause with a remedy: - no access grant (the common case): tell the user they lack access and show the 'brev grant-ssh' command an org admin can run, with their email prefilled - access granted but port not allocated / hostname not ready: explain the node may still be connecting and suggest 'brev refresh' This flows through shell, exec, copy, open, and port-forward via ResolveExternalNodeSSH. ResolveNodeSSHEntry (used by refresh for bulk config generation, where silent skipping is correct) is unchanged. Fixes BRE2-953 * fix(shell): reword no-access hint and stop tracing expected SSH errors Point the user at someone who already has SSH access to the node rather than an org admin, since granting access does not require org admin. Return the SSH-access resolution failures as ValidationErrors and only dump a stack trace in dev/debug builds for unexpected, Sentry-reported errors, so these expected user-facing messages print cleanly without the [error] ...WrapAndTrace frames.
1 parent 12d39b7 commit 1f27141

3 files changed

Lines changed: 104 additions & 3 deletions

File tree

pkg/cmd/cmderrors/cmderrors.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ func DisplayAndHandleError(err error) {
2929
if err != nil {
3030
t := terminal.New()
3131
prettyErr := ""
32+
// showTrace gates whether we dump the full wrapped error (with stack
33+
// frames) in dev/debug builds. Only unexpected, Sentry-reported errors
34+
// warrant a trace; expected user-facing errors always print cleanly.
35+
showTrace := false
3236
switch errors.Cause(err).(type) {
3337
case breverrors.ValidationError:
3438
// do not report error
@@ -65,9 +69,10 @@ func DisplayAndHandleError(err error) {
6569
} else {
6670
er.ReportError(err)
6771
prettyErr = (t.Red(errors.Cause(err).Error()))
72+
showTrace = true
6873
}
6974
}
70-
if featureflag.Debug() || featureflag.IsDev() {
75+
if showTrace && (featureflag.Debug() || featureflag.IsDev()) {
7176
fmt.Fprintln(os.Stderr, err)
7277
} else {
7378
fmt.Fprintln(os.Stderr, prettyErr)

pkg/cmd/util/externalnode.go

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ func ResolveExternalNodeSSH(store ExternalNodeStore, node *nodev1.ExternalNode)
156156

157157
entry := ResolveNodeSSHEntry(user.ID, node)
158158
if entry == nil {
159-
return nil, breverrors.New(fmt.Sprintf("cannot resolve SSH for node %q — no access, no port, or no hostname", node.GetName()))
159+
return nil, classifyNodeSSHFailure(user, node)
160160
}
161161

162162
return &ExternalNodeSSHInfo{
@@ -166,3 +166,48 @@ func ResolveExternalNodeSSH(store ExternalNodeStore, node *nodev1.ExternalNode)
166166
Port: entry.Port,
167167
}, nil
168168
}
169+
170+
// classifyNodeSSHFailure returns an actionable error explaining why SSH cannot
171+
// be resolved for the user on this node. The cases mirror ResolveNodeSSHEntry:
172+
// the user may have no access grant, an access grant whose port is not yet
173+
// allocated, or a port that has not yet reported its hostname. Each case has a
174+
// distinct remedy, so we surface them separately rather than as one catch-all.
175+
func classifyNodeSSHFailure(user *entity.User, node *nodev1.ExternalNode) error {
176+
nodeName := node.GetName()
177+
178+
var access *nodev1.SSHAccess
179+
for _, a := range node.GetSshAccess() {
180+
if a.GetUserId() == user.ID {
181+
access = a
182+
break
183+
}
184+
}
185+
186+
// Most common case (per the issue): the node is visible in `brev ls nodes`
187+
// because the user is in the org, but no SSH access has been granted.
188+
if access == nil || access.GetLinuxUser() == "" {
189+
who := user.Email
190+
if who == "" {
191+
who = user.ID
192+
}
193+
return breverrors.NewValidationError(fmt.Sprintf(
194+
"you don't have SSH access to node %q.\n"+
195+
"Ask someone with SSH access to this node to grant you access, e.g.:\n"+
196+
" brev grant-ssh --node %s --user %s",
197+
nodeName, nodeName, who))
198+
}
199+
200+
port := resolvePortForSSHAccess(node, access)
201+
if port == nil {
202+
return breverrors.NewValidationError(fmt.Sprintf(
203+
"SSH access to node %q is granted but its SSH port isn't allocated yet.\n"+
204+
"The node may still be connecting — try again shortly, or run 'brev refresh'.",
205+
nodeName))
206+
}
207+
208+
// Access and port exist, but the port has no hostname yet.
209+
return breverrors.NewValidationError(fmt.Sprintf(
210+
"SSH access to node %q is granted but the connection details aren't ready yet.\n"+
211+
"The node may still be connecting — try again shortly, or run 'brev refresh'.",
212+
nodeName))
213+
}

pkg/cmd/util/externalnode_test.go

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ package util
22

33
import (
44
"fmt"
5+
"strings"
56
"testing"
67

78
nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1"
89

910
"github.com/brevdev/brev-cli/pkg/entity"
11+
breverrors "github.com/brevdev/brev-cli/pkg/errors"
1012
)
1113

1214
// mockExternalNodeStore satisfies ExternalNodeStore for unit tests that
@@ -104,14 +106,60 @@ func TestResolveExternalNodeSSH_UsesServerPortNotPortNumber(t *testing.T) {
104106

105107
func TestResolveExternalNodeSSH_NoAccess(t *testing.T) {
106108
store := &mockExternalNodeStore{
107-
user: &entity.User{ID: "user_1"},
109+
user: &entity.User{ID: "user_1", Email: "me@example.com"},
108110
}
109111
node := makeTestNode("box", "other_user", "ubuntu", "10.0.0.5", 22)
110112

111113
_, err := ResolveExternalNodeSSH(store, node)
112114
if err == nil {
113115
t.Fatal("expected error for no SSH access")
114116
}
117+
// The message should tell the user they lack access and how to get it,
118+
// not the old confusing "no access, no port, or no hostname".
119+
if !strings.Contains(err.Error(), "don't have SSH access") {
120+
t.Errorf("expected a 'no access' message, got: %v", err)
121+
}
122+
// It should point the user at someone who can grant access, not an org admin.
123+
if !strings.Contains(err.Error(), "someone with SSH access to this node") {
124+
t.Errorf("expected the message to ask someone with SSH access, got: %v", err)
125+
}
126+
if strings.Contains(err.Error(), "org admin") {
127+
t.Errorf("did not expect the message to reference an org admin, got: %v", err)
128+
}
129+
if !strings.Contains(err.Error(), "brev grant-ssh") {
130+
t.Errorf("expected the message to suggest 'brev grant-ssh', got: %v", err)
131+
}
132+
if !strings.Contains(err.Error(), "me@example.com") {
133+
t.Errorf("expected the message to reference the user's email, got: %v", err)
134+
}
135+
// It must be a ValidationError so the top-level handler prints it cleanly
136+
// (no stack trace, no Sentry report) even in dev builds.
137+
var valErr breverrors.ValidationError
138+
if !breverrors.As(err, &valErr) {
139+
t.Errorf("expected a ValidationError, got %T: %v", err, err)
140+
}
141+
}
142+
143+
func TestResolveExternalNodeSSH_NoPortAllocated(t *testing.T) {
144+
store := &mockExternalNodeStore{
145+
user: &entity.User{ID: "user_1"},
146+
}
147+
// User has an access grant, but no port matches its PortId.
148+
node := &nodev1.ExternalNode{
149+
Name: "box",
150+
SshAccess: []*nodev1.SSHAccess{
151+
{UserId: "user_1", LinuxUser: "ubuntu", PortId: "port_missing"},
152+
},
153+
Ports: []*nodev1.Port{},
154+
}
155+
156+
_, err := ResolveExternalNodeSSH(store, node)
157+
if err == nil {
158+
t.Fatal("expected error when access exists but no port is allocated")
159+
}
160+
if !strings.Contains(err.Error(), "is granted") {
161+
t.Errorf("expected a 'granted but not ready' message, got: %v", err)
162+
}
115163
}
116164

117165
func TestResolveExternalNodeSSH_UsesAccessPortNotProtocol(t *testing.T) {
@@ -155,6 +203,9 @@ func TestResolveExternalNodeSSH_EmptyHostname(t *testing.T) {
155203
if err == nil {
156204
t.Fatal("expected error for empty hostname")
157205
}
206+
if !strings.Contains(err.Error(), "is granted") {
207+
t.Errorf("expected a 'granted but not ready' message, got: %v", err)
208+
}
158209
}
159210

160211
func TestResolveExternalNodeSSH_GetUserError(t *testing.T) {

0 commit comments

Comments
 (0)