diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 6912df3ac..6aed98402 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -331,7 +331,7 @@ func createCmdTree(cmd *cobra.Command, t *terminal.Terminal, loginCmdStore *stor cmd.AddCommand(ollama.NewCmdOllama(t, loginCmdStore)) cmd.AddCommand(agentskill.NewCmdAgentSkill(t, noLoginCmdStore)) cmd.AddCommand(background.NewCmdBackground(t, loginCmdStore)) - cmd.AddCommand(status.NewCmdStatus(t, loginCmdStore)) + cmd.AddCommand(status.NewCmdStatus(t, noLoginCmdStore)) cmd.AddCommand(sshkeys.NewCmdSSHKeys(t, loginCmdStore)) cmd.AddCommand(start.NewCmdStart(t, loginCmdStore, noLoginCmdStore)) cmd.AddCommand(stop.NewCmdStop(t, loginCmdStore, noLoginCmdStore)) diff --git a/pkg/cmd/status/status.go b/pkg/cmd/status/status.go index 80830f543..d89d4f17d 100644 --- a/pkg/cmd/status/status.go +++ b/pkg/cmd/status/status.go @@ -1,38 +1,47 @@ +// Package status implements the `brev status` command, which reports the +// caller's login state, current organization, and credential metadata. package status import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/brevdev/brev-cli/pkg/auth" "github.com/brevdev/brev-cli/pkg/cmd/util" + "github.com/brevdev/brev-cli/pkg/config" "github.com/brevdev/brev-cli/pkg/entity" - "github.com/brevdev/brev-cli/pkg/store" + breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/terminal" + "github.com/golang-jwt/jwt/v5" "github.com/spf13/cobra" ) -var ( - createLong = "Create a new Brev machine" - createExample = ` - brev create - ` - // instanceTypes = []string{"p4d.24xlarge", "p3.2xlarge", "p3.8xlarge", "p3.16xlarge", "p3dn.24xlarge", "p2.xlarge", "p2.8xlarge", "p2.16xlarge", "g5.xlarge", "g5.2xlarge", "g5.4xlarge", "g5.8xlarge", "g5.16xlarge", "g5.12xlarge", "g5.24xlarge", "g5.48xlarge", "g5g.xlarge", "g5g.2xlarge", "g5g.4xlarge", "g5g.8xlarge", "g5g.16xlarge", "g5g.metal", "g4dn.xlarge", "g4dn.2xlarge", "g4dn.4xlarge", "g4dn.8xlarge", "g4dn.16xlarge", "g4dn.12xlarge", "g4dn.metal", "g4ad.xlarge", "g4ad.2xlarge", "g4ad.4xlarge", "g4ad.8xlarge", "g4ad.16xlarge", "g3s.xlarge", "g3.4xlarge", "g3.8xlarge", "g3.16xlarge"} +const ( + statusLong = "Show your Brev login status, current organization, and credential metadata." + statusExample = " brev status" + + // auth0Issuer mirrors the hard-coded issuer used in auth.StandardLogin. + auth0Issuer = "https://brevdev.us.auth0.com/" ) type StatusStore interface { - util.GetWorkspaceByNameOrIDErrStore + GetAuthTokens() (*entity.AuthTokens, error) GetActiveOrganizationOrDefault() (*entity.Organization, error) GetCurrentUser() (*entity.User, error) - GetWorkspace(workspaceID string) (*entity.Workspace, error) GetCurrentWorkspaceID() (string, error) - CreateWorkspace(organizationID string, options *store.CreateWorkspacesOptions) (*entity.Workspace, error) + GetWorkspace(workspaceID string) (*entity.Workspace, error) } func NewCmdStatus(t *terminal.Terminal, statusStore StatusStore) *cobra.Command { cmd := &cobra.Command{ - Annotations: map[string]string{"hidden": ""}, + Annotations: map[string]string{"configuration": ""}, Use: "status", DisableFlagsInUseLine: true, - Short: "Show instance status", - Long: createLong, - Example: createExample, + Short: "Show login status, organization, and credential info", + Long: statusLong, + Example: statusExample, RunE: func(cmd *cobra.Command, args []string) error { runShowStatus(t, statusStore) return nil @@ -44,18 +53,222 @@ func NewCmdStatus(t *terminal.Terminal, statusStore StatusStore) *cobra.Command func runShowStatus(t *terminal.Terminal, statusStore StatusStore) { terminal.DisplayBrevLogo(t) t.Vprintf("\n") - wsID, err := statusStore.GetCurrentWorkspaceID() + + tokens, loggedIn := showAuthStatus(t, statusStore) + if loggedIn { + showUserAndOrg(t, statusStore, tokens) + } + showWorkspaceStatus(t, statusStore) +} + +// showAuthStatus prints the login section. Returns the loaded tokens (may be nil) +// and whether the user is logged in. +func showAuthStatus(t *terminal.Terminal, statusStore StatusStore) (*entity.AuthTokens, bool) { + tokens, err := statusStore.GetAuthTokens() if err != nil { - t.Vprintf("\n Error: %s", t.Red(err.Error())) + var notFound *breverrors.CredentialsFileNotFound + if errors.As(err, ¬Found) { + printLoggedOut(t) + return nil, false + } + t.Vprintf("Status: %s (%s)\n", t.Red("unknown"), err.Error()) + return nil, false + } + if tokens == nil || (tokens.AccessToken == "" && tokens.RefreshToken == "") { + printLoggedOut(t) + return nil, false + } + + t.Vprintf("Status: %s\n", t.Green("Logged in")) + + provider, providerDetail := describeCredential(tokens) + if providerDetail != "" { + t.Vprintf("Provider: %s (%s)\n", t.Yellow(provider), providerDetail) + } else { + t.Vprintf("Provider: %s\n", t.Yellow(provider)) + } + + issuedAt, expiresAt, gotClaims := tokenIssuedAndExpiry(tokens.AccessToken) + if gotClaims { + if !issuedAt.IsZero() { + t.Vprintf("Issued at: %s\n", t.Yellow(issuedAt.Local().Format(time.RFC3339))) + } + if !expiresAt.IsZero() { + remaining := time.Until(expiresAt) + if remaining > 0 { + t.Vprintf("Expires at: %s (%s remaining)\n", + t.Yellow(expiresAt.Local().Format(time.RFC3339)), + t.Yellow(formatDuration(remaining))) + } else { + t.Vprintf("Expires at: %s (%s; refresh token will be used on next call)\n", + t.Yellow(expiresAt.Local().Format(time.RFC3339)), + t.Red("expired")) + } + } + } else if tokens.AccessToken != "" { + t.Vprintf("Token: %s\n", t.Yellow("opaque (no expiration metadata)")) + } + + if tokens.RefreshToken != "" && tokens.RefreshToken != "auto-login" { + t.Vprintf("Refresh: %s\n", t.Yellow("present")) + } else { + t.Vprintf("Refresh: %s\n", t.Yellow("absent")) + } + + return tokens, true +} + +func showUserAndOrg(t *terminal.Terminal, statusStore StatusStore, tokens *entity.AuthTokens) { + tokenEmail := "" + if tokens != nil { + tokenEmail = auth.GetEmailFromToken(tokens.AccessToken) + } + + user, userErr := statusStore.GetCurrentUser() + if userErr == nil && user != nil { + t.Vprintf("\nUser: %s\n", t.Yellow(coalesce(user.Name, user.Username, user.Email))) + t.Vprintf("\tID: %s\n", t.Yellow(user.ID)) + if user.Email != "" { + t.Vprintf("\tEmail: %s\n", t.Yellow(user.Email)) + } + if user.Username != "" && user.Username != user.Name { + t.Vprintf("\tUsername: %s\n", t.Yellow(user.Username)) + } + } else { + if tokenEmail != "" { + t.Vprintf("\nUser: %s\n", t.Yellow(tokenEmail)) + } + if userErr != nil { + t.Vprintf("\t(remote user lookup failed: %s)\n", t.Red(rootErrorMessage(userErr))) + } + } + + org, orgErr := statusStore.GetActiveOrganizationOrDefault() + if orgErr == nil && org != nil { + t.Vprintf("\nOrg: %s\n", t.Yellow(org.Name)) + t.Vprintf("\tID: %s\n", t.Yellow(org.ID)) + } else if orgErr != nil { + t.Vprintf("\nOrg: %s\n", t.Red("unknown")) + t.Vprintf("\t(remote org lookup failed: %s)\n", t.Red(rootErrorMessage(orgErr))) + } else { + t.Vprintf("\nOrg: %s\n", t.Yellow("none set")) + } +} + +func showWorkspaceStatus(t *terminal.Terminal, statusStore StatusStore) { + wsID, err := statusStore.GetCurrentWorkspaceID() + if err != nil || wsID == "" { return } ws, err := statusStore.GetWorkspace(wsID) if err != nil { - t.Vprintf("\n Error: %s", t.Red(err.Error())) + t.Vprintf("\nInstance lookup failed: %s\n", t.Red(rootErrorMessage(err))) return } + t.Vprintf("\nInstance: %s\n", t.Yellow(ws.Name)) + t.Vprintf("\tID: %s\n", t.Yellow(ws.ID)) + t.Vprintf("\tMachine: %s\n", t.Yellow(util.GetInstanceString(*ws))) +} + +// rootErrorMessage returns a single-line message from the deepest wrapped +// error, stripping the file/line trace that breverrors.WrapAndTrace prepends. +func rootErrorMessage(err error) string { + if err == nil { + return "" + } + root := breverrors.Root(err) + if root == nil { + return strings.TrimSpace(err.Error()) + } + return strings.TrimSpace(root.Error()) +} + +func printLoggedOut(t *terminal.Terminal) { + t.Vprintf("Status: %s\n", t.Red("Logged out")) + t.Vprintf("Run %s to log in.\n", t.Yellow("brev login")) +} + +// describeCredential infers the credential provider from the stored tokens. +// Returns (provider, optional detail string). +func describeCredential(tokens *entity.AuthTokens) (string, string) { + if tokens.AccessToken == "" { + return "unknown", "" + } + // FileStore.GetAuthTokens returns the Kubernetes service-account token + // with an empty refresh token when running inside a Brev workspace pod. + if tokens.RefreshToken == "" { + return "service account", "Kubernetes pod token" + } + if tokens.RefreshToken == "auto-login" { + return "manual access token", "set via `brev login --token`" + } + if tokens.AccessToken == "auto-login" { + return "manual refresh token", "set via `brev login --token`" + } + if auth.IssuerCheck(tokens.AccessToken, auth0Issuer) { + return "auth0", auth0Issuer + } + if kasIssuer := config.GlobalConfig.GetBrevAuthIssuerURL(); kasIssuer != "" && auth.IssuerCheck(tokens.AccessToken, kasIssuer) { + return "kas (NVIDIA NGC)", kasIssuer + } + if iss := tokenIssuer(tokens.AccessToken); iss != "" { + return "unknown", iss + } + return "unknown", "" +} + +func tokenIssuer(token string) string { + parser := jwt.Parser{} + claims := jwt.MapClaims{} + _, _, err := parser.ParseUnverified(token, &claims) + if err != nil { + return "" + } + iss, _ := claims["iss"].(string) + return iss +} - t.Vprintf("\nYou're on instance %s", t.Yellow(ws.Name)) - t.Vprintf("\n\tID: %s", t.Yellow(ws.ID)) - t.Vprintf("\n\tMachine: %s", t.Yellow(util.GetInstanceString(*ws))) +// tokenIssuedAndExpiry parses a JWT and returns iat and exp times. ok=false if +// the token isn't a JWT or has neither claim. +func tokenIssuedAndExpiry(token string) (issuedAt, expiresAt time.Time, ok bool) { + parser := jwt.Parser{} + claims := jwt.MapClaims{} + _, _, err := parser.ParseUnverified(token, &claims) + if err != nil { + return time.Time{}, time.Time{}, false + } + if exp, e := claims.GetExpirationTime(); e == nil && exp != nil { + expiresAt = exp.Time + } + if iat, e := claims.GetIssuedAt(); e == nil && iat != nil { + issuedAt = iat.Time + } + return issuedAt, expiresAt, !expiresAt.IsZero() || !issuedAt.IsZero() +} + +func formatDuration(d time.Duration) string { + if d < 0 { + d = -d + } + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60) + case d < 24*time.Hour: + return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60) + default: + days := int(d.Hours()) / 24 + hours := int(d.Hours()) % 24 + return fmt.Sprintf("%dd %dh", days, hours) + } +} + +func coalesce(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" }