Skip to content

Commit fa8d938

Browse files
feat(BRE2-818): Standardize register, deregister, enablesssh (#325)
* interactive vs prompt mode * save progress * fix uninstall * fmt * consistent name printing * consistent flag driven * consolidate modes * pattern for interacctive * lint * lint * approve flag * lint and bugfix * fix test * feat(BRE2-816): grant/revoke runnable from anywhere * merge conflict * fix enable-ssh port collision * progress * save progress * remove caching of linux user * grant revoke * bugs * make fmt lint * more spacing * consistent spinners * skip sudo when needed, ask for password when not * tests * fix tests --------- Co-authored-by: Pratik Patel <pratpatel@nvidia.com>
1 parent e98788b commit fa8d938

26 files changed

Lines changed: 1708 additions & 770 deletions

pkg/cmd/deregister/deregister.go

Lines changed: 95 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ type deregisterDeps struct {
4848
platform externalnode.PlatformChecker
4949
prompter terminal.Selector
5050
confirmer terminal.Confirmer
51+
gater sudo.Gater
5152
netbird register.NetBirdManager
5253
nodeClients externalnode.NodeClientFactory
5354
registrationStore register.RegistrationStore
@@ -59,6 +60,7 @@ func defaultDeregisterDeps() deregisterDeps {
5960
platform: register.LinuxPlatform{},
6061
prompter: register.TerminalPrompter{},
6162
confirmer: register.TerminalPrompter{},
63+
gater: sudo.Default,
6264
netbird: register.Netbird{},
6365
nodeClients: register.DefaultNodeClientFactory{},
6466
registrationStore: register.NewFileRegistrationStore(),
@@ -76,6 +78,8 @@ the Brev tunnel (network agent).`
7678
)
7779

7880
func NewCmdDeregister(t *terminal.Terminal, store DeregisterStore) *cobra.Command {
81+
var approveFlag bool
82+
7983
cmd := &cobra.Command{
8084
Annotations: map[string]string{"configuration": ""},
8185
Use: "deregister",
@@ -84,65 +88,110 @@ func NewCmdDeregister(t *terminal.Terminal, store DeregisterStore) *cobra.Comman
8488
Long: deregisterLong,
8589
Example: deregisterExample,
8690
RunE: func(cmd *cobra.Command, args []string) error {
87-
return runDeregister(cmd.Context(), t, store, defaultDeregisterDeps())
91+
return runDeregister(cmd.Context(), t, store, defaultDeregisterDeps(), approveFlag)
8892
},
8993
}
9094

95+
cmd.Flags().BoolVar(&approveFlag, "approve", false, "skip confirmation prompt (assume yes)")
96+
9197
return cmd
9298
}
9399

94-
func runDeregister(ctx context.Context, t *terminal.Terminal, s DeregisterStore, deps deregisterDeps) error { //nolint:funlen // deregistration flow
100+
func runDeregister(ctx context.Context, t *terminal.Terminal, s DeregisterStore, deps deregisterDeps, skipConfirm bool) error { //nolint:funlen,gocyclo // deregistration flow
95101
if !deps.platform.IsCompatible() {
96102
return fmt.Errorf("brev deregister is only supported on Linux")
97103
}
98104

99-
if err := sudo.Gate(t, deps.confirmer, "Device deregistration"); err != nil {
105+
if err := deps.gater.Gate(t, deps.confirmer, "Device deregistration", skipConfirm); err != nil {
100106
return fmt.Errorf("sudo issue: %w", err)
101107
}
102108

103109
reg, err := deps.registrationStore.Load()
104110
if err != nil {
111+
return err //nolint:wrapcheck // do not present stack trace for this error
112+
}
113+
114+
// Only prompt for login when there is a device to deregister.
115+
if _, err := s.GetCurrentUser(); err != nil {
105116
return breverrors.WrapAndTrace(err)
106117
}
107118

119+
orgName := reg.OrgName
120+
if orgName == "" {
121+
orgName = "(unknown)"
122+
}
123+
osUser, _ := user.Current()
124+
linuxUser := "(unknown)"
125+
if osUser != nil {
126+
linuxUser = osUser.Username
127+
}
128+
108129
t.Vprint("")
109-
t.Vprint(t.Green("Deregistering device"))
130+
t.Vprint(t.White("══════════════════════════════════════════════════"))
131+
t.Vprint(t.White(" Deregistering your device from Brev"))
132+
t.Vprint(t.White("══════════════════════════════════════════════════"))
133+
t.Vprint("")
134+
if !skipConfirm {
135+
t.Vprint(t.Green(" Please confirm before continuing:"))
136+
t.Vprint("")
137+
}
138+
t.Vprintf(" %s %s\n", t.Green(fmt.Sprintf("%-14s", "Device:")), t.BoldBlue(reg.DisplayName+" ("+reg.ExternalNodeID+")"))
139+
t.Vprintf(" %s %s\n", t.Green(fmt.Sprintf("%-14s", "Organization:")), t.BoldBlue(orgName+" ("+reg.OrgID+")"))
140+
t.Vprintf(" %s %s\n", t.Green(fmt.Sprintf("%-14s", "Linux user:")), t.BoldBlue(linuxUser))
110141
t.Vprint("")
111-
t.Vprintf(" Node ID: %s\n", reg.ExternalNodeID)
112-
t.Vprintf(" Name: %s\n", reg.DisplayName)
142+
t.Vprint(t.Yellow(" This will:"))
143+
t.Vprint(" 1. Remove this node from Brev")
144+
t.Vprint(" 2. Remove Brev SSH keys from this machine (if any)")
145+
t.Vprint(" 3. Uninstall the Brev tunnel")
146+
t.Vprint(" 4. Delete local registration data")
113147
t.Vprint("")
114148

115-
confirm := deps.prompter.Select(
116-
"Proceed with deregistration?",
117-
[]string{"Yes, proceed", "No, cancel"},
118-
)
119-
if confirm != "Yes, proceed" {
120-
t.Vprint("Deregistration canceled.")
121-
return nil
149+
if !skipConfirm {
150+
confirm := deps.prompter.Select(
151+
"Proceed with deregistration?",
152+
[]string{"Yes, proceed", "No, cancel"},
153+
)
154+
if confirm != "Yes, proceed" {
155+
t.Vprint("Deregistration canceled.")
156+
return nil
157+
}
122158
}
123159

124-
t.Vprint("")
125-
t.Vprint(t.Yellow("Removing node from Brev..."))
160+
const clearLine = "\033[2K\n"
161+
162+
t.Vprint(t.Yellow("[Step 1/4] Removing node from Brev..."))
163+
sp := t.NewSpinner()
164+
sp.Suffix = " Deregistering device..."
165+
sp.Start()
126166
client := deps.nodeClients.NewNodeClient(s, config.GlobalConfig.GetBrevPublicAPIURL())
127-
if _, err := client.RemoveNode(ctx, connect.NewRequest(&nodev1.RemoveNodeRequest{
167+
_, err = client.RemoveNode(ctx, connect.NewRequest(&nodev1.RemoveNodeRequest{
128168
ExternalNodeId: reg.ExternalNodeID,
129-
})); err != nil {
169+
}))
170+
sp.FinalMSG = clearLine
171+
sp.Stop()
172+
if err != nil {
130173
return fmt.Errorf("failed to deregister node: %w", err)
131174
}
132-
t.Vprint(t.Green(" Node removed from Brev."))
175+
t.Vprintf("%s Node removed from Brev.\n", t.Green(" ✓"))
133176
t.Vprint("")
134177

135-
// Remove Brev SSH keys from authorized_keys.
136-
osUser, err := user.Current()
137-
if err != nil {
138-
t.Vprintf(" Warning: could not determine current user for SSH key cleanup: %v\n", err)
178+
t.Vprint(t.Yellow("[Step 2/4] Removing Brev SSH keys..."))
179+
sp = t.NewSpinner()
180+
sp.Suffix = " Deregistering device..."
181+
sp.Start()
182+
if osUser == nil {
183+
sp.FinalMSG = clearLine
184+
sp.Stop()
185+
t.Vprintf(" %s\n", t.Yellow("Skipped: could not determine current user"))
139186
} else {
140187
removed, kerr := deps.sshKeys.RemoveBrevKeys(osUser)
188+
sp.FinalMSG = clearLine
189+
sp.Stop()
141190
switch {
142191
case kerr != nil:
143-
t.Vprintf(" Warning: failed to remove Brev SSH keys: %v\n", kerr)
192+
t.Vprintf(" %s\n", t.Yellow(fmt.Sprintf("Warning: failed to remove Brev SSH keys: %v", kerr)))
144193
case len(removed) > 0:
145-
t.Vprint(t.Green(" Brev SSH keys removed from authorized_keys:"))
194+
t.Vprintf("%s Brev SSH keys removed from authorized_keys:\n", t.Green(" ✓"))
146195
for _, key := range removed {
147196
t.Vprintf(" - %s\n", key)
148197
}
@@ -152,21 +201,34 @@ func runDeregister(ctx context.Context, t *terminal.Terminal, s DeregisterStore,
152201
}
153202
t.Vprint("")
154203

155-
t.Vprint("Removing Brev tunnel...")
156-
if err := deps.netbird.Uninstall(); err != nil {
157-
t.Vprintf(" Warning: failed to remove Brev tunnel: %v\n", err)
204+
t.Vprint(t.Yellow("[Step 3/4] Removing Brev tunnel..."))
205+
sp = t.NewSpinner()
206+
sp.Suffix = " Deregistering device..."
207+
sp.Start()
208+
err = deps.netbird.Uninstall()
209+
sp.FinalMSG = clearLine
210+
sp.Stop()
211+
if err != nil {
212+
t.Vprintf(" %s\n", t.Yellow(fmt.Sprintf("Warning: failed to remove Brev tunnel: %v", err)))
158213
} else {
159-
t.Vprint(t.Green(" Brev tunnel removed."))
214+
t.Vprintf("%s Brev tunnel removed.\n", t.Green(" ✓"))
160215
}
161216
t.Vprint("")
162217

163-
t.Vprint("Removing registration data...")
164-
if err := deps.registrationStore.Delete(); err != nil {
165-
t.Vprintf(" Warning: failed to remove local registration file: %v\n", err)
218+
t.Vprint(t.Yellow("[Step 4/4] Removing registration data..."))
219+
sp = t.NewSpinner()
220+
sp.Suffix = " Deregistering device..."
221+
sp.Start()
222+
err = deps.registrationStore.Delete()
223+
sp.FinalMSG = clearLine
224+
sp.Stop()
225+
if err != nil {
226+
t.Vprintf(" %s\n", t.Yellow(fmt.Sprintf("Warning: failed to remove local registration file: %v", err)))
166227
t.Vprint(" You can manually remove it with: rm /etc/brev/device_registration.json")
228+
} else {
229+
t.Vprintf("%s Registration data removed.\n", t.Green(" ✓"))
167230
}
168-
169-
t.Vprint(t.Green("Deregistration complete."))
231+
t.Vprintf("%s Deregistration complete.\n", t.Green(" ✓"))
170232
t.Vprint("")
171233

172234
return nil

pkg/cmd/deregister/deregister_test.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/brevdev/brev-cli/pkg/cmd/register"
1515
"github.com/brevdev/brev-cli/pkg/entity"
1616
"github.com/brevdev/brev-cli/pkg/externalnode"
17+
"github.com/brevdev/brev-cli/pkg/sudo"
1718
"github.com/brevdev/brev-cli/pkg/terminal"
1819
)
1920

@@ -136,6 +137,7 @@ func testDeregisterDeps(t *testing.T, svc *fakeNodeService, regStore register.Re
136137
return ""
137138
}},
138139
confirmer: mockConfirmer{confirm: true},
140+
gater: sudo.CachedGater{},
139141
netbird: &mockNetBirdManager{},
140142
nodeClients: mockNodeClientFactory{serverURL: server.URL},
141143
registrationStore: regStore,
@@ -171,7 +173,7 @@ func Test_runDeregister_HappyPath(t *testing.T) {
171173
defer server.Close()
172174

173175
term := terminal.New()
174-
err := runDeregister(context.Background(), term, store, deps)
176+
err := runDeregister(context.Background(), term, store, deps, false)
175177
if err != nil {
176178
t.Fatalf("runDeregister failed: %v", err)
177179
}
@@ -214,7 +216,7 @@ func Test_runDeregister_UserCancels(t *testing.T) {
214216
}}
215217

216218
term := terminal.New()
217-
err := runDeregister(context.Background(), term, store, deps)
219+
err := runDeregister(context.Background(), term, store, deps, false)
218220
if err != nil {
219221
t.Fatalf("expected nil error on cancel, got: %v", err)
220222
}
@@ -243,7 +245,7 @@ func Test_runDeregister_NotRegistered(t *testing.T) {
243245
defer server.Close()
244246

245247
term := terminal.New()
246-
err := runDeregister(context.Background(), term, store, deps)
248+
err := runDeregister(context.Background(), term, store, deps, false)
247249
if err == nil {
248250
t.Fatal("expected error when not registered")
249251
}
@@ -274,7 +276,7 @@ func Test_runDeregister_RemoveNodeFails(t *testing.T) {
274276
defer server.Close()
275277

276278
term := terminal.New()
277-
err := runDeregister(context.Background(), term, store, deps)
279+
err := runDeregister(context.Background(), term, store, deps, false)
278280
if err == nil {
279281
t.Fatal("expected error when RemoveNode fails")
280282
}
@@ -316,7 +318,7 @@ func Test_runDeregister_AlwaysUninstallsNetbird(t *testing.T) {
316318
deps.netbird = netbird
317319

318320
term := terminal.New()
319-
err := runDeregister(context.Background(), term, store, deps)
321+
err := runDeregister(context.Background(), term, store, deps, false)
320322
if err != nil {
321323
t.Fatalf("runDeregister failed: %v", err)
322324
}
@@ -363,7 +365,7 @@ func Test_runDeregister_RemoveBrevKeysHandling(t *testing.T) {
363365
deps.sshKeys = tt.sshKeys
364366

365367
term := terminal.New()
366-
err := runDeregister(context.Background(), term, store, deps)
368+
err := runDeregister(context.Background(), term, store, deps, false)
367369
if err != nil {
368370
t.Fatalf("runDeregister failed: %v", err)
369371
}

pkg/cmd/enablessh/enablessh.go

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import (
88
"os/exec"
99
"os/user"
1010

11+
nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1"
12+
"connectrpc.com/connect"
13+
1114
"github.com/brevdev/brev-cli/pkg/cmd/register"
15+
"github.com/brevdev/brev-cli/pkg/config"
1216
"github.com/brevdev/brev-cli/pkg/entity"
1317
breverrors "github.com/brevdev/brev-cli/pkg/errors"
1418
"github.com/brevdev/brev-cli/pkg/externalnode"
@@ -83,10 +87,11 @@ func enableSSH(
8387
reg *register.DeviceRegistration,
8488
brevUser *entity.User,
8589
) error {
86-
u, err := user.Current()
90+
linuxUser, err := user.Current()
8791
if err != nil {
8892
return fmt.Errorf("failed to determine current Linux user: %w", err)
8993
}
94+
linuxUsername := linuxUser.Username
9095

9196
checkSSHDaemon(t)
9297

@@ -95,26 +100,58 @@ func enableSSH(
95100
t.Vprint("")
96101
t.Vprintf(" Node: %s (%s)\n", reg.DisplayName, reg.ExternalNodeID)
97102
t.Vprintf(" Brev user: %s\n", brevUser.ID)
98-
t.Vprintf(" Linux user: %s\n", u.Username)
103+
t.Vprintf(" Linux user: %s\n", linuxUsername)
99104
t.Vprint("")
100105

101-
port, err := register.PromptSSHPort(t)
106+
// Check if the node already has an SSH port allocated (e.g. for another linux user)
107+
port, err := existingSSHPort(ctx, deps, tokenProvider, reg)
102108
if err != nil {
103-
return fmt.Errorf("SSH port: %w", err)
109+
t.Vprintf(" %s\n", t.Yellow(fmt.Sprintf("Warning: could not check for existing ports: %v", err)))
104110
}
105111

106-
if err := register.OpenSSHPort(ctx, t, deps.nodeClients, tokenProvider, reg, port); err != nil {
107-
return fmt.Errorf("enable SSH failed: %w", err)
112+
if port != 0 {
113+
t.Vprintf(" Using existing SSH port %d.\n", port)
114+
} else {
115+
t.Vprint("")
116+
port, err = register.PromptSSHPort(t)
117+
if err != nil {
118+
return fmt.Errorf("SSH port: %w", err)
119+
}
120+
121+
if err := register.OpenSSHPort(ctx, t, deps.nodeClients, tokenProvider, reg, port); err != nil {
122+
return fmt.Errorf("enable SSH failed: %w", err)
123+
}
108124
}
109125

110-
if err := register.GrantSSHAccessToNode(ctx, t, deps.nodeClients, tokenProvider, reg, brevUser, u); err != nil {
126+
if err := register.SetupAndRegisterNodeSSHAccess(ctx, t, deps.nodeClients, tokenProvider, reg, brevUser, linuxUsername); err != nil {
111127
return fmt.Errorf("enable SSH failed: %w", err)
112128
}
113129

114130
t.Vprint(t.Green(fmt.Sprintf("SSH access enabled. You can now SSH to this device via: brev shell %s", reg.DisplayName)))
115131
return nil
116132
}
117133

134+
// existingSSHPort calls GetNode and returns the PortNumber of an already-allocated
135+
// SSH port, or 0 if none exists
136+
func existingSSHPort(ctx context.Context, deps enableSSHDeps, tokenProvider externalnode.TokenProvider, reg *register.DeviceRegistration) (int32, error) {
137+
client := deps.nodeClients.NewNodeClient(tokenProvider, config.GlobalConfig.GetBrevPublicAPIURL())
138+
resp, err := client.GetNode(ctx, connect.NewRequest(&nodev1.GetNodeRequest{
139+
ExternalNodeId: reg.ExternalNodeID,
140+
OrganizationId: reg.OrgID,
141+
}))
142+
if err != nil {
143+
return 0, fmt.Errorf("error retrieving node: %w", err)
144+
}
145+
146+
for _, p := range resp.Msg.GetExternalNode().GetPorts() {
147+
// TODO if we ever allow more than one SSH port, this should be modified
148+
if p.GetProtocol() == nodev1.PortProtocol_PORT_PROTOCOL_SSH {
149+
return p.GetPortNumber(), nil
150+
}
151+
}
152+
return 0, nil
153+
}
154+
118155
// checkSSHDaemon prints a warning if neither "ssh" nor "sshd" systemd services
119156
// appear to be active. It never returns an error — it is best-effort.
120157
func checkSSHDaemon(t *terminal.Terminal) {

0 commit comments

Comments
 (0)